mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-26 23:35:17 -07:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a526531d7
|
||
|
|
04c91add22
|
||
|
|
d5fb43caa3
|
||
|
|
7b92511f23
|
||
|
|
0f53b8beca
|
||
|
|
ca534cd232
|
No files matched your search
@@ -1,28 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## v0.21.1 (2026-10-26)
|
||||
|
||||
### Changed
|
||||
- **Stats Session View**: Redesigned the expanded session view in the stats dashboard.
|
||||
- New stat tiles show pace, lines, known-word rate, lookups, and paused time.
|
||||
- A reading density chart of known and unknown words replaces the cumulative chart.
|
||||
- Yomitan lookups and mined cards now appear in their own lane below the chart.
|
||||
|
||||
### Fixed
|
||||
- **Subtitle Retiming**: Mining, the subtitle sidebar, subtitle navigation, and replay now use the timing you set with mpv's subtitle delay keys (z/x and sub-step), so mined audio and images match the retimed line.
|
||||
- **Subtitle Sidebar**: mpv keys and mouse actions, like volume keys and double-click fullscreen, work again while the sidebar is open.
|
||||
|
||||
### Docs
|
||||
- **README**: Refreshed the anime screenshots using the current build.
|
||||
|
||||
<details>
|
||||
<summary>Internal changes</summary>
|
||||
|
||||
### Internal
|
||||
- **Packaging Layout**: Packaging inputs now live under `packaging/`, and all build output lands in `dist/`, with packaged apps in `dist/release/`.
|
||||
|
||||
</details>
|
||||
|
||||
## v0.21.0 (2026-10-24)
|
||||
|
||||
### Added
|
||||
|
||||
@@ -102,6 +102,10 @@ A stats dashboard tracks watch time, vocabulary growth, mining throughput, sessi
|
||||
<td><b>WebSocket</b></td>
|
||||
<td>Plain subtitle feed plus a dedicated annotated feed for texthooker pages and custom tools</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>Plugins</b></td>
|
||||
<td>Load your own JavaScript at startup to react to playback, run timers, add key-bound actions, and show results in an overlay panel. The bundled example explains the current line with Codex. <a href="https://docs.subminer.moe/plugins">Plugin guide</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<br>
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
// Sends the current subtitle line, with a few lines of context, to `codex exec` and shows the
|
||||
// answer in the SubMiner plugin panel. Registers two actions: `explain` and `translate`.
|
||||
const { mkdtemp, readFile, rm } = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
codexPath: 'codex',
|
||||
model: '',
|
||||
// Passed as -c model_reasoning_effort; low keeps answers to a few seconds. Empty uses your Codex config.
|
||||
reasoningEffort: 'low',
|
||||
before: 2,
|
||||
after: 1,
|
||||
timeoutMs: 120_000,
|
||||
pause: true,
|
||||
prompts: {},
|
||||
};
|
||||
|
||||
const ACTIONS = {
|
||||
explain: {
|
||||
title: 'Grammar',
|
||||
prompt:
|
||||
'You are a Japanese tutor. Explain the line marked >>> for an intermediate learner. ' +
|
||||
'Use the other lines only as context. Reply in English markdown: a natural translation, ' +
|
||||
'then a bullet list of the grammar points and notable vocabulary. Be concise.',
|
||||
},
|
||||
translate: {
|
||||
title: 'Translation',
|
||||
prompt:
|
||||
'Translate the line marked >>> into natural English. Use the other lines only as context. ' +
|
||||
'Reply with the translation, then one short note only if the line is ambiguous.',
|
||||
},
|
||||
};
|
||||
|
||||
function activate(ctx) {
|
||||
const settings = { ...DEFAULT_SETTINGS, ...ctx.settings };
|
||||
for (const [name, action] of Object.entries(ACTIONS)) {
|
||||
const prompt = settings.prompts?.[name] ?? action.prompt;
|
||||
ctx.actions.register(name, () => ask(ctx, settings, action.title, prompt));
|
||||
}
|
||||
}
|
||||
|
||||
function quote(text) {
|
||||
return text
|
||||
.split('\n')
|
||||
.map((line) => `> ${line}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
// Subtitles wrap across lines; flatten each cue so the >>> marker covers all of it.
|
||||
function oneLine(text) {
|
||||
return text
|
||||
.split('\n')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function tail(text, lineCount) {
|
||||
return text.trim().split('\n').slice(-lineCount).join('\n');
|
||||
}
|
||||
|
||||
async function ask(ctx, settings, title, instructions) {
|
||||
const { before, current, after } = ctx.subtitles.context({
|
||||
before: settings.before,
|
||||
after: settings.after,
|
||||
});
|
||||
if (!current) {
|
||||
ctx.notify('No subtitle line to send.', { variant: 'warning' });
|
||||
return;
|
||||
}
|
||||
if (settings.pause) ctx.mpv.command(['set', 'pause', 'yes']);
|
||||
|
||||
const header = quote(current.text);
|
||||
const panel = ctx.ui.panel({ title, markdown: `${header}\n\n_Asking Codex..._` });
|
||||
const prompt = [
|
||||
instructions,
|
||||
'',
|
||||
'Subtitles:',
|
||||
...before.map((line) => oneLine(line.text)),
|
||||
`>>> ${oneLine(current.text)}`,
|
||||
...after.map((line) => oneLine(line.text)),
|
||||
].join('\n');
|
||||
|
||||
// Codex runs in an empty read-only directory so any command it tries cannot touch real files.
|
||||
const workDir = await mkdtemp(path.join(os.tmpdir(), 'subminer-ai-explain-'));
|
||||
const answerPath = path.join(workDir, 'answer.md');
|
||||
const args = [
|
||||
'exec',
|
||||
'--ephemeral',
|
||||
'--skip-git-repo-check',
|
||||
'--sandbox',
|
||||
'read-only',
|
||||
'--color',
|
||||
'never',
|
||||
'-C',
|
||||
workDir,
|
||||
'-o',
|
||||
answerPath,
|
||||
];
|
||||
if (settings.model) args.push('-m', settings.model);
|
||||
if (settings.reasoningEffort) {
|
||||
args.push('-c', `model_reasoning_effort=${JSON.stringify(settings.reasoningEffort)}`);
|
||||
}
|
||||
args.push('-');
|
||||
|
||||
try {
|
||||
const result = await ctx.exec(settings.codexPath, args, {
|
||||
stdin: prompt,
|
||||
timeoutMs: settings.timeoutMs,
|
||||
signal: panel.signal,
|
||||
});
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(tail(result.stderr, 6) || `codex exited with code ${result.exitCode}`);
|
||||
}
|
||||
const answer = (await readFile(answerPath, 'utf8')).trim();
|
||||
panel.update({ markdown: `${header}\n\n${answer || '_Codex returned an empty answer._'}` });
|
||||
} catch (error) {
|
||||
// Closing the panel aborts the request; there is nobody left to show the error to.
|
||||
if (panel.signal.aborted) return;
|
||||
const message =
|
||||
error?.code === 'ENOENT'
|
||||
? `Cannot find \`${settings.codexPath}\`. Set \`plugins.ai-explain.settings.codexPath\` to its full path.`
|
||||
: String(error?.message ?? error);
|
||||
ctx.log.warn(`Codex request failed: ${message}`);
|
||||
panel.update({
|
||||
markdown: `${header}\n\n**Codex request failed.**\n\n\`\`\`\n${message}\n\`\`\``,
|
||||
});
|
||||
} finally {
|
||||
await rm(workDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { activate };
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "AI Explain",
|
||||
"version": "1.0.0",
|
||||
"main": "main.js"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
type: added
|
||||
area: plugins
|
||||
|
||||
- Added JavaScript plugins loaded at startup from `<config dir>/plugins/<id>/` and enabled under `plugins` in the config. Plugins can listen to mpv events, run timers, register actions bound with `__plugin-action:<id>/<action>` keybindings or run with `--plugin-action`, run built-in actions, start mpv, run programs, keep state in a per-plugin data folder, and show results in an overlay panel or their own window.
|
||||
- Added an example `ai-explain` plugin that sends the current line and its neighbors to `codex exec` and shows a translation or grammar explanation.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: docs
|
||||
area: readme
|
||||
|
||||
- Refreshed the README's anime screenshots using the current SubMiner build.
|
||||
@@ -0,0 +1,5 @@
|
||||
type: internal
|
||||
area: build
|
||||
|
||||
- Moved packaging inputs (electron-builder resources, Bun runtime manifests and licenses, committed release notes) under `packaging/`.
|
||||
- Build output now lands in `dist/`: packaged apps in `dist/release/`, staged Yomitan and Hachidori in `dist/yomitan/` and `dist/hachidori/`.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- mpv keys and mouse buttons (such as `9`/`0` volume and double-click fullscreen) pass through the overlay again while the subtitle sidebar is open; only input inside the sidebar stays with it.
|
||||
@@ -218,6 +218,14 @@
|
||||
"overlayPosition": "top-right" // Position for in-overlay notification cards. Values: top-left | top | top-right
|
||||
}, // Overlay notification display behavior.
|
||||
|
||||
// ==========================================
|
||||
// Plugins
|
||||
// JavaScript plugins loaded from <config dir>/plugins/<id>/ at startup.
|
||||
// Plugins run inside SubMiner with full access to your system. Only enable code you trust.
|
||||
// Restart SubMiner after changing plugins.
|
||||
// ==========================================
|
||||
"plugins": {}, // Plugins to load at startup, keyed by directory name under <config dir>/plugins/. Each entry is { "enabled": true, "settings": { ... } }; settings are passed to the plugin as ctx.settings.
|
||||
|
||||
// ==========================================
|
||||
// Keyboard Shortcuts
|
||||
// Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
||||
|
||||
@@ -221,6 +221,7 @@ const sidebar: DefaultTheme.SidebarItem[] = [
|
||||
{ text: 'AniList', link: '/anilist-integration' },
|
||||
{ text: 'AniSkip', link: '/aniskip-integration' },
|
||||
{ text: 'Character Dictionary', link: '/character-dictionary' },
|
||||
{ text: 'Plugins', link: '/plugins' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,28 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## v0.21.1 (2026-10-26)
|
||||
|
||||
**Changed**
|
||||
- **Stats Session View**: Redesigned the expanded session view in the stats dashboard.
|
||||
- New stat tiles show pace, lines, known-word rate, lookups, and paused time.
|
||||
- A reading density chart of known and unknown words replaces the cumulative chart.
|
||||
- Yomitan lookups and mined cards now appear in their own lane below the chart.
|
||||
|
||||
**Fixed**
|
||||
- **Subtitle Retiming**: Mining, the subtitle sidebar, subtitle navigation, and replay now use the timing you set with mpv's subtitle delay keys (z/x and sub-step), so mined audio and images match the retimed line.
|
||||
- **Subtitle Sidebar**: mpv keys and mouse actions, like volume keys and double-click fullscreen, work again while the sidebar is open.
|
||||
|
||||
**Docs**
|
||||
- **README**: Refreshed the anime screenshots using the current build.
|
||||
|
||||
<details>
|
||||
<summary>Internal changes</summary>
|
||||
|
||||
**Internal**
|
||||
- **Packaging Layout**: Packaging inputs now live under `packaging/`, and all build output lands in `dist/`, with packaged apps in `dist/release/`.
|
||||
|
||||
</details>
|
||||
|
||||
## v0.21.0 (2026-10-24)
|
||||
|
||||
**Added**
|
||||
|
||||
@@ -106,6 +106,15 @@ Overlay notifications are also kept in a session-only history panel. Toggle it w
|
||||
|
||||
Mining and startup status notifications use `ankiConnect.behavior.notificationType` (see [AnkiConnect](#ankiconnect)).
|
||||
|
||||
### Plugins
|
||||
|
||||
`plugins` enables [plugins](/plugins) from the `plugins/` folder next to this config file. Each key is a plugin folder name. Changes apply after a restart.
|
||||
|
||||
| Key | Default | What it does |
|
||||
| ----------------------- | ------- | -------------------------------------- |
|
||||
| `plugins.<id>.enabled` | `false` | Load the plugin at startup |
|
||||
| `plugins.<id>.settings` | `{}` | Passed to the plugin as `ctx.settings` |
|
||||
|
||||
### Auto-start overlay
|
||||
|
||||
When mpv is started by SubMiner or the `subminer` launcher, the launcher passes these settings to the bundled mpv plugin. There is no separate plugin config file. `mpv.autoStartSubMiner` and `mpv.pauseUntilOverlayReady` (see [MPV launcher](#mpv-launcher)) control the background start and the initial pause.
|
||||
@@ -268,7 +277,7 @@ Adds a modal for choosing mpv's primary and secondary subtitle tracks. Open it w
|
||||
|
||||
- `key` uses `KeyboardEvent.code` names (`Space`, `KeyR`, `ArrowRight`) with optional `Ctrl+`, `Alt+`, `Shift+`, `Meta+`. Mouse buttons are `MBTN_LEFT`, `MBTN_MID`, `MBTN_RIGHT`, `MBTN_BACK`, `MBTN_FORWARD`. Scroll wheel keys are `WHEEL_UP`, `WHEEL_DOWN`, `WHEEL_LEFT`, `WHEEL_RIGHT`.
|
||||
- `command` is any mpv JSON IPC command array. Set it to `null` to disable a default.
|
||||
- Commands starting with `__` run inside SubMiner: `__playlist-browser-open`, `__youtube-picker-open`, `__replay-subtitle`, `__play-next-subtitle`, `__runtime-options-open`, and `__runtime-option-cycle:<id>[:next|prev]`.
|
||||
- Commands starting with `__` run inside SubMiner: `__playlist-browser-open`, `__youtube-picker-open`, `__replay-subtitle`, `__play-next-subtitle`, `__runtime-options-open`, `__runtime-option-cycle:<id>[:next|prev]`, and `__plugin-action:<plugin id>/<action>` (see [Plugins](/plugins)).
|
||||
- Unused single-key, mouse button, and scroll wheel bindings from your mpv config also work in the overlay. Your SubMiner bindings win on conflicts.
|
||||
|
||||
### Shortcuts configuration
|
||||
|
||||
@@ -70,7 +70,7 @@ Charts for watch time, cards, words, and sessions per day or month, running tota
|
||||
|
||||
### Sessions
|
||||
|
||||
Session history. Expand a session for its pace, lines, known-word rate, lookups, and paused time, plus a reading density chart of known and unknown words over the session with Yomitan lookup ticks and clickable card-mined markers underneath. The **↗** button on a row opens that show's detail view.
|
||||
Session history with new-word activity and pause, seek, and card markers. The **↗** button on a row opens that show's detail view.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# Plugins
|
||||
|
||||
Plugins are JavaScript files that SubMiner loads at startup. A plugin can react to playback, run code on a timer, add actions you bind to keys, run programs, and show results in an overlay panel, all without rebuilding SubMiner.
|
||||
|
||||
::: warning Only run plugins you trust
|
||||
Plugins run inside SubMiner with the same access to your files and system as SubMiner itself. There is no sandbox.
|
||||
:::
|
||||
|
||||
These are SubMiner app plugins. The Lua script that runs inside mpv is the separate [mpv plugin](/mpv-plugin).
|
||||
|
||||
## Install a plugin
|
||||
|
||||
1. Put the plugin in its own folder in `plugins/` next to your [config file](/configuration#configuration-file), for example `~/.config/SubMiner/plugins/ai-explain/`. The folder name is the plugin id.
|
||||
2. Enable it in `config.jsonc`. `settings` is passed to the plugin as-is:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"plugins": {
|
||||
"ai-explain": { "enabled": true, "settings": { "reasoningEffort": "medium" } },
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
3. Bind its actions to keys with the `__plugin-action:<plugin id>/<action>` command:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"keybindings": [
|
||||
{ "key": "Ctrl+KeyE", "command": ["__plugin-action:ai-explain/explain"] },
|
||||
{ "key": "Ctrl+KeyT", "command": ["__plugin-action:ai-explain/translate"] },
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
To run an action from a script or a window-manager shortcut, use `subminer app --plugin-action ai-explain/explain`. It goes to the running SubMiner, or starts one if none is running.
|
||||
|
||||
4. Restart SubMiner. Plugin changes apply on the next start; keybinding changes apply immediately.
|
||||
|
||||
Load errors and plugin log output go to the app log with a `plugin:<id>` scope.
|
||||
|
||||
## Example: explain the current line with Codex
|
||||
|
||||
The repository ships [`assets/plugins/ai-explain`](https://github.com/ksyasuda/SubMiner/tree/main/assets/plugins/ai-explain). It sends the current line, plus the lines around it, to [`codex exec`](https://github.com/openai/codex) and shows the answer in a panel. It needs a logged-in Codex CLI.
|
||||
|
||||
| Action | What it does |
|
||||
| ----------- | --------------------------------------------------------------- |
|
||||
| `explain` | Translation plus a breakdown of the grammar and vocabulary |
|
||||
| `translate` | Natural English translation, with a note only when it's unclear |
|
||||
|
||||
| Setting | Default | What it does |
|
||||
| ----------------- | --------- | ----------------------------------------------------------------- |
|
||||
| `codexPath` | `"codex"` | Codex binary. Use a full path if SubMiner can't find it on `PATH` |
|
||||
| `model` | `""` | Model passed to `codex exec -m`; empty uses your Codex config |
|
||||
| `reasoningEffort` | `"low"` | Codex reasoning effort; empty uses your Codex config |
|
||||
| `before`, `after` | `2`, `1` | Lines of context sent before and after the current line |
|
||||
| `pause` | `true` | Pause playback when the panel opens |
|
||||
| `timeoutMs` | `120000` | Give up after this long |
|
||||
| `prompts` | `{}` | Replace an action's instructions, e.g. `{ "translate": "..." }` |
|
||||
|
||||
Press `Esc` to close the panel. Closing it, or starting another request, stops the running Codex process.
|
||||
|
||||
## Write a plugin
|
||||
|
||||
A plugin folder needs a `plugin.json`:
|
||||
|
||||
```json
|
||||
{ "name": "My Plugin", "version": "1.0.0", "main": "main.js" }
|
||||
```
|
||||
|
||||
`main` points to a CommonJS module (`.js`) or an ES module (`.mjs`) that exports `activate(ctx)`. It may return a cleanup function, or the module can export `deactivate()`. On quit, SubMiner waits up to 3 seconds for it, including any promise it returns. Everything a plugin registers through `ctx` (listeners, timers, actions, panels, running programs) is cleaned up automatically when SubMiner quits.
|
||||
|
||||
```js
|
||||
function activate(ctx) {
|
||||
ctx.mpv.on('media-path-change', ({ path }) => ctx.log.info(`Now playing ${path}`));
|
||||
ctx.every(60_000, () => ctx.log.info('Still watching'));
|
||||
ctx.actions.register('hello', () => {
|
||||
const line = ctx.subtitles.current();
|
||||
ctx.ui.panel({ title: 'Hello', markdown: line ? `> ${line.text}` : '_No subtitle_' });
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { activate };
|
||||
```
|
||||
|
||||
An error thrown by a plugin is logged and does not crash SubMiner. A plugin that blocks with a long synchronous loop still freezes the app, so keep slow work asynchronous.
|
||||
|
||||
### `ctx` reference
|
||||
|
||||
| Member | What it does |
|
||||
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `id`, `dir`, `settings` | Plugin id, plugin folder path, and `plugins.<id>.settings` |
|
||||
| `dataDir` | `plugin-data/<id>/` next to your config file, for caches and state. Exists before `activate` runs |
|
||||
| `log.debug/info/warn/error(message)` | Write to the app log |
|
||||
| `every(ms, fn)`, `after(ms, fn)` | Repeating and one-shot timers. Return a function that cancels them |
|
||||
| `mpv.on(event, fn)` | Listen to mpv events (below). Returns an unsubscribe function |
|
||||
| `mpv.command(args)` | Send an mpv command, e.g. `['set', 'pause', 'yes']` |
|
||||
| `mpv.getProperty(name)` | Read an mpv property (Promise) |
|
||||
| `mpv.connected` | Whether mpv is connected |
|
||||
| `mpv.ensureRunning()` | Connect to mpv, starting an idle mpv if none is running. Resolves `false` if that fails |
|
||||
| `subtitles.current()` | The displayed line as `{ text, startTime, endTime }`, or `null` |
|
||||
| `subtitles.context({ before, after })` | `{ current, before, after }`. Neighbors come from the loaded subtitle file, or recent lines for streams |
|
||||
| `actions.register(name, fn)` | Add an action, bindable as `__plugin-action:<id>/<name>` |
|
||||
| `actions.run(actionId, payload)` | Run a built-in action, the same ids keybindings use, e.g. `openJimaku` or `mineSentence` |
|
||||
| `notify(message, { variant })` | Overlay notification. `variant` is `info`, `warning`, or `error` |
|
||||
| `ui.panel({ title, markdown })` | Open the plugin panel. Returns `{ update(content), close(), closed, signal }` |
|
||||
| `ui.openWindow({ html, preload })` | Open a window from the plugin folder (below). Returns the Electron `BrowserWindow` |
|
||||
| `exec(command, args, options)` | Run a program without a shell (Windows `.cmd`/`.bat` shims go through `cmd.exe`). Resolves `{ exitCode, stdout, stderr }` |
|
||||
| `unstable` | SubMiner internals. These change between releases without notice |
|
||||
|
||||
A panel's `signal` aborts when the user closes it or another panel replaces it. Pass it to `exec` to stop work nobody will see. Only one panel is open at a time. Panel markdown supports headings, lists, quotes, code, bold, and italics; HTML is shown as plain text.
|
||||
|
||||
`exec` options are `stdin`, `cwd`, `env`, `timeoutMs`, and `signal`. It resolves for any exit code and rejects when the program can't start, times out, or is aborted.
|
||||
|
||||
`ui.openWindow` takes `html` and an optional `preload`, both relative to the plugin folder, plus `title`, `width`, and `height`. Opening the same `html` again focuses the open window. The page runs with context isolation and no Node access. The preload is sandboxed too: it can `require('electron')` but nothing else, so bundle anything more into it. Expose what the page needs from the preload with `contextBridge`, and handle its messages with `ipcMain` from `require('electron')` in the plugin. Prefix channel names with the plugin id. Plugin windows close when SubMiner quits.
|
||||
|
||||
mpv events: `connection-change`, `subtitle-change`, `subtitle-timing`, `time-pos-change`, `duration-change`, `pause-change`, `fullscreen-change`, `media-path-change`, `media-title-change`, `subtitle-track-change`, `subtitle-track-list-change`, `secondary-subtitle-change`, `secondary-subtitle-track-change`, `secondary-subtitle-delay-change`, `secondary-subtitle-visibility`, `client-message`, and `end-file`. `end-file` gives `{ reason, playlistEntryId, error }`, where `reason` is `eof`, `stop`, `quit`, `error`, `redirect`, or `unknown`.
|
||||
@@ -218,6 +218,14 @@
|
||||
"overlayPosition": "top-right" // Position for in-overlay notification cards. Values: top-left | top | top-right
|
||||
}, // Overlay notification display behavior.
|
||||
|
||||
// ==========================================
|
||||
// Plugins
|
||||
// JavaScript plugins loaded from <config dir>/plugins/<id>/ at startup.
|
||||
// Plugins run inside SubMiner with full access to your system. Only enable code you trust.
|
||||
// Restart SubMiner after changing plugins.
|
||||
// ==========================================
|
||||
"plugins": {}, // Plugins to load at startup, keyed by directory name under <config dir>/plugins/. Each entry is { "enabled": true, "settings": { ... } }; settings are passed to the plugin as ctx.settings.
|
||||
|
||||
// ==========================================
|
||||
// Keyboard Shortcuts
|
||||
// Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
||||
|
||||
@@ -138,6 +138,8 @@ The plugin's `v` replaces mpv's own subtitle visibility toggle. When the overlay
|
||||
}
|
||||
```
|
||||
|
||||
To run a [plugin](/plugins) action, use `["__plugin-action:<plugin id>/<action>"]` as the command.
|
||||
|
||||
Mouse button names are `MBTN_LEFT`, `MBTN_MID`, `MBTN_RIGHT`, `MBTN_BACK`, and `MBTN_FORWARD`. Scroll wheel names are `WHEEL_UP`, `WHEEL_DOWN`, `WHEEL_LEFT`, and `WHEEL_RIGHT`. See [keybindings](/configuration#keybindings) and [shortcuts configuration](/configuration#shortcuts-configuration) in the config reference.
|
||||
|
||||
## Automatic mpv bindings
|
||||
|
||||
@@ -104,6 +104,7 @@ subminer doctor # check dependencies, config, and the mpv soc
|
||||
subminer generate-subs video.mkv # make Japanese subtitles from the audio
|
||||
subminer logs -e # export a log ZIP for bug reports
|
||||
subminer app --setup # reopen first-run setup
|
||||
subminer app --plugin-action <id>/<name> # run a plugin action
|
||||
subminer -u # update SubMiner
|
||||
```
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ Update checks and startup launcher migration share a serialized update-state sto
|
||||
- `subtitle-generation-reference.ts` ranks mpv's loaded text subtitle tracks, excludes signs/songs and forced references, and extracts timing hints with FFmpeg. The overlay and launcher snapshot references only for matching media, including active subtitle delays. Hints guide long-passage cuts with or without VAD; they never limit audio coverage or replace Whisper timestamps.
|
||||
- `src/main/runtime/subtitle-selection.ts` reads and validates mpv subtitle tracks and applies primary/secondary selections. Its opt-in session shortcut opens the shared overlay modal window, with renderer focus and subtitle suppression handled by the modal registry.
|
||||
- `src/shared/session-key-sequences.ts` rejects sequence prefixes reserved by single-key actions. The session-binding compiler reserves configured and built-in overlay keys; `src/main/runtime/session-bindings-runtime.ts` adds active mpv bindings and publishes the effective list to both the plugin artifact and the renderer through `session-bindings:changed`. mpv no-op `ignore` bindings do not reserve prefixes.
|
||||
- `src/core/services/plugins/` owns user plugins: discovery from `<config dir>/plugins/<id>/plugin.json` for ids enabled in `plugins`, the per-plugin `ctx`, guarded callbacks, and `exec` (no shell, AppImage `LD_LIBRARY_PATH` dropped). `main.ts` supplies the host's deps, attaches each new mpv client so plugin listeners survive client replacement, starts plugins from the app-ready path after logging is configured, and unloads them on `before-quit`. Plugins run in the main process with no sandbox. Plugin actions are the `runPluginAction` session action (keybinding command `__plugin-action:<id>/<name>`, CLI `--plugin-action <id>/<name>`); `runAction` waits for an in-progress load so a cold-start CLI action is not dropped. Each plugin gets `<config dir>/plugin-data/<id>/` as `ctx.dataDir`. `src/main/runtime/plugin-window-runtime.ts` owns plugin windows (`ctx.ui.openWindow`): one window per plugin + HTML path, raised above the overlay like the settings windows, destroyed on unload. `src/main/runtime/plugin-panel-runtime.ts` owns the single `plugin-panel` modal: a new panel aborts the previous handle's signal, updates broadcast to both overlay windows keyed by `panelId`, and the latest content is re-sent once the renderer acknowledges the open.
|
||||
- `src/renderer/` owns overlay rendering and input behavior.
|
||||
- `src/config/` owns config definitions, defaults, loading, and resolution.
|
||||
- `src/types/` owns shared cross-runtime contracts via domain entrypoints; `src/types.ts` stays a compatibility barrel.
|
||||
|
||||
@@ -34,6 +34,7 @@ Read when: you need to find the owner module for a behavior or test surface
|
||||
- AniList tracking + character dictionary: `src/core/services/anilist/`, `src/main/runtime/composers/anilist-*`, `src/main/character-dictionary-runtime.ts`, `src/main/character-dictionary-runtime/`
|
||||
- TMDB live-action metadata: `src/core/services/tmdb/` (client + exact-title resolver), `src/core/services/immersion-tracker/live-action-link.ts` (links an entry to a TMDB title and merges other holders of the same title). The AniList cover-art fetcher calls the resolver as its fallback; `imm_anime.media_kind` marks the result and keeps the entry out of AniList season repair.
|
||||
- Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*`
|
||||
- User plugins: `src/core/services/plugins/` (host, `ctx`, `exec`), `src/main/runtime/plugin-panel-runtime.ts` (panel modal), `src/main/runtime/plugin-window-runtime.ts` (plugin windows), `src/renderer/modals/plugin-panel*.ts`; example in `assets/plugins/`
|
||||
- Window trackers: `src/window-trackers/`
|
||||
- Stats HTTP app: `src/core/services/stats-server.ts`, with route groups and shared route support
|
||||
in `src/core/services/stats-server/`
|
||||
|
||||
@@ -123,7 +123,8 @@ whenever the behavior does not depend on a specific compositor.
|
||||
per-window screenshots), mpv over its JSON IPC socket, and app commands through the
|
||||
normal CLI handoff (`--mine-sentence`, `--toggle-subtitle-sidebar`, ...).
|
||||
- Scenarios live in `e2e/*.e2e.test.ts` and share one session per file through
|
||||
`startE2eSession()`. Pass `config` to override settings for a scenario file.
|
||||
`startE2eSession()`. Pass `config` to override settings for a scenario file, and `files`
|
||||
to write files such as plugins into the isolated config dir before launch.
|
||||
- On a failed startup the temp dir is kept and its path is in the error. Set
|
||||
`SUBMINER_E2E_KEEP=1` to keep it after any run (`app-stdio.log`, `app.log`, `mpv.log`).
|
||||
|
||||
|
||||
@@ -37,12 +37,10 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
// A recording AnkiConnect stand-in: one deck and one Lapis-shaped note type.
|
||||
// Added notes (with later `updateNoteFields` writes) are echoed back by `notesInfo`
|
||||
// so the app can resolve their fields; scenarios still assert on `requests`.
|
||||
// A recording AnkiConnect stand-in: an empty collection with one deck and one
|
||||
// Lapis-shaped note type. Scenarios assert on `requests`, not on stored notes.
|
||||
export async function startFakeAnki(): Promise<FakeAnki> {
|
||||
const requests: AnkiRequest[] = [];
|
||||
const addedNotes = new Map<number, Record<string, unknown>>();
|
||||
let nextNoteId = 1000;
|
||||
|
||||
const handle = (body: unknown): unknown => {
|
||||
@@ -61,38 +59,10 @@ export async function startFakeAnki(): Promise<FakeAnki> {
|
||||
return [FAKE_ANKI_MODEL];
|
||||
case 'modelFieldNames':
|
||||
return MODEL_FIELDS;
|
||||
case 'addNote': {
|
||||
const noteId = nextNoteId++;
|
||||
const note = isRecord(params.note) ? params.note : {};
|
||||
addedNotes.set(noteId, isRecord(note.fields) ? { ...note.fields } : {});
|
||||
return noteId;
|
||||
}
|
||||
case 'updateNoteFields': {
|
||||
const note = isRecord(params.note) ? params.note : {};
|
||||
const fields = typeof note.id === 'number' ? addedNotes.get(note.id) : undefined;
|
||||
if (fields && isRecord(note.fields)) Object.assign(fields, note.fields);
|
||||
return null;
|
||||
}
|
||||
case 'notesInfo': {
|
||||
// Like AnkiConnect: one entry per requested id, `{}` for unknown notes.
|
||||
const ids = Array.isArray(params.notes) ? params.notes : [];
|
||||
return ids.map((id) => {
|
||||
const fields = typeof id === 'number' ? addedNotes.get(id) : undefined;
|
||||
if (!fields) return {};
|
||||
return {
|
||||
noteId: id,
|
||||
modelName: FAKE_ANKI_MODEL,
|
||||
tags: [],
|
||||
fields: Object.fromEntries(
|
||||
MODEL_FIELDS.map((name, order) => [
|
||||
name,
|
||||
{ value: String(fields[name] ?? ''), order },
|
||||
]),
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
case 'addNote':
|
||||
return nextNoteId++;
|
||||
case 'findNotes':
|
||||
case 'notesInfo':
|
||||
return [];
|
||||
case 'storeMediaFile':
|
||||
return params.filename ?? null;
|
||||
|
||||
@@ -28,6 +28,8 @@ type JsonValue = string | number | boolean | null | JsonValue[] | JsonObject;
|
||||
export type E2eSessionOptions = {
|
||||
/** Deep-merged over the harness config, for scenarios that need other settings. */
|
||||
config?: JsonObject;
|
||||
/** Files written before launch, keyed by path relative to the config dir (e.g. plugins). */
|
||||
files?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type AppTarget = {
|
||||
@@ -193,6 +195,15 @@ export async function startE2eSession(options: E2eSessionOptions = {}): Promise<
|
||||
|
||||
const config = mergeConfig(buildConfig(mpvSocketPath, anki.url), options.config ?? {});
|
||||
fs.writeFileSync(path.join(userDataDir, 'config.jsonc'), JSON.stringify(config, null, 2));
|
||||
for (const [relativePath, content] of Object.entries(options.files ?? {})) {
|
||||
const filePath = path.resolve(userDataDir, relativePath);
|
||||
const inside = path.relative(userDataDir, filePath);
|
||||
if (!inside || inside.startsWith('..') || path.isAbsolute(inside)) {
|
||||
throw new Error(`E2E file path must stay inside the config dir: ${relativePath}`);
|
||||
}
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, content);
|
||||
}
|
||||
|
||||
const env: EnvDelta = {
|
||||
set: {
|
||||
|
||||
@@ -91,14 +91,6 @@ async function showCue(cue: (typeof FIXTURE_CUES)[number]): Promise<RenderedToke
|
||||
});
|
||||
}
|
||||
|
||||
// Card-kind flags are written before media, so wait for the update carrying audio.
|
||||
function findMediaUpdate(session: E2eSession) {
|
||||
return session.anki.requests.find(
|
||||
(request) =>
|
||||
request.action === 'updateNoteFields' && JSON.stringify(request.params).includes('[sound:'),
|
||||
);
|
||||
}
|
||||
|
||||
function hasToken(tokens: RenderedToken[], surface: string, headword: string): boolean {
|
||||
return tokens.some((token) => token.surface === surface && token.headword === headword);
|
||||
}
|
||||
@@ -134,10 +126,10 @@ test(
|
||||
session.anki.requests.length = 0;
|
||||
|
||||
await session.runAppCommand('--mine-sentence');
|
||||
const update = await waitUntil(() => findMediaUpdate(session), {
|
||||
description: 'media fields to be written to the mined note',
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
const update = await waitUntil(
|
||||
() => session.anki.requests.find((request) => request.action === 'updateNoteFields'),
|
||||
{ description: 'media fields to be written to the mined note', timeoutMs: 30_000 },
|
||||
);
|
||||
|
||||
const added = session.anki.requests.filter((request) => request.action === 'addNote');
|
||||
assert.equal(added.length, 1);
|
||||
@@ -164,38 +156,6 @@ test(
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'mining a line retimed with sub-delay clips it where mpv plays it',
|
||||
{ timeout: TEST_TIMEOUT_MS },
|
||||
async () => {
|
||||
const { session, overlay } = await useSession();
|
||||
const cue = FIXTURE_CUES[1];
|
||||
const delay = 2;
|
||||
await session.mpv.command('set_property', 'sub-delay', delay);
|
||||
try {
|
||||
// Past the cue's own end, so the line is only on screen because of the delay.
|
||||
await session.mpv.command('seek', cue.end + delay / 2, 'absolute+exact');
|
||||
await overlay.waitFor(
|
||||
`document.getElementById('subtitleRoot').textContent === ${JSON.stringify(cue.text)}`,
|
||||
{ description: 'overlay to show the delayed line' },
|
||||
);
|
||||
session.anki.requests.length = 0;
|
||||
|
||||
await session.runAppCommand('--mine-sentence');
|
||||
await waitUntil(() => findMediaUpdate(session), {
|
||||
description: 'media fields to be written to the mined note',
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
|
||||
// MiscInfo stamps the clip start: the cue's start on the video clock.
|
||||
const written = JSON.stringify(session.anki.requests);
|
||||
assert.ok(written.includes(`(00:00:0${cue.start + delay})`), written);
|
||||
} finally {
|
||||
await session.mpv.command('set_property', 'sub-delay', 0);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'open subtitle sidebar lists the parsed cues and follows playback',
|
||||
{ timeout: TEST_TIMEOUT_MS },
|
||||
@@ -224,49 +184,6 @@ test(
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'subtitle sidebar follows and seeks a sub-delay retimed track',
|
||||
{ timeout: TEST_TIMEOUT_MS },
|
||||
async () => {
|
||||
const { session, overlay } = await useSession();
|
||||
const delay = 2;
|
||||
await session.mpv.command('set_property', 'sub-delay', delay);
|
||||
try {
|
||||
// At 9s cue 2 starts on the file's clock, but mpv is still showing cue 1.
|
||||
await session.mpv.command('seek', FIXTURE_CUES[2].start, 'absolute+exact');
|
||||
await overlay.waitFor(
|
||||
`document.getElementById('subtitleRoot').textContent === ${JSON.stringify(FIXTURE_CUES[1].text)}`,
|
||||
{ description: 'overlay to show the delayed line' },
|
||||
);
|
||||
await session.runAppCommand('--toggle-subtitle-sidebar');
|
||||
await overlay.waitFor(
|
||||
`document.querySelector('#subtitleSidebarList .subtitle-sidebar-item.active')?.dataset.index === '1'`,
|
||||
{ description: 'sidebar to highlight the delayed line' },
|
||||
);
|
||||
|
||||
await overlay.evaluate(
|
||||
`document.querySelector('#subtitleSidebarList .subtitle-sidebar-item[data-index="2"]').click()`,
|
||||
);
|
||||
const target = FIXTURE_CUES[2].start + delay;
|
||||
await waitUntil(
|
||||
async () => {
|
||||
const time = Number(await session.mpv.command('get_property', 'time-pos'));
|
||||
return time >= target && time < target + 0.5;
|
||||
},
|
||||
{ description: `click to seek to cue 2 at ${target}s on the video clock` },
|
||||
);
|
||||
|
||||
await session.runAppCommand('--toggle-subtitle-sidebar');
|
||||
await overlay.waitFor(
|
||||
`document.getElementById('subtitleSidebarModal').classList.contains('hidden')`,
|
||||
{ description: 'sidebar to close' },
|
||||
);
|
||||
} finally {
|
||||
await session.mpv.command('set_property', 'sub-delay', 0);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Runs last: it leaves the lookup popup open over the overlay.
|
||||
test(
|
||||
'holding Shift over a word opens the Yomitan popup with its dictionary entry',
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test, { after } from 'node:test';
|
||||
import type { CdpPage } from './harness/cdp';
|
||||
import { FIXTURE_CUES } from './harness/fixtures';
|
||||
import { OVERLAY_PAGE, startE2eSession, type E2eSession } from './harness/session';
|
||||
import { waitUntil } from './harness/wait';
|
||||
|
||||
// Bun caps node:test hooks at 5s whatever their timeout option says, so the
|
||||
// session boots inside whichever test runs first and every test budgets for it.
|
||||
const TEST_TIMEOUT_MS = 120_000;
|
||||
|
||||
// Opens a panel describing the subtitle context and a child process result, and
|
||||
// logs when the panel's signal aborts so the test can see the close reach main.
|
||||
const PROBE_PLUGIN = `
|
||||
const { ipcMain } = require('electron');
|
||||
|
||||
exports.activate = (ctx) => {
|
||||
ipcMain.handle('e2e-probe:info', () => ({ dataDir: ctx.dataDir, greeting: ctx.settings.greeting }));
|
||||
ctx.actions.register('window', () => {
|
||||
ctx.ui.openWindow({ html: 'ui/index.html', preload: 'preload.js', title: 'E2E Probe Window' });
|
||||
});
|
||||
ctx.mpv.on('end-file', ({ reason }) => ctx.log.info('probe end-file ' + reason));
|
||||
ctx.actions.register('show', () => {
|
||||
const { before, current, after } = ctx.subtitles.context({ before: 1, after: 1 });
|
||||
const panel = ctx.ui.panel({ title: 'Probe', markdown: '_working_' });
|
||||
panel.signal.addEventListener('abort', () => ctx.log.info('probe panel aborted'));
|
||||
ctx
|
||||
.exec(process.execPath, ['-e', 'process.stdout.write("child-ok")'], {
|
||||
env: { ELECTRON_RUN_AS_NODE: '1' },
|
||||
signal: panel.signal,
|
||||
})
|
||||
.then((result) =>
|
||||
panel.update({
|
||||
markdown: [
|
||||
'## ' + ctx.settings.greeting,
|
||||
'',
|
||||
'- before: ' + before.map((line) => line.text).join('|'),
|
||||
'- current: **' + (current ? current.text : '') + '**',
|
||||
'- after: ' + after.map((line) => line.text).join('|'),
|
||||
'- exec: ' + result.stdout,
|
||||
].join('\\n'),
|
||||
}),
|
||||
)
|
||||
.catch((error) => panel.update({ markdown: 'exec failed: ' + error.message }));
|
||||
});
|
||||
return () => ipcMain.removeHandler('e2e-probe:info');
|
||||
};
|
||||
`;
|
||||
|
||||
const PROBE_PRELOAD = `
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
contextBridge.exposeInMainWorld('probe', { info: () => ipcRenderer.invoke('e2e-probe:info') });
|
||||
`;
|
||||
|
||||
const PROBE_WINDOW_HTML = `<!doctype html>
|
||||
<html>
|
||||
<head><title>E2E Probe Window</title></head>
|
||||
<body>
|
||||
<pre id="out">loading</pre>
|
||||
<script>
|
||||
window.probe.info().then((info) => {
|
||||
document.getElementById('out').textContent = JSON.stringify(info);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
type Started = { session: E2eSession; overlay: CdpPage };
|
||||
let started: Promise<Started> | undefined;
|
||||
|
||||
function useSession(): Promise<Started> {
|
||||
started ??= (async () => {
|
||||
const session = await startE2eSession({
|
||||
config: { plugins: { 'e2e-probe': { enabled: true, settings: { greeting: 'hello' } } } },
|
||||
files: {
|
||||
'plugins/e2e-probe/plugin.json': JSON.stringify({ name: 'E2E Probe', main: 'main.cjs' }),
|
||||
'plugins/e2e-probe/main.cjs': PROBE_PLUGIN,
|
||||
'plugins/e2e-probe/preload.js': PROBE_PRELOAD,
|
||||
'plugins/e2e-probe/ui/index.html': PROBE_WINDOW_HTML,
|
||||
},
|
||||
});
|
||||
try {
|
||||
return { session, overlay: await session.openPage(OVERLAY_PAGE) };
|
||||
} catch (error) {
|
||||
await session.dispose();
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
return started;
|
||||
}
|
||||
|
||||
after(async () => {
|
||||
const running = await started?.catch(() => undefined);
|
||||
await running?.session.dispose();
|
||||
});
|
||||
|
||||
type PanelState = {
|
||||
title: string;
|
||||
heading: string;
|
||||
current: string;
|
||||
items: string[];
|
||||
};
|
||||
|
||||
test(
|
||||
'a plugin action opens the plugin panel with subtitle context and child process output',
|
||||
{ timeout: TEST_TIMEOUT_MS },
|
||||
async () => {
|
||||
const { session, overlay } = await useSession();
|
||||
const [previous, cue, next] = FIXTURE_CUES;
|
||||
await session.mpv.command('seek', cue.at, 'absolute+exact');
|
||||
await overlay.waitFor(
|
||||
`document.getElementById('subtitleRoot').textContent === ${JSON.stringify(cue.text)}`,
|
||||
{ description: `overlay to show "${cue.text}"` },
|
||||
);
|
||||
|
||||
await session.runAppCommand(
|
||||
'--session-action',
|
||||
JSON.stringify({
|
||||
actionId: 'runPluginAction',
|
||||
payload: { pluginActionId: 'e2e-probe/show' },
|
||||
}),
|
||||
);
|
||||
|
||||
const modal = await session.openPage('renderer/index.html?layer=modal');
|
||||
const panel = await modal
|
||||
.waitFor<PanelState | false>(
|
||||
`(() => {
|
||||
const root = document.getElementById('pluginPanelModal');
|
||||
const body = document.getElementById('pluginPanelBody');
|
||||
if (root.classList.contains('hidden') || !/exec[: ]/.test(body.textContent)) return false;
|
||||
return {
|
||||
title: document.getElementById('pluginPanelTitle').textContent,
|
||||
heading: body.querySelector('h4')?.textContent ?? '',
|
||||
current: body.querySelector('strong')?.textContent ?? '',
|
||||
items: Array.from(body.querySelectorAll('li'), (item) => item.textContent),
|
||||
};
|
||||
})()`,
|
||||
{ description: 'plugin panel to show the finished probe result' },
|
||||
)
|
||||
.catch(async (error: unknown) => {
|
||||
const body = await modal.evaluate<string>(
|
||||
`document.getElementById('pluginPanelBody').textContent`,
|
||||
);
|
||||
throw new Error(`Plugin panel body: ${JSON.stringify(body)}`, { cause: error });
|
||||
});
|
||||
|
||||
assert.deepEqual(panel, {
|
||||
title: 'Probe',
|
||||
heading: 'hello',
|
||||
current: cue.text,
|
||||
items: [
|
||||
`before: ${previous.text}`,
|
||||
`current: ${cue.text}`,
|
||||
`after: ${next.text}`,
|
||||
'exec: child-ok',
|
||||
],
|
||||
});
|
||||
|
||||
// Closing hides the modal window (Linux destroys it), often before Chromium
|
||||
// acknowledges the key event, so the send is not awaited. The plugin's log line
|
||||
// proves the close reached main and aborted the handle.
|
||||
void modal
|
||||
.send('Input.dispatchKeyEvent', {
|
||||
type: 'rawKeyDown',
|
||||
key: 'Escape',
|
||||
code: 'Escape',
|
||||
windowsVirtualKeyCode: 27,
|
||||
})
|
||||
.catch(() => {});
|
||||
await waitUntil(
|
||||
() =>
|
||||
fs.readFileSync(path.join(session.root, 'app.log'), 'utf8').includes('probe panel aborted'),
|
||||
{ description: 'closing the panel to abort the plugin handle in main' },
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'a CLI plugin action opens a plugin window whose preload talks to the plugin',
|
||||
{ timeout: TEST_TIMEOUT_MS },
|
||||
async () => {
|
||||
const { session } = await useSession();
|
||||
|
||||
await session.runAppCommand('--plugin-action', 'e2e-probe/window');
|
||||
|
||||
const page = await session.openPage('plugins/e2e-probe/ui/index.html');
|
||||
const info = await page.waitFor<{ dataDir: string; greeting: string } | false>(
|
||||
`(() => {
|
||||
const text = document.getElementById('out')?.textContent;
|
||||
return text && text !== 'loading' ? JSON.parse(text) : false;
|
||||
})()`,
|
||||
{ description: 'plugin window to read plugin info over IPC' },
|
||||
);
|
||||
|
||||
assert.equal(info.greeting, 'hello');
|
||||
assert.equal(path.basename(info.dataDir), 'e2e-probe');
|
||||
assert.equal(path.basename(path.dirname(info.dataDir)), 'plugin-data');
|
||||
assert.equal(fs.statSync(info.dataDir).isDirectory(), true);
|
||||
},
|
||||
);
|
||||
|
||||
test('plugins receive mpv end-file events', { timeout: TEST_TIMEOUT_MS }, async () => {
|
||||
const { session } = await useSession();
|
||||
const mediaPath = await session.mpv.command('get_property', 'path');
|
||||
|
||||
// Replacing the playing file ends it with reason "stop".
|
||||
await session.mpv.command('loadfile', String(mediaPath), 'replace');
|
||||
|
||||
await waitUntil(
|
||||
() =>
|
||||
fs.readFileSync(path.join(session.root, 'app.log'), 'utf8').includes('probe end-file stop'),
|
||||
{ description: 'the plugin to log the end-file reason' },
|
||||
);
|
||||
});
|
||||
@@ -174,9 +174,8 @@ test('runAppCommandCaptureOutput strips ELECTRON_RUN_AS_NODE from app child env'
|
||||
|
||||
test(
|
||||
'runAppCommandCaptureOutput transports Linux AppImage args through environment',
|
||||
{ skip: process.platform !== 'linux' },
|
||||
async () => {
|
||||
// Bun's node:test shim ignores the `skip` option, so gate the platform here.
|
||||
if (process.platform !== 'linux') return;
|
||||
await withTempCase(({ dir }) => {
|
||||
const appPath = writeFakeApp(
|
||||
dir,
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "subminer",
|
||||
"productName": "SubMiner",
|
||||
"desktopName": "SubMiner.desktop",
|
||||
"version": "0.21.1",
|
||||
"version": "0.21.0",
|
||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||
"packageManager": "bun@1.3.5",
|
||||
"main": "dist/main-entry.js",
|
||||
|
||||
@@ -1,19 +1,47 @@
|
||||
## Highlights
|
||||
### Changed
|
||||
- **Stats Session View**: The expanded session view in the stats dashboard has been redesigned. It shows stat tiles for pace, lines, known-word rate, lookups, and paused time, plus a reading density chart of known and unknown words with lookup and card-mined markers.
|
||||
### Added
|
||||
- **Hachidori Dictionary Backend**: Bundled Hachidori backend as an alternative to Yomitan. Select it with `dictionaryBackend` and restart SubMiner.
|
||||
- Works with subtitle scanning, popups, frequency highlighting, character dictionaries, Anki mining, and stats dashboard mining.
|
||||
- First-run setup builds Hachidori's Anki template from your existing SubMiner deck, tags, and field mappings.
|
||||
- Can link an external Hachidori app, browser, or Docker host while Anki mining stays in SubMiner.
|
||||
- The tray and dictionary-settings shortcut open the selected backend's settings. `--hachidori` and `--yomitan` open a specific one.
|
||||
- **Jellyfin Subtitle Generation**: Japanese subtitle generation now works while playing Jellyfin episodes, with both direct-play and transcoded playback.
|
||||
|
||||
### Fixed
|
||||
- **Subtitle Delay**: Retiming subtitles in mpv (z/x or sub-step) now carries through SubMiner. Mined card audio, images, and timestamps match the retimed line, and so do the subtitle sidebar, subtitle navigation, and replay.
|
||||
- **Subtitle Sidebar**: mpv keys and mouse buttons, such as volume keys and double-click fullscreen, work again while the sidebar is open.
|
||||
- **Media Timing Review**: Opens on the first try and keeps playback paused while open, even when a dictionary popup closes.
|
||||
- **Jellyfin Terminal Playback**:
|
||||
- `subminer jellyfin -p` lists libraries when SubMiner is already running, and picker image previews work again.
|
||||
- Real errors, like an expired session or an unreachable server, now appear instead of an empty list.
|
||||
- Watch progress and played status reach Jellyfin even when cast discovery is off.
|
||||
- When it starts SubMiner itself, SubMiner now shows a tray icon and quits when mpv closes.
|
||||
- The rofi search step is now a compact input box.
|
||||
- **macOS Casting**: Jellyfin casting finds mpv when SubMiner is opened from Finder, the Dock, or Spotlight. `mpv.executablePath` and `SUBMINER_MPV_PATH` are now honored on macOS and Linux.
|
||||
- **macOS Launcher PATH**: The command-line launcher no longer shows "Not on PATH" when SubMiner is opened from Finder or the Dock.
|
||||
- **Keyboard-Only Mode**: `h`, `l`, and the arrow keys type normally in modals and text fields instead of seeking mpv. Toggling the mode inside a modal no longer turns on a hidden keyboard-only mode.
|
||||
- **Halfwidth Katakana**: Halfwidth katakana in subtitles now displays as fullwidth, including in mined cards and the stats dashboard. Stats sentence search matches katakana in either width.
|
||||
- **Subtitle Annotations**: Lines shown while two cues overlap now get annotations right away. Prefetching no longer restarts during startup.
|
||||
- **Subtitle Sidebar**: The open sidebar does far less work during playback.
|
||||
- **Hyprland and Sway**: A slow or hung `hyprctl` or `swaymsg` no longer freezes the app.
|
||||
- **Stats Mining**: Cards mined from the stats dashboard keep the selected history line and media.
|
||||
- **Security Updates**: Updated Electron, undici, and follow-redirects to fix new security advisories.
|
||||
|
||||
### Docs
|
||||
- **README**: Refreshed the anime screenshots using the current build.
|
||||
- **Hachidori Setup**: Covers backend selection, Hachidori dictionary and Anki setup, settings flags and shortcuts, and linking an external host.
|
||||
- **Jellyfin**: The docs now say SubMiner joins the cast menu at startup only when both `startupWarmups.jellyfinRemoteSession` and `jellyfin.remoteControlAutoConnect` are on. Otherwise, enable **Jellyfin Discovery** in the tray.
|
||||
- **Casting and mpv**: Added how casting finds mpv on each platform, a troubleshooting entry for casts that never start mpv, and a note on the macOS 27 Accessibility list rename.
|
||||
- **Jimaku Environment Variables**: Removed the unused `SUBMINER_JIMAKU_*` variables from the docs. Set the key with `jimaku.apiKey` or `jimaku.apiKeyCommand` instead.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(overlay): forward mpv input while subtitle sidebar is open by @ksyasuda in #300
|
||||
- fix(subtitles): follow mpv sub-delay in mining, sidebar, and navigation by @ksyasuda in #302
|
||||
- feat(stats): redesign expanded session view with density chart by @ksyasuda in #304
|
||||
- feat(dictionary): add Hachidori backend support by @ksyasuda in #266
|
||||
- fix(jellyfin): make terminal play work with a running app and report progress by @ksyasuda in #288
|
||||
- fix(overlay): stop Hyprland/Sway tracker polls from blocking the main thread by @ksyasuda in #289
|
||||
- fix(overlay): poll cheap playback state in the open subtitle sidebar by @ksyasuda in #290
|
||||
- fix(overlay): keep keyboard mode from swallowing modal and text-field keys by @ksyasuda in #291
|
||||
- refactor: remove dead code and enable noUnusedLocals by @ksyasuda in #292
|
||||
- feat(subtitles): generate Japanese subtitles during Jellyfin playback by @ksyasuda in #294
|
||||
- fix(subtitles): widen halfwidth katakana where subtitle text enters the app by @ksyasuda in #295
|
||||
- fix(jellyfin): find mpv when SubMiner is launched from Finder by @ksyasuda in #298
|
||||
|
||||
## Installation
|
||||
|
||||
|
||||
@@ -45,7 +45,6 @@ import {
|
||||
} from './anki-field-config';
|
||||
import { createLogger } from './logger';
|
||||
import { captureLiveSubtitleMiningContext } from './core/services/mining';
|
||||
import { shiftSubtitleRangeToVideoClock } from './core/services/subtitle-clock';
|
||||
import {
|
||||
createUiFeedbackState,
|
||||
beginUpdateProgress,
|
||||
@@ -1030,10 +1029,10 @@ export class AnkiIntegration {
|
||||
Number.isFinite(this.mpvClient.currentSubEnd) &&
|
||||
this.mpvClient.currentSubEnd > this.mpvClient.currentSubStart
|
||||
) {
|
||||
return shiftSubtitleRangeToVideoClock(
|
||||
{ startTime: this.mpvClient.currentSubStart, endTime: this.mpvClient.currentSubEnd },
|
||||
this.mpvClient.currentSubDelay,
|
||||
);
|
||||
return {
|
||||
startTime: this.mpvClient.currentSubStart,
|
||||
endTime: this.mpvClient.currentSubEnd,
|
||||
};
|
||||
}
|
||||
|
||||
const currentTime = this.mpvClient.currentTimePos || 0;
|
||||
@@ -1673,7 +1672,7 @@ export class AnkiIntegration {
|
||||
if (this.config.fields?.miscInfo) {
|
||||
result.miscInfoValue = this.formatMiscInfoPattern(
|
||||
audioFilename,
|
||||
this.mpvClient.currentSubStart + this.mpvClient.currentSubDelay,
|
||||
this.mpvClient.currentSubStart,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1696,7 +1695,7 @@ export class AnkiIntegration {
|
||||
if (this.config.fields?.miscInfo && !result.miscInfoValue) {
|
||||
result.miscInfoValue = this.formatMiscInfoPattern(
|
||||
imageFilename,
|
||||
this.mpvClient.currentSubStart + this.mpvClient.currentSubDelay,
|
||||
this.mpvClient.currentSubStart,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
type MediaTimingReviewRequest,
|
||||
type WordCardKind,
|
||||
} from '../types/anki';
|
||||
import { shiftSubtitleRangeToVideoClock } from '../core/services/subtitle-clock';
|
||||
import { createLogger } from '../logger';
|
||||
import type { MediaInput } from '../media-input';
|
||||
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
||||
@@ -429,12 +428,10 @@ export class CardCreationService {
|
||||
return;
|
||||
}
|
||||
|
||||
let { startTime, endTime } = shiftSubtitleRangeToVideoClock(
|
||||
{ startTime: mpvClient.currentSubStart, endTime: mpvClient.currentSubEnd },
|
||||
mpvClient.currentSubDelay,
|
||||
);
|
||||
let startTime = mpvClient.currentSubStart;
|
||||
let endTime = mpvClient.currentSubEnd;
|
||||
|
||||
if (mpvClient.currentSubStart === undefined || mpvClient.currentSubEnd === undefined) {
|
||||
if (startTime === undefined || endTime === undefined) {
|
||||
const currentTime = mpvClient.currentTimePos || 0;
|
||||
const fallback = this.deps.getFallbackDurationSeconds() / 2;
|
||||
startTime = currentTime - fallback;
|
||||
|
||||
@@ -340,6 +340,14 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
||||
assert.equal(shouldStartApp(sessionAction), true);
|
||||
assert.equal(commandNeedsOverlayRuntime(sessionAction), true);
|
||||
|
||||
const pluginAction = parseArgs(['--plugin-action', 'anime/open']);
|
||||
assert.equal(pluginAction.pluginAction, 'anime/open');
|
||||
assert.equal(parseArgs(['--plugin-action=anime/open']).pluginAction, 'anime/open');
|
||||
assert.equal(hasExplicitCommand(pluginAction), true);
|
||||
assert.equal(shouldStartApp(pluginAction), true);
|
||||
// The plugin decides what it needs; a window-only action must not force the overlay up.
|
||||
assert.equal(commandNeedsOverlayRuntime(pluginAction), false);
|
||||
|
||||
const toggleStatsOverlayRuntime = parseArgs(['--toggle-stats-overlay']);
|
||||
assert.equal(commandNeedsOverlayRuntime(toggleStatsOverlayRuntime), true);
|
||||
|
||||
|
||||
@@ -48,6 +48,8 @@ export interface CliArgs {
|
||||
cycleRuntimeOptionId?: string;
|
||||
cycleRuntimeOptionDirection?: 1 | -1;
|
||||
sessionAction?: SessionActionDispatchRequest;
|
||||
/** `<plugin id>/<action name>` to run, like a `__plugin-action:` keybinding. */
|
||||
pluginAction?: string;
|
||||
copySubtitleCount?: number;
|
||||
mineSentenceCount?: number;
|
||||
anilistStatus: boolean;
|
||||
@@ -338,6 +340,12 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
} else if (arg === '--session-action') {
|
||||
const parsed = parseSessionAction(readValue(argv[i + 1]));
|
||||
if (parsed) args.sessionAction = parsed;
|
||||
} else if (arg.startsWith('--plugin-action=')) {
|
||||
const value = arg.slice('--plugin-action='.length).trim();
|
||||
if (value) args.pluginAction = value;
|
||||
} else if (arg === '--plugin-action') {
|
||||
const value = readValue(argv[i + 1])?.trim();
|
||||
if (value) args.pluginAction = value;
|
||||
} else if (arg.startsWith('--copy-subtitle-count=')) {
|
||||
const value = Number(arg.split('=', 2)[1]);
|
||||
if (Number.isInteger(value) && value > 0) args.copySubtitleCount = value;
|
||||
@@ -601,6 +609,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
||||
args.playbackFeedback !== undefined ||
|
||||
args.cycleRuntimeOptionId !== undefined ||
|
||||
args.sessionAction !== undefined ||
|
||||
args.pluginAction !== undefined ||
|
||||
args.copySubtitleCount !== undefined ||
|
||||
args.mineSentenceCount !== undefined ||
|
||||
args.anilistStatus ||
|
||||
@@ -682,6 +691,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
||||
args.playbackFeedback === undefined &&
|
||||
args.cycleRuntimeOptionId === undefined &&
|
||||
args.sessionAction === undefined &&
|
||||
args.pluginAction === undefined &&
|
||||
args.copySubtitleCount === undefined &&
|
||||
args.mineSentenceCount === undefined &&
|
||||
!args.anilistStatus &&
|
||||
@@ -752,6 +762,7 @@ export function shouldStartApp(args: CliArgs): boolean {
|
||||
args.playbackFeedback !== undefined ||
|
||||
args.cycleRuntimeOptionId !== undefined ||
|
||||
args.sessionAction !== undefined ||
|
||||
args.pluginAction !== undefined ||
|
||||
args.copySubtitleCount !== undefined ||
|
||||
args.mineSentenceCount !== undefined ||
|
||||
args.dictionary ||
|
||||
@@ -819,6 +830,7 @@ export function shouldRunDictionarySettingsOnlyStartup(args: CliArgs): boolean {
|
||||
args.playbackFeedback === undefined &&
|
||||
args.cycleRuntimeOptionId === undefined &&
|
||||
args.sessionAction === undefined &&
|
||||
args.pluginAction === undefined &&
|
||||
args.copySubtitleCount === undefined &&
|
||||
args.mineSentenceCount === undefined &&
|
||||
!args.anilistStatus &&
|
||||
|
||||
@@ -46,6 +46,7 @@ ${B}Mining${R}
|
||||
--open-session-help Open session help modal
|
||||
--open-controller-select Open controller select modal
|
||||
--open-controller-debug Open controller debug modal
|
||||
--plugin-action ${D}<id>/<name>${R} Run a plugin action
|
||||
|
||||
${B}AniList${R}
|
||||
--anilist-setup Open AniList authentication flow
|
||||
|
||||
@@ -37,6 +37,7 @@ const {
|
||||
startupWarmups,
|
||||
updates,
|
||||
notifications,
|
||||
plugins,
|
||||
auto_start_overlay,
|
||||
} = CORE_DEFAULT_CONFIG;
|
||||
const {
|
||||
@@ -74,6 +75,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
|
||||
startupWarmups,
|
||||
updates,
|
||||
notifications,
|
||||
plugins,
|
||||
subtitleStyle,
|
||||
subtitleSidebar,
|
||||
auto_start_overlay,
|
||||
|
||||
@@ -17,6 +17,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
| 'startupWarmups'
|
||||
| 'updates'
|
||||
| 'notifications'
|
||||
| 'plugins'
|
||||
| 'auto_start_overlay'
|
||||
> = {
|
||||
dictionaryBackend: 'yomitan',
|
||||
@@ -145,5 +146,6 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
notifications: {
|
||||
overlayPosition: 'top-right',
|
||||
},
|
||||
plugins: {},
|
||||
auto_start_overlay: true,
|
||||
};
|
||||
@@ -546,6 +546,13 @@ export function buildCoreConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.notifications.overlayPosition,
|
||||
description: 'Position for in-overlay notification cards.',
|
||||
},
|
||||
{
|
||||
path: 'plugins',
|
||||
kind: 'object',
|
||||
defaultValue: defaultConfig.plugins,
|
||||
description:
|
||||
'Plugins to load at startup, keyed by directory name under <config dir>/plugins/. Each entry is { "enabled": true, "settings": { ... } }; settings are passed to the plugin as ctx.settings.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.multiCopyTimeoutMs',
|
||||
kind: 'number',
|
||||
|
||||
@@ -57,6 +57,7 @@ export const SPECIAL_COMMANDS = {
|
||||
ANIMETOSHO_OPEN: '__animetosho-open',
|
||||
TSUKIHIME_OPEN: '__tsukihime-open',
|
||||
RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
|
||||
PLUGIN_ACTION_PREFIX: '__plugin-action:',
|
||||
REPLAY_SUBTITLE: '__replay-subtitle',
|
||||
PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
|
||||
YOUTUBE_PICKER_OPEN: '__youtube-picker-open',
|
||||
|
||||
@@ -92,6 +92,15 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
notes: ['Hot-reload: position changes apply to the next overlay notification.'],
|
||||
key: 'notifications',
|
||||
},
|
||||
{
|
||||
title: 'Plugins',
|
||||
description: [
|
||||
'JavaScript plugins loaded from <config dir>/plugins/<id>/ at startup.',
|
||||
'Plugins run inside SubMiner with full access to your system. Only enable code you trust.',
|
||||
],
|
||||
notes: ['Restart SubMiner after changing plugins.'],
|
||||
key: 'plugins',
|
||||
},
|
||||
{
|
||||
title: 'Keyboard Shortcuts',
|
||||
description: ['Overlay keyboard shortcuts. Set a shortcut to null to disable.'],
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createResolveContext } from './resolve/context';
|
||||
import { applyCoreDomainConfig } from './resolve/core-domains';
|
||||
import { applyImmersionTrackingConfig } from './resolve/immersion-tracking';
|
||||
import { applyIntegrationConfig } from './resolve/integrations';
|
||||
import { applyPluginsConfig } from './resolve/plugins';
|
||||
import { applyStatsConfig } from './resolve/stats';
|
||||
import { applySubtitleDomainConfig } from './resolve/subtitle-domains';
|
||||
import { applyTopLevelConfig } from './resolve/top-level';
|
||||
@@ -15,6 +16,7 @@ const APPLY_RESOLVE_STEPS = [
|
||||
applyIntegrationConfig,
|
||||
applyImmersionTrackingConfig,
|
||||
applyStatsConfig,
|
||||
applyPluginsConfig,
|
||||
applyAnkiConnectResolution,
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { resolveConfig } from '../resolve';
|
||||
|
||||
test('resolveConfig keeps plugin entries and their settings', () => {
|
||||
const { resolved, warnings } = resolveConfig({
|
||||
plugins: {
|
||||
'ai-explain': { enabled: true, settings: { codexPath: 'codex', before: 2 } },
|
||||
dormant: {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(resolved.plugins, {
|
||||
'ai-explain': { enabled: true, settings: { codexPath: 'codex', before: 2 } },
|
||||
dormant: { enabled: false, settings: {} },
|
||||
});
|
||||
assert.deepEqual(warnings, []);
|
||||
});
|
||||
|
||||
test('resolveConfig warns about malformed plugin entries and keeps them disabled', () => {
|
||||
const { resolved, warnings } = resolveConfig({
|
||||
plugins: {
|
||||
flag: { enabled: 'yes' as never, settings: [] as never },
|
||||
scalar: true as never,
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(resolved.plugins, { flag: { enabled: false, settings: {} } });
|
||||
assert.deepEqual(
|
||||
warnings.map((warning) => warning.path),
|
||||
['plugins.flag.enabled', 'plugins.flag.settings', 'plugins.scalar'],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ResolveContext } from './context';
|
||||
import { isObject } from './shared';
|
||||
|
||||
/** Keeps well-formed `plugins.<id>` entries; ids are validated again when the plugin loads. */
|
||||
export function applyPluginsConfig(context: ResolveContext): void {
|
||||
const { src, resolved, warn } = context;
|
||||
if (src.plugins === undefined) return;
|
||||
if (!isObject(src.plugins)) {
|
||||
warn('plugins', src.plugins, resolved.plugins, 'Expected object keyed by plugin id.');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [id, entry] of Object.entries(src.plugins)) {
|
||||
const path = `plugins.${id}`;
|
||||
if (!isObject(entry)) {
|
||||
warn(path, entry, undefined, 'Expected { "enabled": boolean, "settings"?: object }.');
|
||||
continue;
|
||||
}
|
||||
if (entry.enabled !== undefined && typeof entry.enabled !== 'boolean') {
|
||||
warn(`${path}.enabled`, entry.enabled, false, 'Expected boolean.');
|
||||
}
|
||||
if (entry.settings !== undefined && !isObject(entry.settings)) {
|
||||
warn(`${path}.settings`, entry.settings, {}, 'Expected object.');
|
||||
}
|
||||
resolved.plugins[id] = {
|
||||
enabled: entry.enabled === true,
|
||||
settings: isObject(entry.settings) ? { ...entry.settings } : {},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,7 @@ const JSON_OBJECT_FIELDS = new Set([
|
||||
'keybindings',
|
||||
'controller.bindings',
|
||||
'controller.profiles',
|
||||
'plugins',
|
||||
'ankiConnect.knownWords.decks',
|
||||
'subtitleStyle.css',
|
||||
'subtitleStyle.secondary.css',
|
||||
@@ -441,6 +442,9 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
||||
if (path.startsWith('notifications.')) {
|
||||
return { category: 'behavior', section: 'Notifications' };
|
||||
}
|
||||
if (path === 'plugins') {
|
||||
return { category: 'advanced', section: 'Plugins' };
|
||||
}
|
||||
if (path === 'mpv.aniskipButtonKey') {
|
||||
return { category: 'input', section: 'Overlay Shortcuts' };
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ function makeDeps(overrides: Partial<AppReadyRuntimeDeps> = {}) {
|
||||
loadSubtitlePosition: () => calls.push('loadSubtitlePosition'),
|
||||
resolveKeybindings: () => calls.push('resolveKeybindings'),
|
||||
createMpvClient: () => calls.push('createMpvClient'),
|
||||
startPlugins: () => calls.push('startPlugins'),
|
||||
reloadConfig: () => calls.push('reloadConfig'),
|
||||
getResolvedConfig: () => ({
|
||||
websocket: { enabled: 'auto' },
|
||||
@@ -162,6 +163,7 @@ test('runAppReadyRuntime skips heavy startup when shouldSkipHeavyStartup returns
|
||||
assert.equal(calls.includes('loadSubtitlePosition'), false);
|
||||
assert.equal(calls.includes('resolveKeybindings'), false);
|
||||
assert.equal(calls.includes('createMpvClient'), false);
|
||||
assert.equal(calls.includes('startPlugins'), false);
|
||||
assert.equal(calls.includes('initRuntimeOptionsManager'), false);
|
||||
assert.equal(calls.includes('createImmersionTracker'), false);
|
||||
assert.equal(calls.includes('startJellyfinRemoteSession'), false);
|
||||
@@ -326,6 +328,9 @@ test('runAppReadyRuntime starts background warmups after overlay startup', async
|
||||
|
||||
assert.ok(calls.indexOf('loadSubtitlePosition') < calls.indexOf('startBackgroundWarmups'));
|
||||
assert.ok(calls.indexOf('createMpvClient') < calls.indexOf('startBackgroundWarmups'));
|
||||
assert.ok(calls.includes('setLogLevel:info:config'));
|
||||
assert.ok(calls.indexOf('setLogLevel:info:config') < calls.indexOf('startPlugins'));
|
||||
assert.ok(calls.indexOf('startPlugins') < calls.indexOf('startBackgroundWarmups'));
|
||||
assert.ok(calls.indexOf('initializeOverlayRuntime') < calls.indexOf('startBackgroundWarmups'));
|
||||
assert.ok(calls.indexOf('startBackgroundWarmups') < calls.indexOf('handleInitialArgs'));
|
||||
});
|
||||
|
||||
@@ -737,6 +737,23 @@ test('handleCliCommand dispatches generic session action payloads', async () =>
|
||||
});
|
||||
});
|
||||
|
||||
test('handleCliCommand runs plugin actions through the session action dispatcher', async () => {
|
||||
let request: unknown = null;
|
||||
const { deps } = createDeps({
|
||||
dispatchSessionAction: async (nextRequest) => {
|
||||
request = nextRequest;
|
||||
},
|
||||
});
|
||||
|
||||
handleCliCommand(makeArgs({ pluginAction: 'anime/open' }), 'initial', deps);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.deepEqual(request, {
|
||||
actionId: 'runPluginAction',
|
||||
payload: { pluginActionId: 'anime/open' },
|
||||
});
|
||||
});
|
||||
|
||||
test('handleCliCommand dispatches mark-watched session action', async () => {
|
||||
let request: unknown = null;
|
||||
const { deps } = createDeps({
|
||||
|
||||
@@ -422,6 +422,12 @@ export function handleCliCommand(
|
||||
`sessionAction:${args.sessionAction.actionId}`,
|
||||
'Session action failed',
|
||||
);
|
||||
} else if (args.pluginAction) {
|
||||
dispatchCliSessionAction(
|
||||
{ actionId: 'runPluginAction', payload: { pluginActionId: args.pluginAction } },
|
||||
`pluginAction:${args.pluginAction}`,
|
||||
'Plugin action failed',
|
||||
);
|
||||
} else if (args.toggle || args.toggleVisibleOverlay) {
|
||||
deps.toggleVisibleOverlay();
|
||||
} else if (args.togglePrimarySubtitleBar) {
|
||||
|
||||
@@ -55,7 +55,6 @@ export {
|
||||
subtitleCueListSeekTime,
|
||||
subtitleCueSeekTime,
|
||||
} from './subtitle-cue-navigation';
|
||||
export { shiftSubtitleRangeToVideoClock, toSubtitleClock, toVideoClock } from './subtitle-clock';
|
||||
export { createFrequencyDictionaryLookup } from './frequency-dictionary';
|
||||
export { createJlptVocabularyLookup } from './jlpt-vocab';
|
||||
export {
|
||||
|
||||
@@ -89,7 +89,6 @@ function createSubtitleSidebarSnapshotFixture(): SubtitleSidebarSnapshot {
|
||||
cuesRevision: 1,
|
||||
cues: [],
|
||||
currentTimeSec: null,
|
||||
subDelay: 0,
|
||||
currentSubtitle: { text: '', startTime: null, endTime: null },
|
||||
config: {
|
||||
enabled: false,
|
||||
@@ -576,8 +575,8 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
|
||||
const openedModals: unknown[] = [];
|
||||
registerIpcHandlers(
|
||||
{
|
||||
onOverlayModalClosed: (modal) => {
|
||||
closedModals.push(modal);
|
||||
onOverlayModalClosed: (modal, _senderWindow, instanceId) => {
|
||||
closedModals.push(instanceId === undefined ? modal : [modal, instanceId]);
|
||||
},
|
||||
onOverlayModalOpened: (modal) => {
|
||||
openedModals.push(modal);
|
||||
@@ -638,7 +637,9 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
|
||||
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'not-a-modal');
|
||||
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'subsync');
|
||||
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'kiku');
|
||||
assert.deepEqual(closedModals, ['subsync', 'kiku']);
|
||||
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'plugin-panel', 4);
|
||||
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'plugin-panel', 'bad');
|
||||
assert.deepEqual(closedModals, ['subsync', 'kiku', ['plugin-panel', 4], 'plugin-panel']);
|
||||
|
||||
handlers.on.get(IPC_CHANNELS.command.overlayModalOpened)!({}, 'bad');
|
||||
handlers.on.get(IPC_CHANNELS.command.overlayModalOpened)!({}, 'subsync');
|
||||
|
||||
@@ -50,6 +50,7 @@ export interface IpcServiceDeps {
|
||||
onOverlayModalClosed: (
|
||||
modal: OverlayHostedModal,
|
||||
senderWindow: ElectronBrowserWindow | null,
|
||||
instanceId?: number,
|
||||
) => void;
|
||||
onOverlayModalOpened?: (
|
||||
modal: OverlayHostedModal,
|
||||
@@ -317,6 +318,7 @@ export interface IpcDepsRuntimeOptions {
|
||||
onOverlayModalClosed: (
|
||||
modal: OverlayHostedModal,
|
||||
senderWindow: ElectronBrowserWindow | null,
|
||||
instanceId?: number,
|
||||
) => void;
|
||||
onOverlayModalOpened?: (
|
||||
modal: OverlayHostedModal,
|
||||
@@ -527,13 +529,20 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
},
|
||||
);
|
||||
|
||||
ipc.on(IPC_CHANNELS.command.overlayModalClosed, (event: unknown, modal: unknown) => {
|
||||
ipc.on(
|
||||
IPC_CHANNELS.command.overlayModalClosed,
|
||||
(event: unknown, modal: unknown, instanceId: unknown) => {
|
||||
const parsedModal = parseOverlayHostedModal(modal);
|
||||
if (!parsedModal) return;
|
||||
const senderWindow =
|
||||
electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null;
|
||||
deps.onOverlayModalClosed(parsedModal, senderWindow);
|
||||
});
|
||||
deps.onOverlayModalClosed(
|
||||
parsedModal,
|
||||
senderWindow,
|
||||
Number.isInteger(instanceId) ? (instanceId as number) : undefined,
|
||||
);
|
||||
},
|
||||
);
|
||||
ipc.on(IPC_CHANNELS.command.overlayModalOpened, (event: unknown, modal: unknown) => {
|
||||
const parsedModal = parseOverlayHostedModal(modal);
|
||||
if (!parsedModal) return;
|
||||
|
||||
@@ -77,7 +77,6 @@ test('mineSentenceCard handles missing integration and disconnected mpv', async
|
||||
currentSubText: 'line',
|
||||
currentSubStart: 1,
|
||||
currentSubEnd: 2,
|
||||
currentSubDelay: 0,
|
||||
},
|
||||
showMpvOsd: (text) => osd.push(text),
|
||||
}),
|
||||
@@ -110,7 +109,6 @@ test('mineSentenceCard creates sentence card from mpv subtitle state', async ()
|
||||
currentSubText: 'subtitle line',
|
||||
currentSubStart: 10,
|
||||
currentSubEnd: 12,
|
||||
currentSubDelay: 0,
|
||||
currentSecondarySubText: 'secondary line',
|
||||
},
|
||||
showMpvOsd: () => {},
|
||||
@@ -150,7 +148,6 @@ test('mineSentenceCard prefers a canonical primary subtitle snapshot', async ()
|
||||
currentSubText: '今今今手手手',
|
||||
currentSubStart: 11.4,
|
||||
currentSubEnd: 11.8,
|
||||
currentSubDelay: 0,
|
||||
currentSecondarySubText: 'English subtitle',
|
||||
},
|
||||
primarySubtitle: {
|
||||
@@ -190,7 +187,6 @@ test('mineSentenceCard uses normalized secondary subtitle state instead of raw m
|
||||
currentSubText: '日本語字幕',
|
||||
currentSubStart: 10,
|
||||
currentSubEnd: 12,
|
||||
currentSubDelay: 0,
|
||||
currentSecondarySubText: 'Your\nmosaic',
|
||||
requestProperty: async () => {
|
||||
requestedRawSecondaryText = true;
|
||||
@@ -222,7 +218,6 @@ test('mineSentenceCard omits normalized secondary text that matches the primary
|
||||
currentSubText: '日本語字幕',
|
||||
currentSubStart: 10,
|
||||
currentSubEnd: 12,
|
||||
currentSubDelay: 0,
|
||||
currentSecondarySubText: '日本語字幕',
|
||||
},
|
||||
showMpvOsd: () => {},
|
||||
@@ -401,7 +396,7 @@ test('handleMineSentenceDigit joins per-entry secondary subtitles when available
|
||||
|
||||
test('captureLiveSubtitleMiningContext snapshots the current line and timings', () => {
|
||||
const context = captureLiveSubtitleMiningContext(
|
||||
{ currentSubText: ' 食べる ', currentSubStart: 12.5, currentSubEnd: 15.25, currentSubDelay: 0 },
|
||||
{ currentSubText: ' 食べる ', currentSubStart: 12.5, currentSubEnd: 15.25 },
|
||||
() => 1234,
|
||||
);
|
||||
|
||||
@@ -414,16 +409,6 @@ test('captureLiveSubtitleMiningContext snapshots the current line and timings',
|
||||
});
|
||||
});
|
||||
|
||||
test('captureLiveSubtitleMiningContext moves sub-delay retimed lines onto the video clock', () => {
|
||||
const context = captureLiveSubtitleMiningContext(
|
||||
{ currentSubText: '食べる', currentSubStart: 5, currentSubEnd: 8, currentSubDelay: -1.5 },
|
||||
() => 1234,
|
||||
);
|
||||
|
||||
assert.equal(context?.startTime, 3.5);
|
||||
assert.equal(context?.endTime, 6.5);
|
||||
});
|
||||
|
||||
test('captureLiveSubtitleMiningContext rejects missing client, empty text and bad timings', () => {
|
||||
assert.equal(captureLiveSubtitleMiningContext(null), null);
|
||||
assert.equal(
|
||||
@@ -431,7 +416,6 @@ test('captureLiveSubtitleMiningContext rejects missing client, empty text and ba
|
||||
currentSubText: ' ',
|
||||
currentSubStart: 1,
|
||||
currentSubEnd: 2,
|
||||
currentSubDelay: 0,
|
||||
}),
|
||||
null,
|
||||
);
|
||||
@@ -440,7 +424,6 @@ test('captureLiveSubtitleMiningContext rejects missing client, empty text and ba
|
||||
currentSubText: 'line',
|
||||
currentSubStart: 5,
|
||||
currentSubEnd: 5,
|
||||
currentSubDelay: 0,
|
||||
}),
|
||||
null,
|
||||
);
|
||||
@@ -449,7 +432,6 @@ test('captureLiveSubtitleMiningContext rejects missing client, empty text and ba
|
||||
currentSubText: 'line',
|
||||
currentSubStart: NaN,
|
||||
currentSubEnd: 2,
|
||||
currentSubDelay: 0,
|
||||
}),
|
||||
null,
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { SubtitleTimingBlock } from '../../subtitle-timing-tracker';
|
||||
import type { SubtitleMiningContext } from '../../types/subtitle';
|
||||
import { shiftSubtitleRangeToVideoClock } from './subtitle-clock';
|
||||
|
||||
interface SubtitleTimingTrackerLike {
|
||||
getRecentBlocks: (count: number) => string[];
|
||||
@@ -26,7 +25,6 @@ interface MpvClientLike {
|
||||
currentSubText: string;
|
||||
currentSubStart: number;
|
||||
currentSubEnd: number;
|
||||
currentSubDelay: number;
|
||||
currentSecondarySubText?: string;
|
||||
requestProperty?: (name: string) => Promise<unknown>;
|
||||
}
|
||||
@@ -64,10 +62,7 @@ export function handleMultiCopyDigit(
|
||||
* line is on screen by then; callers capture here at mining time instead.
|
||||
*/
|
||||
export function captureLiveSubtitleMiningContext(
|
||||
client: Pick<
|
||||
MpvClientLike,
|
||||
'currentSubText' | 'currentSubStart' | 'currentSubEnd' | 'currentSubDelay'
|
||||
> | null,
|
||||
client: Pick<MpvClientLike, 'currentSubText' | 'currentSubStart' | 'currentSubEnd'> | null,
|
||||
now: () => number = Date.now,
|
||||
): SubtitleMiningContext | null {
|
||||
if (!client) {
|
||||
@@ -78,10 +73,7 @@ export function captureLiveSubtitleMiningContext(
|
||||
if (!text || !Number.isFinite(startTime) || !Number.isFinite(endTime) || endTime <= startTime) {
|
||||
return null;
|
||||
}
|
||||
return shiftSubtitleRangeToVideoClock<SubtitleMiningContext>(
|
||||
{ source: 'overlay', text, startTime, endTime, capturedAtMs: now() },
|
||||
client.currentSubDelay,
|
||||
);
|
||||
return { source: 'overlay', text, startTime, endTime, capturedAtMs: now() };
|
||||
}
|
||||
|
||||
export function copyCurrentSubtitle(deps: {
|
||||
@@ -175,7 +167,6 @@ export async function markLastCardAsAudioCard(deps: {
|
||||
export async function mineSentenceCard(deps: {
|
||||
ankiIntegration: AnkiIntegrationLike | null;
|
||||
mpvClient: MpvClientLike | null;
|
||||
/** Already on the video clock, like every `SubtitleMiningContext`. */
|
||||
primarySubtitle?: Pick<SubtitleMiningContext, 'text' | 'startTime' | 'endTime'>;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}): Promise<boolean> {
|
||||
@@ -194,16 +185,10 @@ export async function mineSentenceCard(deps: {
|
||||
}
|
||||
|
||||
const secondarySubText = getCurrentSecondarySubTextForSentenceCard(mpvClient, primaryText);
|
||||
const range =
|
||||
deps.primarySubtitle ??
|
||||
shiftSubtitleRangeToVideoClock(
|
||||
{ startTime: mpvClient.currentSubStart, endTime: mpvClient.currentSubEnd },
|
||||
mpvClient.currentSubDelay,
|
||||
);
|
||||
return await anki.createSentenceCard(
|
||||
primaryText,
|
||||
range.startTime,
|
||||
range.endTime,
|
||||
deps.primarySubtitle?.startTime ?? mpvClient.currentSubStart,
|
||||
deps.primarySubtitle?.endTime ?? mpvClient.currentSubEnd,
|
||||
secondarySubText,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,7 +38,6 @@ const MPV_SUBTITLE_PROPERTY_OBSERVATIONS: string[] = [
|
||||
'path',
|
||||
'sub-start',
|
||||
'sub-end',
|
||||
'sub-delay',
|
||||
'time-pos',
|
||||
'secondary-sub-text',
|
||||
'aid',
|
||||
|
||||
@@ -73,8 +73,6 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
|
||||
getCurrentSubStart: () => 0,
|
||||
setCurrentSubEnd: () => {},
|
||||
getCurrentSubEnd: () => 0,
|
||||
setCurrentSubDelay: () => {},
|
||||
getCurrentSubDelay: () => 0,
|
||||
emitMediaPathChange: (payload) => {
|
||||
state.mediaPath = payload.path;
|
||||
},
|
||||
@@ -133,22 +131,6 @@ test('dispatchMpvProtocolMessage emits subtitle text on property change', async
|
||||
assert.deepEqual(state.events, [{ text: '字幕', isOverlayVisible: false }]);
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage tracks sub-delay retiming', async () => {
|
||||
const delays: number[] = [];
|
||||
const { deps } = createDeps({ setCurrentSubDelay: (value) => delays.push(value) });
|
||||
|
||||
await dispatchMpvProtocolMessage(
|
||||
{ event: 'property-change', name: 'sub-delay', data: 1.5 },
|
||||
deps,
|
||||
);
|
||||
await dispatchMpvProtocolMessage(
|
||||
{ event: 'property-change', name: 'sub-delay', data: null },
|
||||
deps,
|
||||
);
|
||||
|
||||
assert.deepEqual(delays, [1.5, 0]);
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage widens halfwidth katakana in subtitle text', async () => {
|
||||
const { deps, state } = createDeps();
|
||||
|
||||
@@ -397,29 +379,6 @@ test('dispatchMpvProtocolMessage pauses on sub-end when pendingPauseAtSubEnd is
|
||||
});
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage pauses at the sub-delay retimed sub-end', async () => {
|
||||
let pauseAtTime: number | null = null;
|
||||
let subDelay = 1.5;
|
||||
const { deps } = createDeps({
|
||||
getPendingPauseAtSubEnd: () => true,
|
||||
getCurrentSubDelay: () => subDelay,
|
||||
setCurrentSubDelay: (next) => {
|
||||
subDelay = next;
|
||||
},
|
||||
getPauseAtTime: () => pauseAtTime,
|
||||
setPauseAtTime: (next) => {
|
||||
pauseAtTime = next;
|
||||
},
|
||||
});
|
||||
|
||||
await dispatchMpvProtocolMessage({ event: 'property-change', name: 'sub-end', data: 42 }, deps);
|
||||
assert.equal(pauseAtTime, 43.5);
|
||||
|
||||
// Retiming before the replay reaches its end moves the pause with the line.
|
||||
await dispatchMpvProtocolMessage({ event: 'property-change', name: 'sub-delay', data: 2 }, deps);
|
||||
assert.equal(pauseAtTime, 44);
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage updates current time before emitting time-pos change', async () => {
|
||||
const calls: string[] = [];
|
||||
let currentTimePos = 0;
|
||||
@@ -486,3 +445,19 @@ test('dispatchMpvProtocolMessage emits client-message string args', async () =>
|
||||
|
||||
assert.deepEqual(received, [{ args: ['subminer-skip-intro', 'extra'] }]);
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage emits end-file with reason, entry id, and error', async () => {
|
||||
const received: unknown[] = [];
|
||||
const { deps } = createDeps({ emitEndFile: (payload) => received.push(payload) });
|
||||
|
||||
await dispatchMpvProtocolMessage(
|
||||
{ event: 'end-file', reason: 'error', playlist_entry_id: 3, file_error: 'loading failed' },
|
||||
deps,
|
||||
);
|
||||
await dispatchMpvProtocolMessage({ event: 'end-file', reason: 'something-new' }, deps);
|
||||
|
||||
assert.deepEqual(received, [
|
||||
{ reason: 'error', playlistEntryId: 3, error: 'loading failed' },
|
||||
{ reason: 'unknown', playlistEntryId: null, error: null },
|
||||
]);
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import { MpvSubtitleRenderMetrics } from '../../types';
|
||||
import { sanitizeMediaTitle } from '../../shared/media-identity';
|
||||
import { widenHalfwidthKana } from '../utils/halfwidth-kana';
|
||||
import { toVideoClock } from './subtitle-clock';
|
||||
|
||||
export type MpvMessage = {
|
||||
event?: string;
|
||||
@@ -10,8 +9,30 @@ export type MpvMessage = {
|
||||
args?: unknown;
|
||||
request_id?: number;
|
||||
error?: string;
|
||||
reason?: unknown;
|
||||
playlist_entry_id?: unknown;
|
||||
file_error?: unknown;
|
||||
};
|
||||
|
||||
const MPV_END_FILE_REASONS = ['eof', 'stop', 'quit', 'error', 'redirect'] as const;
|
||||
export type MpvEndFileReason = (typeof MPV_END_FILE_REASONS)[number] | 'unknown';
|
||||
|
||||
/** mpv's `end-file` event: why the current file stopped, and mpv's error text when it failed. */
|
||||
export interface MpvEndFileEvent {
|
||||
reason: MpvEndFileReason;
|
||||
playlistEntryId: number | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function parseEndFileEvent(msg: MpvMessage): MpvEndFileEvent {
|
||||
const reason = MPV_END_FILE_REASONS.find((known) => known === msg.reason) ?? 'unknown';
|
||||
return {
|
||||
reason,
|
||||
playlistEntryId: typeof msg.playlist_entry_id === 'number' ? msg.playlist_entry_id : null,
|
||||
error: typeof msg.file_error === 'string' && msg.file_error ? msg.file_error : null,
|
||||
};
|
||||
}
|
||||
|
||||
export const MPV_REQUEST_ID_SUBTEXT = 101;
|
||||
export const MPV_REQUEST_ID_PATH = 102;
|
||||
export const MPV_REQUEST_ID_SECONDARY_SUBTEXT = 103;
|
||||
@@ -65,8 +86,6 @@ export interface MpvProtocolHandleMessageDeps {
|
||||
getCurrentSubStart: () => number;
|
||||
setCurrentSubEnd: (value: number) => void;
|
||||
getCurrentSubEnd: () => number;
|
||||
setCurrentSubDelay: (value: number) => void;
|
||||
getCurrentSubDelay: () => number;
|
||||
emitMediaPathChange: (payload: { path: string }) => void;
|
||||
emitMediaTitleChange: (payload: { title: string | null }) => void;
|
||||
emitTimePosChange: (payload: { time: number }) => void;
|
||||
@@ -103,6 +122,7 @@ export interface MpvProtocolHandleMessageDeps {
|
||||
shouldQuitOnMpvShutdown: () => boolean;
|
||||
requestAppQuit: () => void;
|
||||
emitClientMessage?: (payload: { args: string[] }) => void;
|
||||
emitEndFile?: (payload: MpvEndFileEvent) => void;
|
||||
}
|
||||
|
||||
type SubtitleTrackCandidate = {
|
||||
@@ -266,8 +286,7 @@ export async function dispatchMpvProtocolMessage(
|
||||
const subEnd = (msg.data as number) || 0;
|
||||
deps.setCurrentSubEnd(subEnd);
|
||||
if (deps.getPendingPauseAtSubEnd() && subEnd > 0) {
|
||||
// `sub-end` is on the subtitle clock; the pause check compares it with `time-pos`.
|
||||
deps.setPauseAtTime(toVideoClock(subEnd, deps.getCurrentSubDelay()));
|
||||
deps.setPauseAtTime(subEnd);
|
||||
deps.setPendingPauseAtSubEnd(false);
|
||||
deps.sendCommand({ command: ['set_property', 'pause', false] });
|
||||
}
|
||||
@@ -276,16 +295,6 @@ export async function dispatchMpvProtocolMessage(
|
||||
start: deps.getCurrentSubStart(),
|
||||
end: deps.getCurrentSubEnd(),
|
||||
});
|
||||
} else if (msg.name === 'sub-delay') {
|
||||
const parsed = typeof msg.data === 'number' ? msg.data : Number(msg.data);
|
||||
const delay = Number.isFinite(parsed) ? parsed : 0;
|
||||
// A scheduled replay pause sits at the line's end on the video clock; retiming
|
||||
// mid-replay moves that end, so the pause moves with it.
|
||||
const pauseAtTime = deps.getPauseAtTime();
|
||||
if (pauseAtTime !== null) {
|
||||
deps.setPauseAtTime(pauseAtTime + delay - deps.getCurrentSubDelay());
|
||||
}
|
||||
deps.setCurrentSubDelay(delay);
|
||||
} else if (msg.name === 'secondary-sub-text') {
|
||||
const nextSubText = (msg.data as string) || '';
|
||||
deps.setCurrentSecondarySubText(nextSubText);
|
||||
@@ -433,6 +442,8 @@ export async function dispatchMpvProtocolMessage(
|
||||
if (args.length > 0) {
|
||||
deps.emitClientMessage?.({ args });
|
||||
}
|
||||
} else if (msg.event === 'end-file') {
|
||||
deps.emitEndFile?.(parseEndFileEvent(msg));
|
||||
} else if (msg.event === 'shutdown') {
|
||||
deps.restorePreviousSecondarySubVisibility();
|
||||
if (deps.shouldQuitOnMpvShutdown()) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
dispatchMpvProtocolMessage,
|
||||
MPV_REQUEST_ID_TRACK_LIST_AUDIO,
|
||||
MPV_REQUEST_ID_TRACK_LIST_SECONDARY,
|
||||
type MpvEndFileEvent,
|
||||
MpvMessage,
|
||||
MpvProtocolHandleMessageDeps,
|
||||
splitMpvMessagesFromBuffer,
|
||||
@@ -138,6 +139,7 @@ export interface MpvIpcClientEventMap {
|
||||
'subtitle-metrics-change': { patch: Partial<MpvSubtitleRenderMetrics> };
|
||||
'secondary-subtitle-visibility': { visible: boolean };
|
||||
'client-message': { args: string[] };
|
||||
'end-file': MpvEndFileEvent;
|
||||
}
|
||||
|
||||
type MpvIpcClientEventName = keyof MpvIpcClientEventMap;
|
||||
@@ -160,8 +162,6 @@ export class MpvIpcClient implements MpvClient {
|
||||
public currentTimePos = 0;
|
||||
public currentSubStart = 0;
|
||||
public currentSubEnd = 0;
|
||||
/** mpv `sub-delay`: seconds each primary line plays after its `sub-start`/`sub-end`. */
|
||||
public currentSubDelay = 0;
|
||||
public currentSubText = '';
|
||||
public currentSecondarySubText = '';
|
||||
public currentAudioStreamIndex: number | null = null;
|
||||
@@ -461,10 +461,6 @@ export class MpvIpcClient implements MpvClient {
|
||||
this.currentSubEnd = value;
|
||||
},
|
||||
getCurrentSubEnd: () => this.currentSubEnd,
|
||||
setCurrentSubDelay: (value: number) => {
|
||||
this.currentSubDelay = value;
|
||||
},
|
||||
getCurrentSubDelay: () => this.currentSubDelay,
|
||||
emitMediaPathChange: (payload) => {
|
||||
this.emit('media-path-change', payload);
|
||||
},
|
||||
@@ -525,6 +521,9 @@ export class MpvIpcClient implements MpvClient {
|
||||
emitClientMessage: (payload) => {
|
||||
this.emit('client-message', payload);
|
||||
},
|
||||
emitEndFile: (payload) => {
|
||||
this.emit('end-file', payload);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
|
||||
import { execPluginCommand, resolvePluginSpawnTarget } from './exec';
|
||||
|
||||
// The test runtime (bun or node) doubles as a portable child process.
|
||||
const runtime = process.execPath;
|
||||
|
||||
test('pipes stdin to the child and collects stdout', async () => {
|
||||
const result = await execPluginCommand(
|
||||
runtime,
|
||||
['-e', 'process.stdin.on("data", (d) => process.stdout.write(String(d).toUpperCase()))'],
|
||||
{ stdin: '字幕 line' },
|
||||
);
|
||||
|
||||
assert.equal(result.exitCode, 0);
|
||||
assert.equal(result.stdout, '字幕 LINE');
|
||||
});
|
||||
|
||||
test('resolves with a non-zero exit code and stderr', async () => {
|
||||
const result = await execPluginCommand(runtime, [
|
||||
'-e',
|
||||
'process.stderr.write("bad things"); process.exit(3)',
|
||||
]);
|
||||
|
||||
assert.equal(result.exitCode, 3);
|
||||
assert.equal(result.stderr, 'bad things');
|
||||
});
|
||||
|
||||
test('drops the AppImage library path from the child environment', async () => {
|
||||
const previous = process.env.LD_LIBRARY_PATH;
|
||||
process.env.LD_LIBRARY_PATH = '/tmp/.mount_SubMinXXXXXX/usr/lib';
|
||||
try {
|
||||
const result = await execPluginCommand(
|
||||
runtime,
|
||||
['-e', 'process.stdout.write(String(process.env.LD_LIBRARY_PATH) + "|" + process.env.EXTRA)'],
|
||||
{ env: { EXTRA: 'kept' } },
|
||||
);
|
||||
assert.equal(result.stdout, 'undefined|kept');
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.LD_LIBRARY_PATH;
|
||||
else process.env.LD_LIBRARY_PATH = previous;
|
||||
}
|
||||
});
|
||||
|
||||
test('aborting kills the child and rejects with AbortError', async () => {
|
||||
const controller = new AbortController();
|
||||
const pending = execPluginCommand(runtime, ['-e', 'setTimeout(() => {}, 60_000)'], {
|
||||
signal: controller.signal,
|
||||
});
|
||||
setTimeout(() => controller.abort(), 50);
|
||||
|
||||
await assert.rejects(pending, { name: 'AbortError' });
|
||||
});
|
||||
|
||||
test('force-kills a child that ignores SIGTERM after the grace period', async (t) => {
|
||||
if (process.platform === 'win32') return t.skip('POSIX signals only');
|
||||
const pidFile = path.join(await mkdtemp(path.join(os.tmpdir(), 'plugin-exec-')), 'pid');
|
||||
const controller = new AbortController();
|
||||
const pending = execPluginCommand(
|
||||
runtime,
|
||||
[
|
||||
'-e',
|
||||
`process.on("SIGTERM", () => {}); require("node:fs").writeFileSync(${JSON.stringify(pidFile)}, String(process.pid)); setInterval(() => {}, 1000);`,
|
||||
],
|
||||
{ signal: controller.signal },
|
||||
100,
|
||||
);
|
||||
|
||||
let pid = 0;
|
||||
for (let i = 0; i < 100 && !pid; i += 1) {
|
||||
pid = Number(await readFile(pidFile, 'utf8').catch(() => '0'));
|
||||
if (!pid) await sleep(20);
|
||||
}
|
||||
assert.ok(pid > 0, 'child never reported its pid');
|
||||
try {
|
||||
controller.abort();
|
||||
await assert.rejects(pending, { name: 'AbortError' });
|
||||
let alive = true;
|
||||
for (let i = 0; i < 100 && alive; i += 1) {
|
||||
await sleep(20);
|
||||
alive = isRunning(pid);
|
||||
}
|
||||
assert.equal(alive, false, 'child survived SIGTERM');
|
||||
} finally {
|
||||
if (isRunning(pid)) process.kill(pid, 'SIGKILL');
|
||||
await rm(path.dirname(pidFile), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function isRunning(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
test('rejects when the binary does not exist', async () => {
|
||||
await assert.rejects(
|
||||
execPluginCommand('subminer-definitely-missing-binary', []),
|
||||
(error: NodeJS.ErrnoException) => error.code === 'ENOENT',
|
||||
);
|
||||
});
|
||||
|
||||
test('Windows batch shims run through cmd.exe; other commands spawn directly', async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), 'plugin-exec-shim-'));
|
||||
try {
|
||||
await writeFile(path.join(dir, 'codex.cmd'), '');
|
||||
await writeFile(path.join(dir, 'native.exe'), '');
|
||||
const env = { PATH: dir, PATHEXT: '.exe;.cmd', ComSpec: 'cmd.exe' };
|
||||
|
||||
const shim = resolvePluginSpawnTarget('codex', ['exec', '-'], env, 'win32');
|
||||
assert.equal(shim.viaShell, true);
|
||||
assert.equal(shim.file, 'cmd.exe');
|
||||
assert.deepEqual(shim.args.slice(0, 4), ['/d', '/s', '/v:off', '/c']);
|
||||
assert.ok(shim.args[4]!.includes(path.join(dir, 'codex.cmd')));
|
||||
|
||||
assert.deepEqual(resolvePluginSpawnTarget('native', ['-v'], env, 'win32'), {
|
||||
file: path.join(dir, 'native.exe'),
|
||||
args: ['-v'],
|
||||
viaShell: false,
|
||||
});
|
||||
assert.deepEqual(resolvePluginSpawnTarget('codex', ['-'], env, 'linux'), {
|
||||
file: 'codex',
|
||||
args: ['-'],
|
||||
viaShell: false,
|
||||
});
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { type ChildProcess, spawn } from 'node:child_process';
|
||||
|
||||
import { buildSystemBinaryEnv } from '../../utils/system-binary-env';
|
||||
import {
|
||||
buildWindowsShimInvocation,
|
||||
isWindowsCommandShim,
|
||||
resolveWindowsCommand,
|
||||
} from '../../utils/windows-command-shim';
|
||||
import type { PluginExecOptions, PluginExecResult } from './types';
|
||||
|
||||
const MAX_CAPTURED_OUTPUT_CHARS = 4 * 1024 * 1024;
|
||||
const DEFAULT_KILL_GRACE_MS = 3_000;
|
||||
|
||||
function appendCapped(current: string, chunk: string): string {
|
||||
if (current.length >= MAX_CAPTURED_OUTPUT_CHARS) return current;
|
||||
return (current + chunk).slice(0, MAX_CAPTURED_OUTPUT_CHARS);
|
||||
}
|
||||
|
||||
interface SpawnTarget {
|
||||
file: string;
|
||||
args: string[];
|
||||
viaShell: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows batch shims (npm installs `codex` as `codex.cmd`) only run through cmd.exe. Native
|
||||
* binaries, and every command on Linux and macOS, are spawned directly.
|
||||
*/
|
||||
export function resolvePluginSpawnTarget(
|
||||
command: string,
|
||||
args: string[],
|
||||
env: NodeJS.ProcessEnv,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): SpawnTarget {
|
||||
if (platform !== 'win32') return { file: command, args, viaShell: false };
|
||||
const resolved = resolveWindowsCommand(command, env);
|
||||
if (!isWindowsCommandShim(resolved)) return { file: resolved, args, viaShell: false };
|
||||
return { ...buildWindowsShimInvocation(resolved, args, env), viaShell: true };
|
||||
}
|
||||
|
||||
// Killing cmd.exe alone would leave the shim's own child running, so end the whole tree.
|
||||
function killWindowsProcessTree(child: ChildProcess): void {
|
||||
if (child.pid === undefined) return;
|
||||
spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
}).on('error', () => child.kill());
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs `command` (no shell, except cmd.exe for Windows batch shims) and collects its output. Resolves with the exit code on any exit,
|
||||
* including non-zero; rejects when the binary cannot start, the timeout passes, or `signal` aborts.
|
||||
* A timed-out or aborted child gets SIGTERM, then SIGKILL after `killGraceMs` (a shim's
|
||||
* process tree is ended with taskkill instead).
|
||||
*/
|
||||
export function execPluginCommand(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: PluginExecOptions = {},
|
||||
killGraceMs = DEFAULT_KILL_GRACE_MS,
|
||||
): Promise<PluginExecResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (options.signal?.aborted) {
|
||||
reject(new DOMException('Command aborted.', 'AbortError'));
|
||||
return;
|
||||
}
|
||||
|
||||
const env = { ...buildSystemBinaryEnv(), ...options.env };
|
||||
const target = resolvePluginSpawnTarget(command, args, env);
|
||||
const child = spawn(target.file, target.args, {
|
||||
cwd: options.cwd,
|
||||
env,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
windowsVerbatimArguments: target.viaShell,
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let settled = false;
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const settle = (finish: () => void): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timeout) clearTimeout(timeout);
|
||||
options.signal?.removeEventListener('abort', onAbort);
|
||||
finish();
|
||||
};
|
||||
|
||||
// Rejects right away, then escalates to SIGKILL if the child ignores SIGTERM.
|
||||
const killWith = (error: Error): void => {
|
||||
settle(() => reject(error));
|
||||
if (target.viaShell) {
|
||||
killWindowsProcessTree(child);
|
||||
return;
|
||||
}
|
||||
child.kill('SIGTERM');
|
||||
const forceKill = setTimeout(() => {
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
|
||||
}, killGraceMs);
|
||||
forceKill.unref();
|
||||
child.once('exit', () => clearTimeout(forceKill));
|
||||
};
|
||||
|
||||
function onAbort(): void {
|
||||
killWith(new DOMException('Command aborted.', 'AbortError'));
|
||||
}
|
||||
|
||||
options.signal?.addEventListener('abort', onAbort, { once: true });
|
||||
if (options.timeoutMs !== undefined && options.timeoutMs > 0) {
|
||||
timeout = setTimeout(
|
||||
() => killWith(new Error(`${command} timed out after ${options.timeoutMs} ms.`)),
|
||||
options.timeoutMs,
|
||||
);
|
||||
}
|
||||
|
||||
child.stdout.setEncoding('utf8');
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stdout.on('data', (chunk: string) => {
|
||||
stdout = appendCapped(stdout, chunk);
|
||||
});
|
||||
child.stderr.on('data', (chunk: string) => {
|
||||
stderr = appendCapped(stderr, chunk);
|
||||
});
|
||||
// A child that exits without reading stdin raises EPIPE here; the exit code tells the story.
|
||||
child.stdin.on('error', () => {});
|
||||
|
||||
child.on('error', (error) => settle(() => reject(error)));
|
||||
child.on('close', (exitCode) => settle(() => resolve({ exitCode, stdout, stderr })));
|
||||
|
||||
child.stdin.end(options.stdin ?? '');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import type { PluginManifest } from './types';
|
||||
|
||||
const PLUGIN_NAME_PATTERN = /^[a-z0-9][a-z0-9._-]*$/i;
|
||||
|
||||
/** Plugin ids double as directory names and action prefixes, and action names follow the same rule. */
|
||||
export function isValidPluginName(id: string): boolean {
|
||||
return PLUGIN_NAME_PATTERN.test(id);
|
||||
}
|
||||
|
||||
/** Validates parsed `plugin.json` content. Returns an error message instead of throwing. */
|
||||
export function parsePluginManifest(
|
||||
raw: unknown,
|
||||
fallbackName: string,
|
||||
): { manifest: PluginManifest } | { error: string } {
|
||||
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
||||
return { error: 'plugin.json must contain a JSON object.' };
|
||||
}
|
||||
const value = raw as Record<string, unknown>;
|
||||
if (typeof value.main !== 'string' || value.main.trim().length === 0) {
|
||||
return { error: 'plugin.json needs a "main" entry file.' };
|
||||
}
|
||||
if (value.name !== undefined && typeof value.name !== 'string') {
|
||||
return { error: 'plugin.json "name" must be a string.' };
|
||||
}
|
||||
if (value.version !== undefined && typeof value.version !== 'string') {
|
||||
return { error: 'plugin.json "version" must be a string.' };
|
||||
}
|
||||
return {
|
||||
manifest: {
|
||||
name: typeof value.name === 'string' && value.name.trim() ? value.name.trim() : fallbackName,
|
||||
version: typeof value.version === 'string' ? value.version : null,
|
||||
main: value.main.trim(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolves a plugin file (entry, window HTML, preload), rejecting paths that leave the plugin directory. */
|
||||
export function resolvePluginFilePath(pluginDir: string, file: string): string | null {
|
||||
const resolved = path.resolve(pluginDir, file);
|
||||
const relative = path.relative(pluginDir, resolved);
|
||||
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
return null;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { BrowserWindow } from 'electron';
|
||||
|
||||
import type { SessionActionDispatchRequest } from '../../../types/runtime';
|
||||
import { createPluginHost, type PluginHostDeps } from './plugin-host';
|
||||
import type {
|
||||
PluginConfigEntry,
|
||||
PluginLogger,
|
||||
PluginMpvEventName,
|
||||
PluginWindowRequest,
|
||||
} from './types';
|
||||
|
||||
function writePlugin(root: string, id: string, files: Record<string, string>): void {
|
||||
const dir = path.join(root, id);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
for (const [name, content] of Object.entries(files)) {
|
||||
writeFileSync(path.join(dir, name), content);
|
||||
}
|
||||
}
|
||||
|
||||
type FakeWindow = ReturnType<typeof createFakeWindow>;
|
||||
|
||||
/** The slice of BrowserWindow the host touches: destroy, isDestroyed, and the `closed` event. */
|
||||
function createFakeWindow() {
|
||||
const events = new EventEmitter();
|
||||
const fake = {
|
||||
destroyed: false,
|
||||
isDestroyed: () => fake.destroyed,
|
||||
destroy: () => {
|
||||
fake.destroyed = true;
|
||||
events.emit('closed');
|
||||
},
|
||||
once: (event: string, listener: () => void) => events.once(event, listener),
|
||||
asWindow: () => fake as unknown as BrowserWindow,
|
||||
};
|
||||
return fake;
|
||||
}
|
||||
|
||||
function createHarness(
|
||||
config: Record<string, PluginConfigEntry>,
|
||||
overrides: Partial<PluginHostDeps<{ marker: string }>> = {},
|
||||
) {
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), 'subminer-plugins-'));
|
||||
const errors: string[] = [];
|
||||
const notifications: string[] = [];
|
||||
const sessionActions: SessionActionDispatchRequest[] = [];
|
||||
const windowRequests: PluginWindowRequest[] = [];
|
||||
const windows = new Map<string, FakeWindow>();
|
||||
const logger: PluginLogger = {
|
||||
debug: () => {},
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: (message) => errors.push(message),
|
||||
};
|
||||
const deps: PluginHostDeps<{ marker: string }> = {
|
||||
pluginsDir: path.join(root, 'plugins'),
|
||||
pluginDataDir: path.join(root, 'data'),
|
||||
getPluginConfig: () => config,
|
||||
createLogger: () => logger,
|
||||
isMpvConnected: () => true,
|
||||
sendMpvCommand: () => {},
|
||||
requestMpvProperty: async () => null,
|
||||
ensureMpvRunning: async () => true,
|
||||
dispatchSessionAction: async (request) => {
|
||||
sessionActions.push(request);
|
||||
},
|
||||
getCurrentSubtitle: () => null,
|
||||
getSubtitleContext: () => ({ current: null, before: [], after: [] }),
|
||||
notify: (message) => notifications.push(message),
|
||||
openPanel: () => {
|
||||
throw new Error('not used');
|
||||
},
|
||||
openWindow: (request) => {
|
||||
windowRequests.push(request);
|
||||
const existing = windows.get(request.key);
|
||||
if (existing && !existing.destroyed) return { window: existing.asWindow(), created: false };
|
||||
const window = createFakeWindow();
|
||||
windows.set(request.key, window);
|
||||
return { window: window.asWindow(), created: true };
|
||||
},
|
||||
unstable: { marker: 'internals' },
|
||||
...overrides,
|
||||
};
|
||||
return {
|
||||
root: deps.pluginsDir,
|
||||
dataRoot: deps.pluginDataDir,
|
||||
errors,
|
||||
notifications,
|
||||
sessionActions,
|
||||
windowRequests,
|
||||
windows,
|
||||
host: createPluginHost(deps),
|
||||
cleanup: () => rmSync(root, { recursive: true, force: true }),
|
||||
};
|
||||
}
|
||||
|
||||
const enabled = (settings: Record<string, unknown> = {}): PluginConfigEntry => ({
|
||||
enabled: true,
|
||||
settings,
|
||||
});
|
||||
|
||||
function createFakeMpvClient() {
|
||||
const bus = new EventEmitter();
|
||||
return {
|
||||
on: (event: PluginMpvEventName, listener: (payload: unknown) => void) =>
|
||||
bus.on(event, listener),
|
||||
off: (event: PluginMpvEventName, listener: (payload: unknown) => void) =>
|
||||
bus.off(event, listener),
|
||||
emit: (event: PluginMpvEventName, payload: unknown) => bus.emit(event, payload),
|
||||
};
|
||||
}
|
||||
|
||||
test('loads enabled plugins and runs their registered actions', async () => {
|
||||
const h = createHarness({
|
||||
greeter: enabled({ greeting: 'hi' }),
|
||||
off: { enabled: false, settings: {} },
|
||||
});
|
||||
try {
|
||||
writePlugin(h.root, 'greeter', {
|
||||
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
|
||||
'main.cjs': `exports.activate = (ctx) => {
|
||||
ctx.actions.register('say', () => ctx.notify(ctx.settings.greeting + ' ' + ctx.unstable.marker));
|
||||
};`,
|
||||
});
|
||||
writePlugin(h.root, 'off', {
|
||||
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
|
||||
'main.cjs': `throw new Error('disabled plugins must not load');`,
|
||||
});
|
||||
|
||||
await h.host.loadAll();
|
||||
|
||||
assert.equal(await h.host.runAction('greeter/say'), true);
|
||||
assert.deepEqual(h.notifications, ['hi internals']);
|
||||
assert.equal(await h.host.runAction('greeter/missing'), false);
|
||||
assert.deepEqual(h.errors, []);
|
||||
} finally {
|
||||
h.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('loads ES module plugins', async () => {
|
||||
const h = createHarness({ esm: enabled() });
|
||||
try {
|
||||
writePlugin(h.root, 'esm', {
|
||||
'plugin.json': JSON.stringify({ main: 'main.mjs' }),
|
||||
'main.mjs': `export function activate(ctx) { ctx.actions.register('go', () => ctx.notify('esm')); }`,
|
||||
});
|
||||
|
||||
await h.host.loadAll();
|
||||
|
||||
assert.equal(await h.host.runAction('esm/go'), true);
|
||||
assert.deepEqual(h.notifications, ['esm']);
|
||||
} finally {
|
||||
h.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('a failed activation rolls back its registrations without affecting other plugins', async () => {
|
||||
const h = createHarness({ broken: enabled(), healthy: enabled() });
|
||||
try {
|
||||
writePlugin(h.root, 'broken', {
|
||||
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
|
||||
'main.cjs': `exports.activate = (ctx) => {
|
||||
ctx.actions.register('half', () => {});
|
||||
throw new Error('boom');
|
||||
};`,
|
||||
});
|
||||
writePlugin(h.root, 'healthy', {
|
||||
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
|
||||
'main.cjs': `exports.activate = (ctx) => { ctx.actions.register('ok', () => {}); };`,
|
||||
});
|
||||
|
||||
await h.host.loadAll();
|
||||
|
||||
assert.equal(await h.host.runAction('broken/half'), false);
|
||||
assert.equal(await h.host.runAction('healthy/ok'), true);
|
||||
assert.deepEqual(h.errors, ['Activation failed: boom']);
|
||||
} finally {
|
||||
h.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects entry files outside the plugin directory', async () => {
|
||||
const h = createHarness({ escape: enabled() });
|
||||
try {
|
||||
writePlugin(h.root, 'escape', {
|
||||
'plugin.json': JSON.stringify({ main: '../elsewhere.cjs' }),
|
||||
});
|
||||
|
||||
await h.host.loadAll();
|
||||
|
||||
assert.equal(h.errors.length, 1);
|
||||
assert.match(h.errors[0]!, /must stay inside/);
|
||||
} finally {
|
||||
h.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('mpv listener errors are contained and unload detaches listeners and calls deactivate', async () => {
|
||||
const h = createHarness({ watcher: enabled() });
|
||||
const client = createFakeMpvClient();
|
||||
try {
|
||||
writePlugin(h.root, 'watcher', {
|
||||
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
|
||||
'main.cjs': `exports.activate = (ctx) => {
|
||||
ctx.mpv.on('pause-change', ({ paused }) => {
|
||||
ctx.notify('paused=' + paused);
|
||||
throw new Error('listener bug');
|
||||
});
|
||||
return () => ctx.notify('deactivated');
|
||||
};`,
|
||||
});
|
||||
h.host.attachMpvClient(client);
|
||||
await h.host.loadAll();
|
||||
|
||||
client.emit('pause-change', { paused: true });
|
||||
assert.deepEqual(h.notifications, ['paused=true']);
|
||||
assert.deepEqual(h.errors, ['mpv pause-change listener failed: listener bug']);
|
||||
|
||||
await h.host.unloadAll();
|
||||
client.emit('pause-change', { paused: false });
|
||||
assert.deepEqual(h.notifications, ['paused=true', 'deactivated']);
|
||||
} finally {
|
||||
h.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('unload gives up on a deactivate that never settles and still disposes the plugin', async () => {
|
||||
const h = createHarness({ stuck: enabled() }, { deactivateTimeoutMs: 20 });
|
||||
const client = createFakeMpvClient();
|
||||
try {
|
||||
writePlugin(h.root, 'stuck', {
|
||||
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
|
||||
'main.cjs': `exports.activate = (ctx) => {
|
||||
ctx.mpv.on('pause-change', () => ctx.notify('still listening'));
|
||||
return () => new Promise(() => {});
|
||||
};`,
|
||||
});
|
||||
h.host.attachMpvClient(client);
|
||||
await h.host.loadAll();
|
||||
|
||||
await h.host.unloadAll();
|
||||
client.emit('pause-change', { paused: true });
|
||||
|
||||
assert.deepEqual(h.notifications, []);
|
||||
assert.deepEqual(h.errors, ['deactivate failed: timed out after 20 ms']);
|
||||
} finally {
|
||||
h.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('events follow a replacement mpv client', async () => {
|
||||
const h = createHarness({ watcher: enabled() });
|
||||
const first = createFakeMpvClient();
|
||||
const second = createFakeMpvClient();
|
||||
try {
|
||||
writePlugin(h.root, 'watcher', {
|
||||
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
|
||||
'main.cjs': `exports.activate = (ctx) => {
|
||||
ctx.mpv.on('media-path-change', ({ path }) => ctx.notify(path));
|
||||
};`,
|
||||
});
|
||||
h.host.attachMpvClient(first);
|
||||
await h.host.loadAll();
|
||||
h.host.attachMpvClient(second);
|
||||
|
||||
first.emit('media-path-change', { path: 'stale.mkv' });
|
||||
second.emit('media-path-change', { path: 'current.mkv' });
|
||||
|
||||
assert.deepEqual(h.notifications, ['current.mkv']);
|
||||
} finally {
|
||||
h.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('runAction waits for a load that is still in progress', async () => {
|
||||
const h = createHarness({ slow: enabled() });
|
||||
try {
|
||||
writePlugin(h.root, 'slow', {
|
||||
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
|
||||
'main.cjs': `exports.activate = async (ctx) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
ctx.actions.register('go', () => ctx.notify('went'));
|
||||
};`,
|
||||
});
|
||||
|
||||
void h.host.loadAll();
|
||||
|
||||
assert.equal(await h.host.runAction('slow/go'), true);
|
||||
assert.deepEqual(h.notifications, ['went']);
|
||||
} finally {
|
||||
h.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('each plugin gets its own data folder, created before activate', async () => {
|
||||
const h = createHarness({ keeper: enabled() });
|
||||
try {
|
||||
writePlugin(h.root, 'keeper', {
|
||||
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
|
||||
'main.cjs': `const fs = require('node:fs');
|
||||
exports.activate = (ctx) => {
|
||||
fs.writeFileSync(require('node:path').join(ctx.dataDir, 'state.json'), '{}');
|
||||
ctx.notify(ctx.dataDir);
|
||||
};`,
|
||||
});
|
||||
|
||||
await h.host.loadAll();
|
||||
|
||||
const expected = path.join(h.dataRoot, 'keeper');
|
||||
assert.deepEqual(h.notifications, [expected]);
|
||||
assert.equal(existsSync(path.join(expected, 'state.json')), true);
|
||||
assert.deepEqual(h.errors, []);
|
||||
} finally {
|
||||
h.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('actions.run forwards built-in session actions', async () => {
|
||||
const h = createHarness({ caller: enabled() });
|
||||
try {
|
||||
writePlugin(h.root, 'caller', {
|
||||
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
|
||||
'main.cjs': `exports.activate = async (ctx) => {
|
||||
await ctx.actions.run('openJimaku');
|
||||
await ctx.actions.run('cycleRuntimeOption', { runtimeOptionId: 'x', direction: -1 });
|
||||
};`,
|
||||
});
|
||||
|
||||
await h.host.loadAll();
|
||||
|
||||
assert.deepEqual(h.sessionActions, [
|
||||
{ actionId: 'openJimaku' },
|
||||
{ actionId: 'cycleRuntimeOption', payload: { runtimeOptionId: 'x', direction: -1 } },
|
||||
]);
|
||||
} finally {
|
||||
h.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('openWindow resolves plugin files, reuses open windows, and closes them on unload', async () => {
|
||||
const h = createHarness({ viewer: enabled() });
|
||||
try {
|
||||
writePlugin(h.root, 'viewer', {
|
||||
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
|
||||
'main.cjs': `exports.activate = (ctx) => {
|
||||
ctx.actions.register('open', () => ctx.ui.openWindow({ html: 'ui/index.html', preload: 'preload.js', width: 400 }));
|
||||
ctx.actions.register('escape', () => ctx.ui.openWindow({ html: '../other/index.html' }));
|
||||
};`,
|
||||
});
|
||||
await h.host.loadAll();
|
||||
|
||||
await h.host.runAction('viewer/open');
|
||||
await h.host.runAction('viewer/open');
|
||||
const dir = path.join(h.root, 'viewer');
|
||||
assert.deepEqual(h.windowRequests[0], {
|
||||
key: `viewer:${path.join(dir, 'ui', 'index.html')}`,
|
||||
htmlPath: path.join(dir, 'ui', 'index.html'),
|
||||
preloadPath: path.join(dir, 'preload.js'),
|
||||
title: 'viewer',
|
||||
width: 400,
|
||||
height: 720,
|
||||
});
|
||||
assert.equal(h.windows.size, 1);
|
||||
|
||||
await h.host.runAction('viewer/escape');
|
||||
assert.equal(h.windowRequests.length, 2);
|
||||
assert.match(h.errors[0] ?? '', /must stay inside/);
|
||||
|
||||
const [window] = [...h.windows.values()];
|
||||
await h.host.unloadAll();
|
||||
assert.equal(window?.destroyed, true);
|
||||
} finally {
|
||||
h.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,400 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { BrowserWindow } from 'electron';
|
||||
|
||||
import type { SessionActionDispatchRequest } from '../../../types/runtime';
|
||||
import { execPluginCommand } from './exec';
|
||||
import { isValidPluginName, parsePluginManifest, resolvePluginFilePath } from './manifest';
|
||||
import {
|
||||
PLUGIN_MPV_EVENT_NAMES,
|
||||
type PluginConfigEntry,
|
||||
type PluginContext,
|
||||
type PluginLogger,
|
||||
type PluginModule,
|
||||
type PluginMpvEventName,
|
||||
type PluginNotifyVariant,
|
||||
type PluginPanelContent,
|
||||
type PluginPanelHandle,
|
||||
type PluginSubtitleContext,
|
||||
type PluginSubtitleLine,
|
||||
type PluginWindowRequest,
|
||||
} from './types';
|
||||
|
||||
const MIN_TIMER_INTERVAL_MS = 100;
|
||||
const DEFAULT_WINDOW_WIDTH = 960;
|
||||
const DEFAULT_WINDOW_HEIGHT = 720;
|
||||
// Quit waits for unloadAll, so a deactivate that never settles must not hold the app open.
|
||||
const DEFAULT_DEACTIVATE_TIMEOUT_MS = 3_000;
|
||||
|
||||
interface MpvClientEvents {
|
||||
on: (event: PluginMpvEventName, listener: (payload: unknown) => void) => void;
|
||||
off: (event: PluginMpvEventName, listener: (payload: unknown) => void) => void;
|
||||
}
|
||||
|
||||
export interface PluginHostDeps<TUnstable> {
|
||||
pluginsDir: string;
|
||||
/** Root of the per-plugin data folders; each plugin gets `<pluginDataDir>/<id>`. */
|
||||
pluginDataDir: string;
|
||||
getPluginConfig: () => Record<string, PluginConfigEntry>;
|
||||
createLogger: (scope: string) => PluginLogger;
|
||||
isMpvConnected: () => boolean;
|
||||
sendMpvCommand: (command: (string | number)[]) => void;
|
||||
requestMpvProperty: (name: string) => Promise<unknown>;
|
||||
ensureMpvRunning: () => Promise<boolean>;
|
||||
dispatchSessionAction: (request: SessionActionDispatchRequest) => Promise<void>;
|
||||
getCurrentSubtitle: () => PluginSubtitleLine | null;
|
||||
getSubtitleContext: (options: { before?: number; after?: number }) => PluginSubtitleContext;
|
||||
notify: (message: string, variant: PluginNotifyVariant) => void;
|
||||
openPanel: (content: PluginPanelContent) => PluginPanelHandle;
|
||||
/** Focuses the window already open under `request.key`, or creates it (`created: true`). */
|
||||
openWindow: (request: PluginWindowRequest) => { window: BrowserWindow; created: boolean };
|
||||
unstable: TUnstable;
|
||||
/** Test seam; defaults to Node's `require`, which also loads synchronous ES modules. */
|
||||
loadModule?: (entryPath: string) => PluginModule;
|
||||
exec?: typeof execPluginCommand;
|
||||
/** How long unload waits for each plugin's deactivate before disposing it anyway. */
|
||||
deactivateTimeoutMs?: number;
|
||||
}
|
||||
|
||||
interface LoadedPlugin {
|
||||
id: string;
|
||||
disposers: Set<() => void>;
|
||||
deactivate: (() => unknown) | null;
|
||||
/** Aborted on unload so in-flight `exec` children die with the plugin. */
|
||||
lifetime: AbortController;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
typeof (value as { then?: unknown }).then === 'function'
|
||||
);
|
||||
}
|
||||
|
||||
function pickExport<K extends 'activate' | 'deactivate'>(
|
||||
mod: PluginModule,
|
||||
key: K,
|
||||
): NonNullable<PluginModule[K]> | null {
|
||||
const direct = mod[key];
|
||||
if (typeof direct === 'function') return direct as NonNullable<PluginModule[K]>;
|
||||
const fromDefault = mod.default?.[key];
|
||||
return typeof fromDefault === 'function' ? (fromDefault as NonNullable<PluginModule[K]>) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads enabled plugins from `<pluginsDir>/<id>/plugin.json` and gives each one a scoped context.
|
||||
* Every plugin callback runs behind a guard, so a throwing plugin logs instead of breaking the app.
|
||||
* Plugins run in the main process: a synchronous busy loop still blocks it.
|
||||
*/
|
||||
export function createPluginHost<TUnstable>(deps: PluginHostDeps<TUnstable>) {
|
||||
const mpvEvents = new EventEmitter();
|
||||
mpvEvents.setMaxListeners(0);
|
||||
const plugins = new Map<string, LoadedPlugin>();
|
||||
const actions = new Map<string, { pluginId: string; handler: () => unknown }>();
|
||||
const loadModule = deps.loadModule ?? ((entryPath: string) => require(entryPath) as PluginModule);
|
||||
const exec = deps.exec ?? execPluginCommand;
|
||||
let attachedClient: {
|
||||
client: MpvClientEvents;
|
||||
forwarders: Map<string, (p: unknown) => void>;
|
||||
} | null = null;
|
||||
let loadPromise: Promise<void> | null = null;
|
||||
|
||||
function runGuarded(log: PluginLogger, label: string, callback: () => unknown): void {
|
||||
try {
|
||||
const result = callback();
|
||||
if (isPromiseLike(result)) {
|
||||
Promise.resolve(result).catch((error) =>
|
||||
log.error(`${label} failed: ${errorMessage(error)}`),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(`${label} failed: ${errorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function createContext(
|
||||
plugin: LoadedPlugin,
|
||||
dir: string,
|
||||
dataDir: string,
|
||||
entry: PluginConfigEntry,
|
||||
log: PluginLogger,
|
||||
): PluginContext<TUnstable> {
|
||||
const track = (dispose: () => void): (() => void) => {
|
||||
let disposed = false;
|
||||
const disposer = (): void => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
plugin.disposers.delete(disposer);
|
||||
dispose();
|
||||
};
|
||||
plugin.disposers.add(disposer);
|
||||
return disposer;
|
||||
};
|
||||
|
||||
return {
|
||||
id: plugin.id,
|
||||
dir,
|
||||
settings: Object.freeze({ ...entry.settings }),
|
||||
dataDir,
|
||||
log,
|
||||
every: (intervalMs, callback) => {
|
||||
const handle = setInterval(
|
||||
() => runGuarded(log, 'Timer', callback),
|
||||
Math.max(MIN_TIMER_INTERVAL_MS, intervalMs),
|
||||
);
|
||||
return track(() => clearInterval(handle));
|
||||
},
|
||||
after: (delayMs, callback) => {
|
||||
const handle = setTimeout(
|
||||
() => {
|
||||
dispose();
|
||||
runGuarded(log, 'Timer', callback);
|
||||
},
|
||||
Math.max(0, delayMs),
|
||||
);
|
||||
const dispose = track(() => clearTimeout(handle));
|
||||
return dispose;
|
||||
},
|
||||
mpv: {
|
||||
get connected() {
|
||||
return deps.isMpvConnected();
|
||||
},
|
||||
on: (event, listener) => {
|
||||
const wrapped = (payload: unknown): void =>
|
||||
runGuarded(log, `mpv ${event} listener`, () =>
|
||||
listener(payload as Parameters<typeof listener>[0]),
|
||||
);
|
||||
mpvEvents.on(event, wrapped);
|
||||
return track(() => mpvEvents.off(event, wrapped));
|
||||
},
|
||||
command: (command) => deps.sendMpvCommand(command),
|
||||
getProperty: (name) => deps.requestMpvProperty(name),
|
||||
ensureRunning: () => deps.ensureMpvRunning(),
|
||||
},
|
||||
subtitles: {
|
||||
current: () => deps.getCurrentSubtitle(),
|
||||
context: (options = {}) => deps.getSubtitleContext(options),
|
||||
},
|
||||
actions: {
|
||||
register: (name, handler) => {
|
||||
if (!isValidPluginName(name)) {
|
||||
throw new Error(`Invalid action name "${name}"; use letters, digits, ".", "_" or "-".`);
|
||||
}
|
||||
const actionId = `${plugin.id}/${name}`;
|
||||
if (actions.has(actionId)) {
|
||||
throw new Error(`Action "${actionId}" is already registered.`);
|
||||
}
|
||||
actions.set(actionId, { pluginId: plugin.id, handler });
|
||||
return track(() => actions.delete(actionId));
|
||||
},
|
||||
run: (actionId, payload) =>
|
||||
deps.dispatchSessionAction(payload === undefined ? { actionId } : { actionId, payload }),
|
||||
},
|
||||
notify: (message, options) => deps.notify(message, options?.variant ?? 'info'),
|
||||
ui: {
|
||||
panel: (content) => {
|
||||
const handle = deps.openPanel({
|
||||
title: content.title ?? plugin.id,
|
||||
markdown: content.markdown,
|
||||
});
|
||||
const dispose = track(() => handle.close());
|
||||
handle.signal.addEventListener('abort', dispose, { once: true });
|
||||
return handle;
|
||||
},
|
||||
openWindow: (options) => {
|
||||
const resolveFile = (label: string, file: string): string => {
|
||||
const resolved = resolvePluginFilePath(dir, file);
|
||||
if (!resolved) throw new Error(`Window ${label} "${file}" must stay inside ${dir}.`);
|
||||
return resolved;
|
||||
};
|
||||
const htmlPath = resolveFile('html', options.html);
|
||||
const { window, created } = deps.openWindow({
|
||||
key: `${plugin.id}:${htmlPath}`,
|
||||
htmlPath,
|
||||
preloadPath:
|
||||
options.preload === undefined ? null : resolveFile('preload', options.preload),
|
||||
title: options.title ?? plugin.id,
|
||||
width: options.width ?? DEFAULT_WINDOW_WIDTH,
|
||||
height: options.height ?? DEFAULT_WINDOW_HEIGHT,
|
||||
});
|
||||
if (created) {
|
||||
let closed = false;
|
||||
const dispose = track(() => {
|
||||
if (!closed && !window.isDestroyed()) window.destroy();
|
||||
});
|
||||
window.once('closed', () => {
|
||||
closed = true;
|
||||
dispose();
|
||||
});
|
||||
}
|
||||
return window;
|
||||
},
|
||||
},
|
||||
exec: (command, args, options = {}) =>
|
||||
exec(command, args, {
|
||||
...options,
|
||||
signal: options.signal
|
||||
? AbortSignal.any([options.signal, plugin.lifetime.signal])
|
||||
: plugin.lifetime.signal,
|
||||
}),
|
||||
unstable: deps.unstable,
|
||||
};
|
||||
}
|
||||
|
||||
function disposePlugin(plugin: LoadedPlugin): void {
|
||||
plugin.lifetime.abort();
|
||||
for (const dispose of [...plugin.disposers]) {
|
||||
try {
|
||||
dispose();
|
||||
} catch {
|
||||
// A failing disposer must not keep the rest registered.
|
||||
}
|
||||
}
|
||||
plugins.delete(plugin.id);
|
||||
}
|
||||
|
||||
async function loadPlugin(id: string, entry: PluginConfigEntry): Promise<void> {
|
||||
const log = deps.createLogger(`plugin:${id}`);
|
||||
if (!isValidPluginName(id)) {
|
||||
log.error(`Invalid plugin id "${id}"; use letters, digits, ".", "_" or "-".`);
|
||||
return;
|
||||
}
|
||||
|
||||
const dir = path.join(deps.pluginsDir, id);
|
||||
let rawManifest: unknown;
|
||||
try {
|
||||
rawManifest = JSON.parse(await fs.readFile(path.join(dir, 'plugin.json'), 'utf8'));
|
||||
} catch (error) {
|
||||
log.error(`Cannot read ${path.join(dir, 'plugin.json')}: ${errorMessage(error)}`);
|
||||
return;
|
||||
}
|
||||
const parsed = parsePluginManifest(rawManifest, id);
|
||||
if ('error' in parsed) {
|
||||
log.error(parsed.error);
|
||||
return;
|
||||
}
|
||||
const entryPath = resolvePluginFilePath(dir, parsed.manifest.main);
|
||||
if (!entryPath) {
|
||||
log.error(`Entry "${parsed.manifest.main}" must stay inside ${dir}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const dataDir = path.join(deps.pluginDataDir, id);
|
||||
try {
|
||||
await fs.mkdir(dataDir, { recursive: true });
|
||||
} catch (error) {
|
||||
log.error(`Cannot create data folder ${dataDir}: ${errorMessage(error)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const plugin: LoadedPlugin = {
|
||||
id,
|
||||
disposers: new Set(),
|
||||
deactivate: null,
|
||||
lifetime: new AbortController(),
|
||||
};
|
||||
plugins.set(id, plugin);
|
||||
try {
|
||||
const mod = loadModule(entryPath);
|
||||
const activate = pickExport(mod, 'activate');
|
||||
if (!activate) {
|
||||
throw new Error(`${parsed.manifest.main} does not export an activate(ctx) function.`);
|
||||
}
|
||||
const returned = await activate(
|
||||
createContext(plugin, dir, dataDir, entry, log) as PluginContext,
|
||||
);
|
||||
plugin.deactivate =
|
||||
typeof returned === 'function'
|
||||
? (returned as () => unknown)
|
||||
: pickExport(mod, 'deactivate');
|
||||
const version = parsed.manifest.version ? ` v${parsed.manifest.version}` : '';
|
||||
log.info(`Loaded ${parsed.manifest.name}${version}.`);
|
||||
} catch (error) {
|
||||
log.error(`Activation failed: ${errorMessage(error)}`);
|
||||
disposePlugin(plugin);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
/** Forwards the client's events to plugin listeners, replacing any previously attached client. */
|
||||
attachMpvClient(client: MpvClientEvents): void {
|
||||
if (attachedClient) {
|
||||
for (const [event, forward] of attachedClient.forwarders) {
|
||||
attachedClient.client.off(event as PluginMpvEventName, forward);
|
||||
}
|
||||
}
|
||||
const forwarders = new Map<string, (payload: unknown) => void>();
|
||||
for (const event of PLUGIN_MPV_EVENT_NAMES) {
|
||||
const forward = (payload: unknown): void => {
|
||||
mpvEvents.emit(event, payload);
|
||||
};
|
||||
client.on(event, forward);
|
||||
forwarders.set(event, forward);
|
||||
}
|
||||
attachedClient = { client, forwarders };
|
||||
},
|
||||
|
||||
/** Loads every enabled plugin once; later calls return the first load. */
|
||||
loadAll(): Promise<void> {
|
||||
loadPromise ??= Promise.all(
|
||||
Object.entries(deps.getPluginConfig())
|
||||
.filter(([, entry]) => entry.enabled)
|
||||
.map(([id, entry]) => loadPlugin(id, entry)),
|
||||
).then(() => undefined);
|
||||
return loadPromise;
|
||||
},
|
||||
|
||||
async unloadAll(): Promise<void> {
|
||||
loadPromise = null;
|
||||
const timeoutMs = deps.deactivateTimeoutMs ?? DEFAULT_DEACTIVATE_TIMEOUT_MS;
|
||||
await Promise.all(
|
||||
[...plugins.values()].map(async (plugin) => {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
await Promise.race([
|
||||
Promise.resolve().then(() => plugin.deactivate?.()),
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new Error(`timed out after ${timeoutMs} ms`)),
|
||||
timeoutMs,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
} catch (error) {
|
||||
deps
|
||||
.createLogger(`plugin:${plugin.id}`)
|
||||
.error(`deactivate failed: ${errorMessage(error)}`);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
disposePlugin(plugin);
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Runs a registered `<plugin id>/<name>` action, waiting for an in-progress load first so a
|
||||
* command sent at startup is not lost. Resolves false when no plugin registered the action.
|
||||
*/
|
||||
async runAction(actionId: string): Promise<boolean> {
|
||||
await loadPromise;
|
||||
const action = actions.get(actionId);
|
||||
if (!action) return false;
|
||||
runGuarded(
|
||||
deps.createLogger(`plugin:${action.pluginId}`),
|
||||
`Action ${actionId}`,
|
||||
action.handler,
|
||||
);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type PluginHost = ReturnType<typeof createPluginHost>;
|
||||
@@ -0,0 +1,63 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { buildPluginSubtitleContext } from './subtitle-context';
|
||||
|
||||
const cues = [
|
||||
{ text: '一', startTime: 1, endTime: 2 },
|
||||
{ text: 'はい', startTime: 3, endTime: 4 },
|
||||
{ text: '二', startTime: 5, endTime: 6 },
|
||||
{ text: '三', startTime: 7, endTime: 8 },
|
||||
{ text: 'はい', startTime: 9, endTime: 10 },
|
||||
{ text: '四', startTime: 11, endTime: 12 },
|
||||
];
|
||||
|
||||
test('returns neighbors of the matching cue, preferring the one nearest the live timing', () => {
|
||||
// Live timing is shifted by a +1.5 s sub-delay, so it sits outside both はい cues.
|
||||
const context = buildPluginSubtitleContext({
|
||||
cues,
|
||||
current: { text: 'はい', startTime: 10.5, endTime: 11.5 },
|
||||
recentLines: [],
|
||||
before: 2,
|
||||
after: 1,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
context.before.map((line) => line.text),
|
||||
['二', '三'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
context.after.map((line) => line.text),
|
||||
['四'],
|
||||
);
|
||||
});
|
||||
|
||||
test('falls back to recently displayed lines when the line is not in the cue list', () => {
|
||||
const context = buildPluginSubtitleContext({
|
||||
cues: [],
|
||||
current: { text: '今', startTime: 20, endTime: 21 },
|
||||
recentLines: [
|
||||
{ text: '前の前', startTime: 16, endTime: 17 },
|
||||
{ text: '前', startTime: 18, endTime: 19 },
|
||||
{ text: '今', startTime: 20, endTime: 21 },
|
||||
],
|
||||
before: 2,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
context.before.map((line) => line.text),
|
||||
['前の前', '前'],
|
||||
);
|
||||
assert.deepEqual(context.after, []);
|
||||
});
|
||||
|
||||
test('returns an empty context when no subtitle is displayed', () => {
|
||||
assert.deepEqual(
|
||||
buildPluginSubtitleContext({
|
||||
cues,
|
||||
current: { text: ' ', startTime: null, endTime: null },
|
||||
recentLines: [],
|
||||
}),
|
||||
{ current: null, before: [], after: [] },
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { PluginSubtitleContext, PluginSubtitleLine } from './types';
|
||||
|
||||
interface CueLike {
|
||||
text: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}
|
||||
|
||||
const MAX_CONTEXT_LINES = 20;
|
||||
|
||||
function normalizeText(text: string): string {
|
||||
return text.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function clampCount(value: number | undefined, fallback: number): number {
|
||||
if (value === undefined || !Number.isFinite(value)) return fallback;
|
||||
return Math.min(MAX_CONTEXT_LINES, Math.max(0, Math.floor(value)));
|
||||
}
|
||||
|
||||
function toLine(cue: CueLike): PluginSubtitleLine {
|
||||
return { text: cue.text.trim(), startTime: cue.startTime, endTime: cue.endTime };
|
||||
}
|
||||
|
||||
/**
|
||||
* Locates the displayed line in the parsed cue list. Text decides, timing breaks ties: mpv's live
|
||||
* timing can be shifted by sub-delay, so the same text nearest the reported start wins.
|
||||
*/
|
||||
function findCurrentCueIndex(cues: readonly CueLike[], current: PluginSubtitleLine): number {
|
||||
const target = normalizeText(current.text);
|
||||
const anchor = current.startTime;
|
||||
let best = -1;
|
||||
let bestDistance = Number.POSITIVE_INFINITY;
|
||||
cues.forEach((cue, index) => {
|
||||
if (normalizeText(cue.text) !== target) return;
|
||||
const distance = anchor === null ? 0 : Math.abs(cue.startTime - anchor);
|
||||
if (distance < bestDistance) {
|
||||
best = index;
|
||||
bestDistance = distance;
|
||||
}
|
||||
});
|
||||
if (best >= 0 || anchor === null) return best;
|
||||
return cues.findIndex((cue) => anchor >= cue.startTime && anchor < cue.endTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the current line plus neighbors. Uses the parsed subtitle file when one is loaded and
|
||||
* falls back to recently displayed lines (no `after` context) for streams without a cue list.
|
||||
*/
|
||||
export function buildPluginSubtitleContext(input: {
|
||||
cues: readonly CueLike[];
|
||||
current: PluginSubtitleLine | null;
|
||||
recentLines: readonly PluginSubtitleLine[];
|
||||
before?: number;
|
||||
after?: number;
|
||||
}): PluginSubtitleContext {
|
||||
const beforeCount = clampCount(input.before, 2);
|
||||
const afterCount = clampCount(input.after, 1);
|
||||
const current = input.current && normalizeText(input.current.text) ? input.current : null;
|
||||
if (!current) return { current: null, before: [], after: [] };
|
||||
|
||||
const cues = input.cues
|
||||
.filter((cue) => normalizeText(cue.text).length > 0)
|
||||
.slice()
|
||||
.sort((a, b) => a.startTime - b.startTime || a.endTime - b.endTime);
|
||||
const index = findCurrentCueIndex(cues, current);
|
||||
if (index >= 0) {
|
||||
return {
|
||||
current,
|
||||
before: cues.slice(Math.max(0, index - beforeCount), index).map(toLine),
|
||||
after: cues.slice(index + 1, index + 1 + afterCount).map(toLine),
|
||||
};
|
||||
}
|
||||
|
||||
const target = normalizeText(current.text);
|
||||
const recent = input.recentLines.filter((line) => normalizeText(line.text).length > 0);
|
||||
// The tracker usually already holds the current line as its newest entry.
|
||||
const history =
|
||||
recent.length > 0 && normalizeText(recent[recent.length - 1]!.text) === target
|
||||
? recent.slice(0, -1)
|
||||
: recent;
|
||||
return {
|
||||
current,
|
||||
before: beforeCount > 0 ? history.slice(-beforeCount) : [],
|
||||
after: [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
|
||||
import type { SessionActionId, SessionActionPayload } from '../../../types/session-bindings';
|
||||
import type { MpvIpcClientEventMap } from '../mpv';
|
||||
|
||||
/** `plugin.json` at the root of a plugin directory. */
|
||||
export interface PluginManifest {
|
||||
/** Display name; defaults to the directory name. */
|
||||
name: string;
|
||||
version: string | null;
|
||||
/** Entry module, relative to the plugin directory. */
|
||||
main: string;
|
||||
}
|
||||
|
||||
/** One `plugins.<id>` entry from config. */
|
||||
export interface PluginConfigEntry {
|
||||
enabled: boolean;
|
||||
settings: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** mpv events plugins may observe. Render metrics are internal and stay hidden. */
|
||||
export type PluginMpvEventMap = Omit<MpvIpcClientEventMap, 'subtitle-metrics-change'>;
|
||||
export type PluginMpvEventName = keyof PluginMpvEventMap;
|
||||
|
||||
export const PLUGIN_MPV_EVENT_NAMES = [
|
||||
'connection-change',
|
||||
'subtitle-change',
|
||||
'subtitle-timing',
|
||||
'time-pos-change',
|
||||
'duration-change',
|
||||
'pause-change',
|
||||
'fullscreen-change',
|
||||
'secondary-subtitle-change',
|
||||
'subtitle-track-change',
|
||||
'secondary-subtitle-track-change',
|
||||
'secondary-subtitle-delay-change',
|
||||
'subtitle-track-list-change',
|
||||
'media-path-change',
|
||||
'media-title-change',
|
||||
'secondary-subtitle-visibility',
|
||||
'client-message',
|
||||
'end-file',
|
||||
] as const satisfies readonly PluginMpvEventName[];
|
||||
|
||||
export interface PluginSubtitleLine {
|
||||
text: string;
|
||||
/** Seconds on the subtitle file's timeline; null when mpv did not report timing. */
|
||||
startTime: number | null;
|
||||
endTime: number | null;
|
||||
}
|
||||
|
||||
export interface PluginSubtitleContext {
|
||||
current: PluginSubtitleLine | null;
|
||||
/** Oldest first. */
|
||||
before: PluginSubtitleLine[];
|
||||
after: PluginSubtitleLine[];
|
||||
}
|
||||
|
||||
export interface PluginPanelContent {
|
||||
title: string;
|
||||
/** Rendered as a safe markdown subset; raw HTML is shown as text. */
|
||||
markdown: string;
|
||||
}
|
||||
|
||||
export interface PluginPanelHandle {
|
||||
/** Aborts when the user closes the panel or another panel replaces it. */
|
||||
readonly signal: AbortSignal;
|
||||
readonly closed: boolean;
|
||||
update: (content: Partial<PluginPanelContent>) => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export interface PluginWindowOptions {
|
||||
/** HTML file to load, relative to the plugin directory. */
|
||||
html: string;
|
||||
/** Preload script, relative to the plugin directory. Runs with context isolation. */
|
||||
preload?: string;
|
||||
/** Window title; Hyprland matches it to float the window above the overlay. */
|
||||
title?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/** What the host needs to show a plugin window; paths are absolute and already validated. */
|
||||
export interface PluginWindowRequest {
|
||||
/** Same key, same window: reopening focuses the existing one. */
|
||||
key: string;
|
||||
htmlPath: string;
|
||||
preloadPath: string | null;
|
||||
title: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface PluginExecOptions {
|
||||
/** Written to the child's stdin, which is then closed. */
|
||||
stdin?: string;
|
||||
cwd?: string;
|
||||
/** Merged over the parent environment. */
|
||||
env?: Record<string, string>;
|
||||
/** Kills the child and rejects after this many milliseconds. */
|
||||
timeoutMs?: number;
|
||||
/** Kills the child and rejects when aborted. */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface PluginExecResult {
|
||||
/** Null when the child was terminated by a signal. */
|
||||
exitCode: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export type PluginNotifyVariant = 'info' | 'warning' | 'error';
|
||||
|
||||
export interface PluginLogger {
|
||||
debug: (message: string, ...meta: unknown[]) => void;
|
||||
info: (message: string, ...meta: unknown[]) => void;
|
||||
warn: (message: string, ...meta: unknown[]) => void;
|
||||
error: (message: string, ...meta: unknown[]) => void;
|
||||
}
|
||||
|
||||
type Disposer = () => void;
|
||||
|
||||
/** The API object passed to a plugin's `activate(ctx)`. */
|
||||
export interface PluginContext<TUnstable = unknown> {
|
||||
readonly id: string;
|
||||
/** Absolute path of the plugin directory. */
|
||||
readonly dir: string;
|
||||
/** `plugins.<id>.settings` from config. */
|
||||
readonly settings: Readonly<Record<string, unknown>>;
|
||||
/**
|
||||
* `<config dir>/plugin-data/<id>/` for caches and state. Kept apart from `dir`, so replacing the
|
||||
* plugin folder keeps its data. Created before `activate`.
|
||||
*/
|
||||
readonly dataDir: string;
|
||||
readonly log: PluginLogger;
|
||||
/** Repeating timer; cleared automatically when the plugin unloads. */
|
||||
every: (intervalMs: number, callback: () => unknown) => Disposer;
|
||||
/** One-shot timer; cleared automatically when the plugin unloads. */
|
||||
after: (delayMs: number, callback: () => unknown) => Disposer;
|
||||
readonly mpv: {
|
||||
readonly connected: boolean;
|
||||
on: <E extends PluginMpvEventName>(
|
||||
event: E,
|
||||
listener: (payload: PluginMpvEventMap[E]) => unknown,
|
||||
) => Disposer;
|
||||
command: (command: (string | number)[]) => void;
|
||||
getProperty: (name: string) => Promise<unknown>;
|
||||
/** Connects to mpv, launching an idle mpv if none is running. Resolves false when that fails. */
|
||||
ensureRunning: () => Promise<boolean>;
|
||||
};
|
||||
readonly subtitles: {
|
||||
current: () => PluginSubtitleLine | null;
|
||||
context: (options?: { before?: number; after?: number }) => PluginSubtitleContext;
|
||||
};
|
||||
readonly actions: {
|
||||
/** Bind with `["__plugin-action:<plugin id>/<name>"]` in `keybindings`. */
|
||||
register: (name: string, handler: () => unknown) => Disposer;
|
||||
/** Runs a built-in session action, the same ones keybindings use (e.g. `openJimaku`). */
|
||||
run: (actionId: SessionActionId, payload?: SessionActionPayload) => Promise<void>;
|
||||
};
|
||||
notify: (message: string, options?: { variant?: PluginNotifyVariant }) => void;
|
||||
readonly ui: {
|
||||
/** Opens the shared plugin panel, replacing any panel that is already open. */
|
||||
panel: (content: Partial<PluginPanelContent> & { markdown: string }) => PluginPanelHandle;
|
||||
/**
|
||||
* Opens a window for this plugin, or focuses it if the same `html` is already open.
|
||||
* The window is destroyed when the plugin unloads.
|
||||
*/
|
||||
openWindow: (options: PluginWindowOptions) => BrowserWindow;
|
||||
};
|
||||
/** Runs a binary without a shell, with the AppImage library path removed from its environment. */
|
||||
exec: (command: string, args: string[], options?: PluginExecOptions) => Promise<PluginExecResult>;
|
||||
/** Raw app internals. No stability guarantee across releases. */
|
||||
readonly unstable: TUnstable;
|
||||
}
|
||||
|
||||
export interface PluginModule {
|
||||
activate?: (ctx: PluginContext) => unknown;
|
||||
deactivate?: () => unknown;
|
||||
default?: { activate?: (ctx: PluginContext) => unknown; deactivate?: () => unknown };
|
||||
}
|
||||
@@ -55,6 +55,10 @@ function createDeps(overrides: Partial<SessionActionExecutorDeps> = {}) {
|
||||
playNextSubtitle: () => calls.push('play-next'),
|
||||
cycleRuntimeOption: () => ({ ok: true }),
|
||||
playNextPlaylistItem: () => calls.push('playlist-next'),
|
||||
runPluginAction: async (pluginActionId) => {
|
||||
calls.push(`plugin:${pluginActionId}`);
|
||||
return pluginActionId === 'ai-explain/explain';
|
||||
},
|
||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||
...overrides,
|
||||
};
|
||||
@@ -81,3 +85,22 @@ test('dispatchSessionAction does not advance playlist when mark watched no-ops',
|
||||
|
||||
assert.deepEqual(calls, ['mark-watched']);
|
||||
});
|
||||
|
||||
test('dispatchSessionAction runs plugin actions and reports unregistered ones', async () => {
|
||||
const { calls, deps } = createDeps();
|
||||
|
||||
await dispatchSessionAction(
|
||||
{ actionId: 'runPluginAction', payload: { pluginActionId: 'ai-explain/explain' } },
|
||||
deps,
|
||||
);
|
||||
await dispatchSessionAction(
|
||||
{ actionId: 'runPluginAction', payload: { pluginActionId: 'ai-explain/missing' } },
|
||||
deps,
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'plugin:ai-explain/explain',
|
||||
'plugin:ai-explain/missing',
|
||||
'osd:Plugin action not available: ai-explain/missing',
|
||||
]);
|
||||
});
|
||||
@@ -32,6 +32,8 @@ export interface SessionActionExecutorDeps {
|
||||
playNextSubtitle: () => void;
|
||||
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
|
||||
playNextPlaylistItem: () => void;
|
||||
/** Returns false when no loaded plugin registered the action. */
|
||||
runPluginAction: (pluginActionId: string) => Promise<boolean>;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}
|
||||
|
||||
@@ -151,6 +153,13 @@ export async function dispatchSessionAction(
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'runPluginAction': {
|
||||
const pluginActionId = request.payload?.pluginActionId;
|
||||
if (!pluginActionId || !(await deps.runPluginAction(pluginActionId))) {
|
||||
deps.showMpvOsd(`Plugin action not available: ${pluginActionId ?? 'unknown'}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
default:
|
||||
return assertUnreachableSessionAction(request.actionId);
|
||||
}
|
||||
|
||||
@@ -323,6 +323,31 @@ test('compileSessionBindings maps the legacy Animetosho command to the TsukiHime
|
||||
);
|
||||
});
|
||||
|
||||
test('compileSessionBindings maps plugin action commands to a validated session action', () => {
|
||||
const result = compileSessionBindings({
|
||||
shortcuts: createShortcuts(),
|
||||
keybindings: [
|
||||
createKeybinding('Ctrl+E', [`${SPECIAL_COMMANDS.PLUGIN_ACTION_PREFIX}ai-explain/explain`]),
|
||||
createKeybinding('Ctrl+R', [`${SPECIAL_COMMANDS.PLUGIN_ACTION_PREFIX}no-action-name`]),
|
||||
],
|
||||
platform: 'linux',
|
||||
});
|
||||
|
||||
const binding = result.bindings[0];
|
||||
assert.equal(result.bindings.length, 1);
|
||||
assert.ok(binding?.actionType === 'session-action');
|
||||
assert.equal(binding.actionId, 'runPluginAction');
|
||||
assert.deepEqual(binding.payload, { pluginActionId: 'ai-explain/explain' });
|
||||
assert.deepEqual(
|
||||
parseSessionActionDispatchRequest({ actionId: binding.actionId, payload: binding.payload }),
|
||||
{ actionId: 'runPluginAction', payload: { pluginActionId: 'ai-explain/explain' } },
|
||||
);
|
||||
assert.deepEqual(
|
||||
result.warnings.map((warning) => `${warning.kind}:${warning.path}`),
|
||||
['unsupported:keybindings[1].command'],
|
||||
);
|
||||
});
|
||||
|
||||
test('compileSessionBindings leaves retired subtitle-delay shift tokens as mpv commands', () => {
|
||||
const result = compileSessionBindings({
|
||||
shortcuts: createShortcuts(),
|
||||
|
||||
@@ -342,6 +342,16 @@ function resolveCommandBinding(
|
||||
if (command.length !== 1) return null;
|
||||
return { actionType: 'session-action', actionId: 'playNextSubtitle' };
|
||||
}
|
||||
if (first.startsWith(SPECIAL_COMMANDS.PLUGIN_ACTION_PREFIX)) {
|
||||
const pluginActionId = first.slice(SPECIAL_COMMANDS.PLUGIN_ACTION_PREFIX.length);
|
||||
const [pluginId, actionName, ...rest] = pluginActionId.split('/');
|
||||
if (command.length !== 1 || !pluginId || !actionName || rest.length > 0) return null;
|
||||
return {
|
||||
actionType: 'session-action',
|
||||
actionId: 'runPluginAction',
|
||||
payload: { pluginActionId },
|
||||
};
|
||||
}
|
||||
if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) {
|
||||
if (command.length !== 1) {
|
||||
return null;
|
||||
|
||||
@@ -117,6 +117,8 @@ export interface AppReadyRuntimeDeps {
|
||||
loadSubtitlePosition: () => void;
|
||||
resolveKeybindings: () => void;
|
||||
createMpvClient: () => void;
|
||||
/** Loads enabled plugins; full startup only. */
|
||||
startPlugins?: () => void;
|
||||
reloadConfig: () => void;
|
||||
getResolvedConfig: () => AppReadyConfigLike;
|
||||
getConfigWarnings: () => ConfigValidationWarning[];
|
||||
@@ -310,6 +312,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
||||
deps.createMpvClient();
|
||||
deps.initRuntimeOptionsManager();
|
||||
deps.setSecondarySubMode(config.secondarySub?.defaultMode ?? deps.defaultSecondarySubMode);
|
||||
deps.startPlugins?.();
|
||||
|
||||
const wsConfig = config.websocket || {};
|
||||
const wsEnabled = wsConfig.enabled ?? 'auto';
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* mpv's `sub-start`/`sub-end` and parsed cues carry the subtitle file's own timestamps
|
||||
* (the subtitle clock), but mpv plays each line `sub-delay` seconds later (z/x retiming,
|
||||
* `sub-step`). Playback position, seeks, and media cuts are on the video clock, so any
|
||||
* value crossing between the two converts here.
|
||||
*/
|
||||
|
||||
function finiteDelay(subDelay: number): number {
|
||||
return Number.isFinite(subDelay) ? subDelay : 0;
|
||||
}
|
||||
|
||||
/** Playback position (`time-pos`) on the subtitle clock, comparable with cue times. */
|
||||
export function toSubtitleClock(videoTimeSec: number, subDelay: number): number {
|
||||
return videoTimeSec - finiteDelay(subDelay);
|
||||
}
|
||||
|
||||
/** Subtitle-clock seconds on the video clock, for seeks. */
|
||||
export function toVideoClock(subtitleTimeSec: number, subDelay: number): number {
|
||||
return subtitleTimeSec + finiteDelay(subDelay);
|
||||
}
|
||||
|
||||
/** A subtitle-clock range on the video clock, for mining and media generation. */
|
||||
export function shiftSubtitleRangeToVideoClock<T extends { startTime: number; endTime: number }>(
|
||||
range: T,
|
||||
subDelay: number,
|
||||
): T {
|
||||
const delay = finiteDelay(subDelay);
|
||||
if (delay === 0) {
|
||||
return range;
|
||||
}
|
||||
return { ...range, startTime: range.startTime + delay, endTime: range.endTime + delay };
|
||||
}
|
||||
@@ -26,7 +26,7 @@ test('next subtitle navigation skips generated ASS events and seeks to the next
|
||||
},
|
||||
];
|
||||
|
||||
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.2, 0), [
|
||||
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.2), [
|
||||
'seek',
|
||||
13.08,
|
||||
'absolute+exact',
|
||||
@@ -41,7 +41,7 @@ test('next subtitle navigation treats simultaneous sanitized cues as one line bo
|
||||
{ startTime: 13.02, endTime: 16, text: 'Next English' },
|
||||
];
|
||||
|
||||
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.1, 0), [
|
||||
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.1), [
|
||||
'seek',
|
||||
13.08,
|
||||
'absolute+exact',
|
||||
@@ -55,7 +55,7 @@ test('next subtitle navigation advances past the latest overlapping lyric', () =
|
||||
{ startTime: 16, endTime: 19, text: 'next lyric' },
|
||||
];
|
||||
|
||||
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 13.2, 0), [
|
||||
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 13.2), [
|
||||
'seek',
|
||||
16.08,
|
||||
'absolute+exact',
|
||||
@@ -68,7 +68,7 @@ test('previous subtitle navigation leaves the current cue and seeks to the prior
|
||||
{ startTime: 13, endTime: 16, text: 'current line' },
|
||||
];
|
||||
|
||||
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', -1], cues, 14.5, 0), [
|
||||
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', -1], cues, 14.5), [
|
||||
'seek',
|
||||
10.08,
|
||||
'absolute+exact',
|
||||
@@ -78,22 +78,8 @@ test('previous subtitle navigation leaves the current cue and seeks to the prior
|
||||
test('subtitle navigation falls back when no sanitized destination exists', () => {
|
||||
const cues = [{ startTime: 10, endTime: 13, text: 'only line' }];
|
||||
|
||||
assert.equal(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.2, 0), null);
|
||||
assert.equal(resolveSanitizedSubtitleSeekCommand(['seek', 5], cues, 10.2, 0), null);
|
||||
});
|
||||
|
||||
test('subtitle cue navigation finds and seeks cues on the sub-delay retimed timeline', () => {
|
||||
const cues = [
|
||||
{ startTime: 10, endTime: 13, text: 'one' },
|
||||
{ startTime: 13, endTime: 16, text: 'two' },
|
||||
];
|
||||
|
||||
// At 14.5 with a 2s delay, mpv is showing "one" (12-15 on the video clock).
|
||||
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 14.5, 2), [
|
||||
'seek',
|
||||
15.08,
|
||||
'absolute+exact',
|
||||
]);
|
||||
assert.equal(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.2), null);
|
||||
assert.equal(resolveSanitizedSubtitleSeekCommand(['seek', 5], cues, 10.2), null);
|
||||
});
|
||||
|
||||
test('sidebar cue seeks share the boundary-safe sanitized cue timestamp', () => {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { toSubtitleClock, toVideoClock } from './subtitle-clock';
|
||||
import type { SubtitleCue } from './subtitle-cue-parser';
|
||||
|
||||
const CUE_START_GROUP_TOLERANCE_SECONDS = 0.05;
|
||||
@@ -81,10 +80,8 @@ export function subtitleCueListSeekTime(
|
||||
export function resolveSanitizedSubtitleSeekCommand(
|
||||
command: readonly (string | number)[],
|
||||
cues: readonly SubtitleCue[],
|
||||
videoTimeSec: number,
|
||||
subDelay: number,
|
||||
currentTimeSec: number,
|
||||
): (string | number)[] | null {
|
||||
const currentTimeSec = toSubtitleClock(videoTimeSec, subDelay);
|
||||
if (
|
||||
command.length < 2 ||
|
||||
command[0] !== 'sub-seek' ||
|
||||
@@ -127,5 +124,5 @@ export function resolveSanitizedSubtitleSeekCommand(
|
||||
if (!destination) {
|
||||
return null;
|
||||
}
|
||||
return ['seek', toVideoClock(subtitleCueSeekTime(destination.cue), subDelay), 'absolute+exact'];
|
||||
return ['seek', subtitleCueSeekTime(destination.cue), 'absolute+exact'];
|
||||
}
|
||||
@@ -1,22 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
buildNotifySendEnv,
|
||||
createNotifySendReplacer,
|
||||
resolveDefaultNotificationIconPath,
|
||||
} from './notification';
|
||||
|
||||
test('notify-send child environment drops the AppImage library-path override', () => {
|
||||
const env = buildNotifySendEnv({
|
||||
LD_LIBRARY_PATH: '/tmp/.mount_SubMinXXXXXX/usr/lib',
|
||||
DBUS_SESSION_BUS_ADDRESS: 'unix:path=/run/user/1000/bus',
|
||||
HOME: '/home/user',
|
||||
});
|
||||
|
||||
assert.equal(env.LD_LIBRARY_PATH, undefined);
|
||||
assert.equal(env.DBUS_SESSION_BUS_ADDRESS, 'unix:path=/run/user/1000/bus');
|
||||
assert.equal(env.HOME, '/home/user');
|
||||
});
|
||||
import { createNotifySendReplacer, resolveDefaultNotificationIconPath } from './notification';
|
||||
|
||||
test('default notification icon resolves packaged SubMiner asset when no per-notification icon is provided', () => {
|
||||
const path = resolveDefaultNotificationIconPath({
|
||||
|
||||
@@ -3,6 +3,7 @@ import electron from 'electron';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { createLogger } from '../../logger';
|
||||
import { buildSystemBinaryEnv } from './system-binary-env';
|
||||
|
||||
const { Notification, nativeImage } = electron;
|
||||
const logger = createLogger('core:notification');
|
||||
@@ -203,19 +204,8 @@ export function createNotifySendReplacer(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Electron AppImages export `LD_LIBRARY_PATH=<mount>/usr/lib`, whose bundled libnotify predates the
|
||||
* symbols the system notify-send links against, so an inherited environment kills the child with a
|
||||
* symbol lookup error before it can send anything. A system binary resolves its own libraries fine,
|
||||
* so the override is dropped entirely rather than filtered.
|
||||
*/
|
||||
export function buildNotifySendEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
||||
const { LD_LIBRARY_PATH: _dropped, ...rest } = env;
|
||||
return rest;
|
||||
}
|
||||
|
||||
const showLinuxReplaceableNotification = createNotifySendReplacer((args, callback) =>
|
||||
execFile('notify-send', args, { timeout: 5_000, env: buildNotifySendEnv() }, (error, stdout) =>
|
||||
execFile('notify-send', args, { timeout: 5_000, env: buildSystemBinaryEnv() }, (error, stdout) =>
|
||||
callback(error, stdout ?? ''),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { buildSystemBinaryEnv } from './system-binary-env';
|
||||
|
||||
test('system binary environment drops the AppImage library-path override', () => {
|
||||
const env = buildSystemBinaryEnv({
|
||||
LD_LIBRARY_PATH: '/tmp/.mount_SubMinXXXXXX/usr/lib',
|
||||
DBUS_SESSION_BUS_ADDRESS: 'unix:path=/run/user/1000/bus',
|
||||
HOME: '/home/user',
|
||||
});
|
||||
|
||||
assert.equal(env.LD_LIBRARY_PATH, undefined);
|
||||
assert.equal(env.DBUS_SESSION_BUS_ADDRESS, 'unix:path=/run/user/1000/bus');
|
||||
assert.equal(env.HOME, '/home/user');
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Electron AppImages export `LD_LIBRARY_PATH=<mount>/usr/lib`, whose bundled libraries (libnotify and
|
||||
* friends) predate the symbols system binaries link against, so an inherited environment can kill a
|
||||
* spawned child with a symbol lookup error. A system binary resolves its own libraries fine, so the
|
||||
* override is dropped entirely rather than filtered.
|
||||
*/
|
||||
export function buildSystemBinaryEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
||||
const { LD_LIBRARY_PATH: _dropped, ...rest } = env;
|
||||
return rest;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
buildWindowsShimInvocation,
|
||||
isWindowsCommandShim,
|
||||
resolveWindowsCommand,
|
||||
} from './windows-command-shim';
|
||||
|
||||
test('resolves a bare name through PATH and PATHEXT in order', () => {
|
||||
const npmDir = path.join('/fake', 'npm');
|
||||
const binDir = path.join('/fake', 'bin');
|
||||
const existing = new Set([path.join(npmDir, 'codex.CMD'), path.join(binDir, 'codex.EXE')]);
|
||||
const env = { PATH: [npmDir, binDir].join(path.delimiter), PATHEXT: '.EXE;.CMD' };
|
||||
|
||||
assert.equal(
|
||||
resolveWindowsCommand('codex', env, (candidate) => existing.has(candidate)),
|
||||
path.join(npmDir, 'codex.CMD'),
|
||||
);
|
||||
assert.equal(
|
||||
resolveWindowsCommand('codex.exe', env, () => true),
|
||||
'codex.exe',
|
||||
'commands with an extension are left alone',
|
||||
);
|
||||
assert.equal(
|
||||
resolveWindowsCommand('missing', env, () => false),
|
||||
'missing',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolves an extensionless path in its own directory', () => {
|
||||
const shim = path.join('/fake', 'npm', 'codex');
|
||||
assert.equal(
|
||||
resolveWindowsCommand(shim, { PATHEXT: '.CMD' }, (candidate) => candidate === `${shim}.CMD`),
|
||||
`${shim}.CMD`,
|
||||
);
|
||||
});
|
||||
|
||||
test('recognizes batch shims only', () => {
|
||||
assert.equal(isWindowsCommandShim('C:\\npm\\codex.cmd'), true);
|
||||
assert.equal(isWindowsCommandShim('run.BAT'), true);
|
||||
assert.equal(isWindowsCommandShim('codex.exe'), false);
|
||||
});
|
||||
|
||||
test('quotes the shim call for cmd.exe', () => {
|
||||
const invocation = buildWindowsShimInvocation(
|
||||
'C:\\npm\\codex.cmd',
|
||||
['-C', 'C:\\Temp dir\\a&b', '-c', 'effort="low"'],
|
||||
{ ComSpec: 'C:\\Windows\\system32\\cmd.exe' },
|
||||
);
|
||||
|
||||
assert.deepEqual(invocation, {
|
||||
file: 'C:\\Windows\\system32\\cmd.exe',
|
||||
args: [
|
||||
'/d',
|
||||
'/s',
|
||||
'/v:off',
|
||||
'/c',
|
||||
'"^"C:\\npm\\codex.cmd^" ^"-C^" ^"C:\\Temp^ dir\\a^&b^" ^"-c^" ^"effort=\\^"low\\^"^""',
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
/** True for batch shims (`.cmd`/`.bat`), which Windows only runs through cmd.exe. */
|
||||
export function isWindowsCommandShim(command: string): boolean {
|
||||
return /\.(cmd|bat)$/i.test(command);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an extensionless Windows command the way cmd.exe does: each `PATHEXT` extension in
|
||||
* the command's own directory, or in each `PATH` entry for a bare name. Returns `command`
|
||||
* unchanged when it already has an extension or nothing matches.
|
||||
*/
|
||||
export function resolveWindowsCommand(
|
||||
command: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
existsSync: (candidate: string) => boolean = fs.existsSync,
|
||||
): string {
|
||||
if (path.extname(command)) return command;
|
||||
const directory = path.dirname(command);
|
||||
const directories =
|
||||
directory === '.' ? (env.PATH ?? env.Path ?? '').split(path.delimiter).filter(Boolean) : [''];
|
||||
const extensions = (env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean);
|
||||
for (const entry of directories) {
|
||||
for (const extension of extensions) {
|
||||
const candidate = entry ? path.join(entry, command + extension) : command + extension;
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
return command;
|
||||
}
|
||||
|
||||
/*!
|
||||
* Windows command escaping adapted from cross-spawn 7.0.6.
|
||||
*
|
||||
* The MIT License (MIT)
|
||||
*
|
||||
* Copyright (c) 2018 Made With MOXY Lda <hello@moxy.studio>
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*/
|
||||
const WINDOWS_SHELL_META_CHARACTERS = /([()\][%!^"`<>&|;, *?])/g;
|
||||
|
||||
// Quote for both cmd.exe and the Windows argv parser. The outer caret escapes
|
||||
// are consumed by cmd, leaving the quoted argument unchanged for the command.
|
||||
function escapeWindowsShellArgument(value: string): string {
|
||||
const quotesEscaped = value
|
||||
.replace(/(?=(\\+?)?)\1"/g, '$1$1\\"')
|
||||
.replace(/(?=(\\+?)?)\1$/, '$1$1');
|
||||
return `"${quotesEscaped}"`.replace(WINDOWS_SHELL_META_CHARACTERS, '^$1');
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the cmd.exe invocation that runs a batch shim with `args`. Spawn the result with
|
||||
* `windowsVerbatimArguments: true` so Node does not re-quote the prepared command line.
|
||||
*/
|
||||
export function buildWindowsShimInvocation(
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
env: NodeJS.ProcessEnv,
|
||||
): { file: string; args: string[] } {
|
||||
const commandLine = [command, ...args].map(escapeWindowsShellArgument).join(' ');
|
||||
return {
|
||||
file: env.ComSpec ?? env.COMSPEC ?? process.env.ComSpec ?? 'cmd.exe',
|
||||
args: ['/d', '/s', '/v:off', '/c', `"${commandLine}"`],
|
||||
};
|
||||
}
|
||||
+115
-31
@@ -328,11 +328,9 @@ import {
|
||||
setMpvSubVisibilityRuntime,
|
||||
setOverlayDebugVisualizationEnabledRuntime,
|
||||
setVisibleOverlayVisible as setVisibleOverlayVisibleCore,
|
||||
shiftSubtitleRangeToVideoClock,
|
||||
showMpvOsdRuntime,
|
||||
JellyfinPlaybackReporter,
|
||||
tokenizeSubtitle as tokenizeSubtitleCore,
|
||||
toSubtitleClock,
|
||||
triggerFieldGrouping as triggerFieldGroupingCore,
|
||||
upsertYomitanDictionarySettings,
|
||||
updateLastCardFromClipboard as updateLastCardFromClipboardCore,
|
||||
@@ -514,6 +512,14 @@ import { openSubsyncManualModal as openSubsyncManualModalRuntime } from './main/
|
||||
import { openSessionHelpModal as openSessionHelpModalRuntime } from './main/runtime/session-help-open';
|
||||
import { openChangelogModal as openChangelogModalRuntime } from './main/runtime/changelog-open';
|
||||
import { createChangelogRuntime } from './main/runtime/changelog/changelog-runtime';
|
||||
import { createPluginHost } from './core/services/plugins/plugin-host';
|
||||
import { buildPluginSubtitleContext } from './core/services/plugins/subtitle-context';
|
||||
import type { PluginSubtitleLine } from './core/services/plugins/types';
|
||||
import {
|
||||
createPluginPanelRuntime,
|
||||
openPluginPanelModal,
|
||||
} from './main/runtime/plugin-panel-runtime';
|
||||
import { createPluginWindowRuntime } from './main/runtime/plugin-window-runtime';
|
||||
import { openCharacterDictionaryManagerModal as openCharacterDictionaryManagerModalRuntime } from './main/runtime/character-dictionary-open';
|
||||
import { openControllerSelectModal as openControllerSelectModalRuntime } from './main/runtime/controller-select-open';
|
||||
import { openControllerDebugModal as openControllerDebugModalRuntime } from './main/runtime/controller-debug-open';
|
||||
@@ -1939,10 +1945,7 @@ let tokenizeSubtitleDeferred: ((text: string) => Promise<SubtitleData>) | null =
|
||||
function withCurrentSubtitleTiming(payload: SubtitleData): SubtitleData {
|
||||
const canonical = resolveCanonicalPrimarySubtitle({
|
||||
liveText: payload.text,
|
||||
currentTimeSec: toSubtitleClock(
|
||||
Number(appState.mpvClient?.currentTimePos),
|
||||
appState.mpvClient?.currentSubDelay ?? 0,
|
||||
),
|
||||
currentTimeSec: Number(appState.mpvClient?.currentTimePos),
|
||||
cues: appState.activeParsedSubtitleCues,
|
||||
});
|
||||
return {
|
||||
@@ -1956,11 +1959,9 @@ function captureCurrentPrimarySubtitleMiningContext(): SubtitleMiningContext | n
|
||||
// Mine what the overlay shows, not raw mpv `sub-text`: the raw text lists every active
|
||||
// event, so a finished caption row lingering beside a fresh line would end up on the
|
||||
// card. The parsed view also carries the cue's own timings for the clip range.
|
||||
// Cues sit on the subtitle clock, so the lookup undoes `sub-delay` first.
|
||||
const subDelay = appState.mpvClient?.currentSubDelay ?? 0;
|
||||
const resolved = resolvePrimarySubtitle({
|
||||
liveText: appState.mpvClient?.currentSubText ?? '',
|
||||
currentTimeSec: toSubtitleClock(Number(appState.mpvClient?.currentTimePos), subDelay),
|
||||
currentTimeSec: Number(appState.mpvClient?.currentTimePos),
|
||||
cues: appState.activeParsedSubtitleCues,
|
||||
});
|
||||
// Same validity bar as the live capture path: an unusable resolved span must fall
|
||||
@@ -1975,16 +1976,13 @@ function captureCurrentPrimarySubtitleMiningContext(): SubtitleMiningContext | n
|
||||
) {
|
||||
return captureLiveSubtitleMiningContext(appState.mpvClient);
|
||||
}
|
||||
return shiftSubtitleRangeToVideoClock<SubtitleMiningContext>(
|
||||
{
|
||||
return {
|
||||
source: 'overlay',
|
||||
text: resolvedText,
|
||||
startTime: resolved.startTime,
|
||||
endTime: resolved.endTime,
|
||||
capturedAtMs: Date.now(),
|
||||
},
|
||||
subDelay,
|
||||
);
|
||||
};
|
||||
}
|
||||
function emitSubtitlePayload(payload: SubtitleData, options?: { resumePrefetch?: boolean }): void {
|
||||
const timedPayload = withCurrentSubtitleTiming(payload);
|
||||
@@ -2126,18 +2124,13 @@ function getSubtitleSidebarPlaybackState(): SubtitleSidebarPlaybackState {
|
||||
subtitleSidebarCuesRef = appState.activeParsedSubtitleCues;
|
||||
subtitleSidebarCuesRevision += 1;
|
||||
}
|
||||
const subDelay = appState.mpvClient?.currentSubDelay ?? 0;
|
||||
return {
|
||||
sourceKey: JSON.stringify([
|
||||
appState.activeParsedSubtitleMediaPath,
|
||||
appState.activeParsedSubtitleSource,
|
||||
]),
|
||||
cuesRevision: subtitleSidebarCuesRevision,
|
||||
currentTimeSec:
|
||||
appState.mpvClient?.currentTimePos === undefined
|
||||
? null
|
||||
: toSubtitleClock(appState.mpvClient.currentTimePos, subDelay),
|
||||
subDelay,
|
||||
currentTimeSec: appState.mpvClient?.currentTimePos ?? null,
|
||||
currentSubtitle: {
|
||||
text: appState.currentSubText,
|
||||
startTime: appState.mpvClient?.currentSubStart ?? null,
|
||||
@@ -2176,7 +2169,6 @@ function sendRendererMpvCommand(rawCommand: (string | number)[]): void {
|
||||
rawCommand,
|
||||
appState.activeParsedSubtitleCues,
|
||||
appState.mpvClient?.currentTimePos ?? Number.NaN,
|
||||
appState.mpvClient?.currentSubDelay ?? 0,
|
||||
) ?? rawCommand;
|
||||
if (isExplicitMpvSeekCommand(command)) {
|
||||
explicitSeekIntentExpiresAtMs = Date.now() + EXPLICIT_SEEK_INTENT_TTL_MS;
|
||||
@@ -3094,6 +3086,87 @@ function createOverlayHostedModalOpenDeps(): {
|
||||
};
|
||||
}
|
||||
|
||||
const pluginPanelRuntime = createPluginPanelRuntime({
|
||||
openModal: (getSnapshot) => openPluginPanelModal(createOverlayHostedModalOpenDeps(), getSnapshot),
|
||||
// The panel may live in either window; broadcastToOverlayWindows only reaches the main one.
|
||||
broadcast: (channel, payload) => {
|
||||
for (const window of [overlayManager.getMainWindow(), overlayManager.getModalWindow()]) {
|
||||
if (window && !window.isDestroyed()) window.webContents.send(channel, payload);
|
||||
}
|
||||
},
|
||||
onOpenFailed: () =>
|
||||
overlayNotificationsRuntime.showConfiguredStatusNotification('Plugin panel unavailable.', {
|
||||
variant: 'warning',
|
||||
}),
|
||||
});
|
||||
|
||||
function getPluginCurrentSubtitle(): PluginSubtitleLine | null {
|
||||
const text = appState.currentSubText.trim();
|
||||
if (!text) return null;
|
||||
// Same view as mining: the parsed line with its cue timings on the subtitle file's timeline.
|
||||
const resolved = resolvePrimarySubtitle({
|
||||
liveText: appState.mpvClient?.currentSubText ?? '',
|
||||
currentTimeSec: Number(appState.mpvClient?.currentTimePos),
|
||||
cues: appState.activeParsedSubtitleCues,
|
||||
});
|
||||
const resolvedText = resolved?.text.replace(/\n{2,}/g, '\n').trim();
|
||||
if (
|
||||
resolved &&
|
||||
resolvedText &&
|
||||
Number.isFinite(resolved.startTime) &&
|
||||
Number.isFinite(resolved.endTime) &&
|
||||
resolved.endTime > resolved.startTime
|
||||
) {
|
||||
return { text: resolvedText, startTime: resolved.startTime, endTime: resolved.endTime };
|
||||
}
|
||||
const client = appState.mpvClient;
|
||||
// The client reports a cleared sub-start as zero, so only trust timing for a live line.
|
||||
const hasTiming = client !== null && client.currentSubEnd > client.currentSubStart;
|
||||
return {
|
||||
text,
|
||||
startTime: hasTiming ? client.currentSubStart : null,
|
||||
endTime: hasTiming ? client.currentSubEnd : null,
|
||||
};
|
||||
}
|
||||
|
||||
const pluginWindowRuntime = createPluginWindowRuntime({
|
||||
createBrowserWindow: (options) => new BrowserWindow(options),
|
||||
promoteAboveOverlay: (window) => promoteSettingsWindowAboveOverlay(window),
|
||||
logError: (message) => logger.error(message),
|
||||
});
|
||||
|
||||
const pluginHost = createPluginHost({
|
||||
pluginsDir: path.join(CONFIG_DIR, 'plugins'),
|
||||
pluginDataDir: path.join(USER_DATA_PATH, 'plugin-data'),
|
||||
getPluginConfig: () => configService.getConfig().plugins,
|
||||
createLogger,
|
||||
isMpvConnected: () => appState.mpvClient?.connected === true,
|
||||
sendMpvCommand: (command) => sendMpvCommandRuntime(appState.mpvClient, command),
|
||||
requestMpvProperty: async (name) => {
|
||||
if (!appState.mpvClient) throw new Error('mpv is not connected.');
|
||||
return await appState.mpvClient.requestProperty(name);
|
||||
},
|
||||
ensureMpvRunning: () => ensureMpvConnectedForPlayback(),
|
||||
dispatchSessionAction: (request) => dispatchSessionAction(request),
|
||||
getCurrentSubtitle: () => getPluginCurrentSubtitle(),
|
||||
getSubtitleContext: (options) =>
|
||||
buildPluginSubtitleContext({
|
||||
cues: appState.activeParsedSubtitleCues,
|
||||
current: getPluginCurrentSubtitle(),
|
||||
recentLines: (appState.subtitleTimingTracker?.getRecentEntries(40) ?? []).map((entry) => ({
|
||||
text: entry.displayText,
|
||||
startTime: entry.startTime,
|
||||
endTime: entry.endTime,
|
||||
})),
|
||||
...options,
|
||||
}),
|
||||
notify: (message, variant) =>
|
||||
overlayNotificationsRuntime.showConfiguredStatusNotification(message, { variant }),
|
||||
openPanel: (content) => pluginPanelRuntime.open(content),
|
||||
openWindow: (request) => pluginWindowRuntime.open(request),
|
||||
unstable: { appState, configService, overlayManager },
|
||||
});
|
||||
|
||||
const mediaTimingFrameExtractor = createMediaTimingFrameExtractor();
|
||||
const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
@@ -3131,10 +3204,7 @@ const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({
|
||||
acquireMediaWindow: (source, range) => getSharedRemoteMediaWindowCache().acquire(source, range),
|
||||
getSubtitleContextLines: (range) =>
|
||||
collectMediaTimingContextLines({
|
||||
// Review ranges and tracker entries are on the video clock; parsed cues are not.
|
||||
cues: appState.activeParsedSubtitleCues.map((cue) =>
|
||||
shiftSubtitleRangeToVideoClock(cue, appState.mpvClient?.currentSubDelay ?? 0),
|
||||
),
|
||||
cues: appState.activeParsedSubtitleCues,
|
||||
fallbackPrevious: appState.subtitleTimingTracker?.getRecentEntries(40) ?? [],
|
||||
startTime: range.startTime,
|
||||
endTime: range.endTime,
|
||||
@@ -4333,6 +4403,7 @@ const {
|
||||
void appState.discordPresenceService?.stop();
|
||||
appState.discordPresenceService = null;
|
||||
},
|
||||
unloadPlugins: () => pluginHost.unloadAll(),
|
||||
},
|
||||
shouldRestoreWindowsOnActivateMainDeps: {
|
||||
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
|
||||
@@ -4602,6 +4673,9 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
|
||||
createMpvClient: () => {
|
||||
appState.mpvClient = createMpvClientRuntimeService();
|
||||
},
|
||||
startPlugins: () => {
|
||||
void pluginHost.loadAll();
|
||||
},
|
||||
getResolvedConfig: () => configService.getConfig(),
|
||||
getConfigWarnings: () => configService.getWarnings(),
|
||||
logConfigWarning: (warning) => appLogger.logConfigWarning(warning),
|
||||
@@ -5298,6 +5372,7 @@ function createMpvClientRuntimeService(): MpvIpcClient {
|
||||
client.on('media-path-change', aniSkipRuntime.handleMediaPathChange);
|
||||
client.on('time-pos-change', aniSkipRuntime.handleTimePosChange);
|
||||
client.on('client-message', aniSkipRuntime.handleClientMessage);
|
||||
pluginHost.attachMpvClient(client);
|
||||
return client;
|
||||
}
|
||||
|
||||
@@ -5879,6 +5954,11 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
|
||||
},
|
||||
playNextPlaylistItem: () =>
|
||||
sendMpvCommandRuntime(appState.mpvClient, ['playlist-next', 'force']),
|
||||
runPluginAction: async (pluginActionId) => {
|
||||
const ran = await pluginHost.runAction(pluginActionId);
|
||||
if (!ran) logger.warn(`Plugin action is not registered: ${pluginActionId}`);
|
||||
return ran;
|
||||
},
|
||||
showMpvOsd: (text) => showConfiguredPlaybackFeedback(text),
|
||||
});
|
||||
}
|
||||
@@ -5960,7 +6040,15 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
return false;
|
||||
}
|
||||
},
|
||||
onOverlayModalClosed: (modal, senderWindow) => {
|
||||
onOverlayModalClosed: (modal, senderWindow, instanceId) => {
|
||||
// A late close for a replaced plugin panel must not hide the panel now showing.
|
||||
if (
|
||||
modal === 'plugin-panel' &&
|
||||
instanceId !== undefined &&
|
||||
!pluginPanelRuntime.handleModalClosed(instanceId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (modal === 'media-timing-review') {
|
||||
void mediaTimingReviewRuntime.dispose();
|
||||
}
|
||||
@@ -6047,11 +6135,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
// live mpv sub timings at lookup time so media generation clips the mined line even
|
||||
// when extraction finishes long after playback has moved on.
|
||||
recordSubtitleMiningContext: (context) =>
|
||||
recordSubtitleMiningContext(
|
||||
context
|
||||
? shiftSubtitleRangeToVideoClock(context, appState.mpvClient?.currentSubDelay ?? 0)
|
||||
: captureCurrentPrimarySubtitleMiningContext(),
|
||||
),
|
||||
recordSubtitleMiningContext(context ?? captureCurrentPrimarySubtitleMiningContext()),
|
||||
tokenizeCurrentSubtitle: async () => {
|
||||
const tokenizeSubtitleForCurrent = tokenizeSubtitleDeferred;
|
||||
return resolveCurrentSubtitleForRenderer({
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface AppReadyRuntimeDepsFactoryInput {
|
||||
loadSubtitlePosition: AppReadyRuntimeDeps['loadSubtitlePosition'];
|
||||
resolveKeybindings: AppReadyRuntimeDeps['resolveKeybindings'];
|
||||
createMpvClient: AppReadyRuntimeDeps['createMpvClient'];
|
||||
startPlugins?: AppReadyRuntimeDeps['startPlugins'];
|
||||
reloadConfig: AppReadyRuntimeDeps['reloadConfig'];
|
||||
getResolvedConfig: AppReadyRuntimeDeps['getResolvedConfig'];
|
||||
getConfigWarnings: AppReadyRuntimeDeps['getConfigWarnings'];
|
||||
@@ -94,6 +95,7 @@ export function createAppReadyRuntimeDeps(
|
||||
loadSubtitlePosition: params.loadSubtitlePosition,
|
||||
resolveKeybindings: params.resolveKeybindings,
|
||||
createMpvClient: params.createMpvClient,
|
||||
startPlugins: params.startPlugins,
|
||||
reloadConfig: params.reloadConfig,
|
||||
getResolvedConfig: params.getResolvedConfig,
|
||||
getConfigWarnings: params.getConfigWarnings,
|
||||
|
||||
@@ -303,7 +303,7 @@ test('subtitle sidebar open state is restored for replacement visible overlay wi
|
||||
/onOverlayModalOpened:\s*\(modal,\s*senderWindow\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
|
||||
)?.groups?.body;
|
||||
const closedBlock = source.match(
|
||||
/onOverlayModalClosed:\s*\(modal,\s*senderWindow\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
|
||||
/onOverlayModalClosed:\s*\(modal,\s*senderWindow(?:,\s*instanceId)?\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
|
||||
)?.groups?.body;
|
||||
const depsBlock = source.match(/getSubtitleSidebarOpen:\s*\(\)\s*=>\s*(?<body>[^\n,]+)/)?.groups
|
||||
?.body;
|
||||
|
||||
@@ -55,6 +55,7 @@ function makeCleanupDeps(calls: string[], overrides: Partial<CleanupDeps> = {}):
|
||||
cleanupRemoteMediaWindows: record('cleanupRemoteMediaWindows'),
|
||||
cleanupJellyfinSubtitleCache: record('cleanupJellyfinSubtitleCache'),
|
||||
stopDiscordPresenceService: record('stopDiscordPresenceService'),
|
||||
unloadPlugins: record('unloadPlugins'),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -99,6 +100,31 @@ test('on will quit cleanup handler flushes before teardown and awaits stats befo
|
||||
assert.ok(calls.indexOf('destroyImmersionTracker') < calls.indexOf('destroyAnkiIntegration'));
|
||||
});
|
||||
|
||||
test('on will quit cleanup handler starts plugin unload first and waits for it to settle', async () => {
|
||||
const calls: string[] = [];
|
||||
let finishUnload!: () => void;
|
||||
const cleanup = createOnWillQuitCleanupHandler(
|
||||
makeCleanupDeps(calls, {
|
||||
unloadPlugins: () => {
|
||||
calls.push('unloadPlugins');
|
||||
return new Promise<void>((resolve) => (finishUnload = resolve));
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
let settled = false;
|
||||
const pending = cleanup().then(() => {
|
||||
settled = true;
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(calls[0], 'unloadPlugins');
|
||||
assert.equal(settled, false);
|
||||
|
||||
finishUnload();
|
||||
await pending;
|
||||
assert.equal(settled, true);
|
||||
});
|
||||
|
||||
test('on will quit cleanup handler cleans subtitle caches when stopping remote session fails', async () => {
|
||||
const calls: string[] = [];
|
||||
const cleanup = createOnWillQuitCleanupHandler(
|
||||
|
||||
@@ -59,8 +59,14 @@ export function createOnWillQuitCleanupHandler(deps: {
|
||||
cleanupRemoteMediaWindows: () => void;
|
||||
cleanupJellyfinSubtitleCache: () => void;
|
||||
stopDiscordPresenceService: () => void;
|
||||
unloadPlugins: () => void | Promise<void>;
|
||||
}) {
|
||||
return async (): Promise<void> => {
|
||||
const cleanupErrors: unknown[] = [];
|
||||
// Plugins deactivate first, while the app is still intact; the host bounds how long that takes.
|
||||
const unloadPlugins = (async () => deps.unloadPlugins())().catch((error: unknown) => {
|
||||
cleanupErrors.push(error);
|
||||
});
|
||||
deps.destroyTray();
|
||||
deps.stopConfigHotReload();
|
||||
deps.restorePreviousSecondarySubVisibility();
|
||||
@@ -68,7 +74,6 @@ export function createOnWillQuitCleanupHandler(deps: {
|
||||
deps.unregisterAllGlobalShortcuts();
|
||||
deps.stopSubtitleWebsocket();
|
||||
deps.stopTexthookerService();
|
||||
const cleanupErrors: unknown[] = [];
|
||||
const stopSyncAutoScheduler = Promise.resolve(deps.stopSyncAutoScheduler()).catch(
|
||||
(error: unknown) => {
|
||||
cleanupErrors.push(error);
|
||||
@@ -118,6 +123,7 @@ export function createOnWillQuitCleanupHandler(deps: {
|
||||
deps.cleanupRemoteMediaWindows();
|
||||
deps.stopDiscordPresenceService();
|
||||
await stopSyncAutoScheduler;
|
||||
await unloadPlugins;
|
||||
if (cleanupErrors.length > 0) {
|
||||
throw cleanupErrors[0];
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ function buildCleanupDeps(overrides: Partial<BuilderDeps> = {}) {
|
||||
cleanupRemoteMediaWindows: noop,
|
||||
cleanupJellyfinSubtitleCache: noop,
|
||||
stopDiscordPresenceService: noop,
|
||||
unloadPlugins: noop,
|
||||
...overrides,
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
cleanupRemoteMediaWindows: () => void;
|
||||
cleanupJellyfinSubtitleCache: () => void;
|
||||
stopDiscordPresenceService: () => void;
|
||||
unloadPlugins: () => void | Promise<void>;
|
||||
}) {
|
||||
return () => ({
|
||||
destroyTray: () => deps.destroyTray(),
|
||||
@@ -154,5 +155,6 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
cleanupRemoteMediaWindows: () => deps.cleanupRemoteMediaWindows(),
|
||||
cleanupJellyfinSubtitleCache: () => deps.cleanupJellyfinSubtitleCache(),
|
||||
stopDiscordPresenceService: () => deps.stopDiscordPresenceService(),
|
||||
unloadPlugins: () => deps.unloadPlugins(),
|
||||
});
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export function createBuildAppReadyRuntimeMainDepsHandler(deps: AppReadyRuntimeD
|
||||
loadSubtitlePosition: deps.loadSubtitlePosition,
|
||||
resolveKeybindings: deps.resolveKeybindings,
|
||||
createMpvClient: deps.createMpvClient,
|
||||
startPlugins: deps.startPlugins,
|
||||
reloadConfig: deps.reloadConfig,
|
||||
getResolvedConfig: deps.getResolvedConfig,
|
||||
getConfigWarnings: deps.getConfigWarnings,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { toSubtitleClock } from '../../core/services/subtitle-clock';
|
||||
import type { SubtitleCue, SubtitleData } from '../../types';
|
||||
import { selectAutoplayStartupCue } from './autoplay-subtitle-primer';
|
||||
import { primeVisibleOverlaySubtitleFromMpv } from './current-subtitle-snapshot';
|
||||
@@ -13,7 +12,6 @@ type AutoplaySubtitlePrimingMpvClient = {
|
||||
requestProperty: (name: string) => Promise<unknown>;
|
||||
currentVideoPath?: string;
|
||||
currentTimePos?: number;
|
||||
currentSubDelay?: number;
|
||||
currentSubText?: string;
|
||||
currentSecondarySubText?: string;
|
||||
setCurrentSecondarySubText?: (text: string) => void;
|
||||
@@ -158,7 +156,7 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
|
||||
const currentTimeSec = Number(client?.currentTimePos ?? deps.getLastObservedTimePos());
|
||||
return resolvePrimarySubtitleText({
|
||||
liveText: text,
|
||||
currentTimeSec: toSubtitleClock(currentTimeSec, client?.currentSubDelay ?? 0),
|
||||
currentTimeSec,
|
||||
cues: deps.getActiveParsedSubtitleCues(),
|
||||
});
|
||||
}
|
||||
@@ -272,10 +270,7 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
|
||||
const currentTimeSeconds = Number(
|
||||
timePosRaw ?? client?.currentTimePos ?? deps.getLastObservedTimePos() ?? 0,
|
||||
);
|
||||
const resolvedTimeSeconds = toSubtitleClock(
|
||||
Number.isFinite(currentTimeSeconds) ? currentTimeSeconds : 0,
|
||||
client?.currentSubDelay ?? 0,
|
||||
);
|
||||
const resolvedTimeSeconds = Number.isFinite(currentTimeSeconds) ? currentTimeSeconds : 0;
|
||||
const cue = selectAutoplayStartupCue(
|
||||
cues,
|
||||
resolvedTimeSeconds,
|
||||
|
||||
@@ -2,6 +2,11 @@ import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
buildWindowsShimInvocation,
|
||||
isWindowsCommandShim,
|
||||
} from '../../core/utils/windows-command-shim';
|
||||
|
||||
export interface RunCommandResult {
|
||||
exitCode: number | null;
|
||||
stdout: string;
|
||||
@@ -142,43 +147,7 @@ export function failureMessage(result: RunCommandResult, fallback: string): stri
|
||||
}
|
||||
|
||||
function needsWindowsShell(command: string): boolean {
|
||||
return process.platform === 'win32' && /\.(cmd|bat)$/i.test(command);
|
||||
}
|
||||
|
||||
/*!
|
||||
* Windows command escaping adapted from cross-spawn 7.0.6.
|
||||
*
|
||||
* The MIT License (MIT)
|
||||
*
|
||||
* Copyright (c) 2018 Made With MOXY Lda <hello@moxy.studio>
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*/
|
||||
const WINDOWS_SHELL_META_CHARACTERS = /([()\][%!^"`<>&|;, *?])/g;
|
||||
|
||||
// Quote for both cmd.exe and the Windows argv parser. The outer caret escapes
|
||||
// are consumed by cmd, leaving the quoted argument unchanged for the command.
|
||||
function escapeWindowsShellArgument(value: string): string {
|
||||
const quotesEscaped = value
|
||||
.replace(/(?=(\\+?)?)\1"/g, '$1$1\\"')
|
||||
.replace(/(?=(\\+?)?)\1$/, '$1$1');
|
||||
return `"${quotesEscaped}"`.replace(WINDOWS_SHELL_META_CHARACTERS, '^$1');
|
||||
return process.platform === 'win32' && isWindowsCommandShim(command);
|
||||
}
|
||||
|
||||
function createDefaultRunCommand(): RunCommand {
|
||||
@@ -189,12 +158,8 @@ function createDefaultRunCommand(): RunCommand {
|
||||
try {
|
||||
const env = options.env ?? process.env;
|
||||
if (useShell) {
|
||||
const shellCommand = [
|
||||
escapeWindowsShellArgument(command),
|
||||
...args.map(escapeWindowsShellArgument),
|
||||
].join(' ');
|
||||
const commandProcessor = env.ComSpec ?? env.COMSPEC ?? process.env.ComSpec ?? 'cmd.exe';
|
||||
child = spawn(commandProcessor, ['/d', '/s', '/v:off', '/c', `"${shellCommand}"`], {
|
||||
const invocation = buildWindowsShimInvocation(command, args, env);
|
||||
child = spawn(invocation.file, invocation.args, {
|
||||
env,
|
||||
windowsHide: false,
|
||||
windowsVerbatimArguments: true,
|
||||
|
||||
@@ -176,21 +176,6 @@ test('subtitle-track transitions ignore stale parsed cues until replacement cues
|
||||
assert.deepEqual(recordedStarts.slice(-1), [20]);
|
||||
});
|
||||
|
||||
test('subtitle timing history records lines on the video clock under sub-delay', () => {
|
||||
const timing: Array<{ text: string; start: number; end: number }> = [];
|
||||
const { handlers } = buildMainDeps({
|
||||
mpvClient: { currentSubDelay: 2 },
|
||||
subtitleTimingTracker: {
|
||||
recordSubtitle: (text: string, start: number, end: number) =>
|
||||
timing.push({ text, start, end }),
|
||||
},
|
||||
});
|
||||
|
||||
handlers.recordSubtitleTiming('食べる', 5, 8);
|
||||
|
||||
assert.deepEqual(timing, [{ text: '食べる', start: 7, end: 10 }]);
|
||||
});
|
||||
|
||||
test('canonical ASS cues replace live glyph spam for display, history, and immersion', () => {
|
||||
const immersion: Array<{ text: string; start: number; end: number }> = [];
|
||||
const timing: Array<{ text: string; start: number; end: number }> = [];
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { toSubtitleClock } from '../../core/services/subtitle-clock';
|
||||
import { createSubtitleLineDedupGate } from '../../core/services/subtitle-line-dedup-gate';
|
||||
import type { MergedToken, SubtitleCue, SubtitleData } from '../../types';
|
||||
import {
|
||||
@@ -24,7 +23,6 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
currentSubText?: string;
|
||||
currentSecondarySubText?: string;
|
||||
currentTimePos?: number;
|
||||
currentSubDelay?: number;
|
||||
requestProperty?: (name: string) => Promise<unknown>;
|
||||
} | null;
|
||||
immersionTracker: {
|
||||
@@ -163,10 +161,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
resolveSubtitleText: (liveText: string) =>
|
||||
resolvePrimarySubtitleText({
|
||||
liveText,
|
||||
currentTimeSec: toSubtitleClock(
|
||||
Number(deps.appState.mpvClient?.currentTimePos),
|
||||
deps.appState.mpvClient?.currentSubDelay ?? 0,
|
||||
),
|
||||
currentTimeSec: Number(deps.appState.mpvClient?.currentTimePos),
|
||||
cues: deps.appState.activeParsedSubtitleCues,
|
||||
}),
|
||||
getCurrentLiveSubtitleText: () => deps.appState.mpvClient?.currentSubText ?? '',
|
||||
@@ -236,9 +231,6 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
hasSubtitleTimingTracker: () => Boolean(deps.appState.subtitleTimingTracker),
|
||||
recordSubtitleTiming: (text: string, start: number, end: number) => {
|
||||
const secondaryText = deps.appState.mpvClient?.currentSecondarySubText || undefined;
|
||||
// The tracker feeds multi-line mining, so it stores video-clock times: the subtitle
|
||||
// timings plus the `sub-delay` in effect while the line was on screen.
|
||||
const subDelay = deps.appState.mpvClient?.currentSubDelay ?? 0;
|
||||
const canonical = resolveCanonicalSample(text, start);
|
||||
if (!canonical) {
|
||||
const recordableText = resolveTextForRecording(text, start);
|
||||
@@ -247,8 +239,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
}
|
||||
deps.appState.subtitleTimingTracker?.recordSubtitle?.(
|
||||
recordableText,
|
||||
start + subDelay,
|
||||
end + subDelay,
|
||||
start,
|
||||
end,
|
||||
secondaryText,
|
||||
);
|
||||
return;
|
||||
@@ -261,8 +253,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
recordedTimingCanonicalKeys.add(key);
|
||||
deps.appState.subtitleTimingTracker?.recordSubtitle?.(
|
||||
cue.text,
|
||||
cue.startTime + subDelay,
|
||||
cue.endTime + subDelay,
|
||||
cue.startTime,
|
||||
cue.endTime,
|
||||
secondaryText,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import type { PluginPanelSnapshot } from '../../types/runtime';
|
||||
import { createPluginPanelRuntime } from './plugin-panel-runtime';
|
||||
|
||||
function createHarness(openResult: Promise<boolean>) {
|
||||
const sent: Array<{ channel: string; payload: unknown }> = [];
|
||||
const opened: PluginPanelSnapshot[] = [];
|
||||
let openFailures = 0;
|
||||
const runtime = createPluginPanelRuntime({
|
||||
openModal: async (getSnapshot) => {
|
||||
opened.push(getSnapshot());
|
||||
return await openResult;
|
||||
},
|
||||
broadcast: (channel, payload) => sent.push({ channel, payload }),
|
||||
onOpenFailed: () => {
|
||||
openFailures += 1;
|
||||
},
|
||||
});
|
||||
return { runtime, sent, opened, failures: () => openFailures };
|
||||
}
|
||||
|
||||
test('re-sends content updated before the renderer acknowledged the open', async () => {
|
||||
let acknowledge!: (opened: boolean) => void;
|
||||
const h = createHarness(new Promise((resolve) => (acknowledge = resolve)));
|
||||
|
||||
const panel = h.runtime.open({ title: 'Explain', markdown: '_Asking..._' });
|
||||
panel.update({ markdown: 'done' });
|
||||
acknowledge(true);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.deepEqual(h.opened, [{ panelId: 1, title: 'Explain', markdown: '_Asking..._' }]);
|
||||
assert.deepEqual(h.sent.at(-1), {
|
||||
channel: IPC_CHANNELS.event.pluginPanelUpdate,
|
||||
payload: { panelId: 1, title: 'Explain', markdown: 'done' },
|
||||
});
|
||||
});
|
||||
|
||||
test('closing from the overlay aborts the handle and ignores later updates', async () => {
|
||||
const h = createHarness(Promise.resolve(true));
|
||||
const panel = h.runtime.open({ title: 'Explain', markdown: '' });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const sentBefore = h.sent.length;
|
||||
|
||||
assert.equal(h.runtime.handleModalClosed(1), true);
|
||||
panel.update({ markdown: 'too late' });
|
||||
|
||||
assert.equal(panel.signal.aborted, true);
|
||||
assert.equal(panel.closed, true);
|
||||
assert.equal(h.sent.length, sentBefore);
|
||||
});
|
||||
|
||||
test('a late close acknowledgement for a replaced panel leaves the new panel open', () => {
|
||||
const h = createHarness(Promise.resolve(true));
|
||||
const first = h.runtime.open({ title: 'One', markdown: '' });
|
||||
first.close();
|
||||
const second = h.runtime.open({ title: 'Two', markdown: '' });
|
||||
|
||||
assert.equal(h.runtime.handleModalClosed(1), false, 'skip teardown of the newer panel');
|
||||
assert.equal(second.signal.aborted, false);
|
||||
});
|
||||
|
||||
test('a close notice after the plugin closed its panel still tears the modal down', () => {
|
||||
const h = createHarness(Promise.resolve(true));
|
||||
h.runtime.open({ title: 'One', markdown: '' }).close();
|
||||
|
||||
assert.equal(h.runtime.handleModalClosed(1), true);
|
||||
});
|
||||
|
||||
test('opening a new panel aborts the previous one', () => {
|
||||
const h = createHarness(Promise.resolve(true));
|
||||
const first = h.runtime.open({ title: 'One', markdown: '' });
|
||||
const second = h.runtime.open({ title: 'Two', markdown: '' });
|
||||
|
||||
assert.equal(first.signal.aborted, true);
|
||||
assert.equal(second.signal.aborted, false);
|
||||
});
|
||||
|
||||
test('plugin close tells the renderer which panel to close', () => {
|
||||
const h = createHarness(Promise.resolve(true));
|
||||
const panel = h.runtime.open({ title: 'One', markdown: '' });
|
||||
|
||||
panel.close();
|
||||
|
||||
assert.equal(panel.signal.aborted, true);
|
||||
assert.deepEqual(h.sent.at(-1), { channel: IPC_CHANNELS.event.pluginPanelClose, payload: 1 });
|
||||
});
|
||||
|
||||
test('a failed open aborts the handle and reports it once', async () => {
|
||||
const h = createHarness(Promise.resolve(false));
|
||||
const panel = h.runtime.open({ title: 'One', markdown: '' });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.equal(panel.signal.aborted, true);
|
||||
assert.equal(h.failures(), 1);
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { PluginPanelContent, PluginPanelHandle } from '../../core/services/plugins/types';
|
||||
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import type { PluginPanelSnapshot } from '../../types/runtime';
|
||||
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
|
||||
|
||||
const PLUGIN_PANEL_MODAL: OverlayHostedModal = 'plugin-panel';
|
||||
const PLUGIN_PANEL_OPEN_TIMEOUT_MS = 1500;
|
||||
|
||||
interface PanelState {
|
||||
panelId: number;
|
||||
content: PluginPanelContent;
|
||||
controller: AbortController;
|
||||
}
|
||||
|
||||
/** Sends the open request, retrying once like the other overlay-hosted modals. */
|
||||
export async function openPluginPanelModal(
|
||||
deps: Parameters<typeof openOverlayHostedModal>[0] & Parameters<typeof retryOverlayModalOpen>[0],
|
||||
getSnapshot: () => PluginPanelSnapshot,
|
||||
): Promise<boolean> {
|
||||
return await retryOverlayModalOpen(deps, {
|
||||
modal: PLUGIN_PANEL_MODAL,
|
||||
timeoutMs: PLUGIN_PANEL_OPEN_TIMEOUT_MS,
|
||||
retryWarning:
|
||||
'Plugin panel did not acknowledge modal open on first attempt; retrying dedicated modal window.',
|
||||
sendOpen: () =>
|
||||
openOverlayHostedModal(deps, {
|
||||
channel: IPC_CHANNELS.event.pluginPanelOpen,
|
||||
modal: PLUGIN_PANEL_MODAL,
|
||||
payload: getSnapshot(),
|
||||
preferModalWindow: true,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the single plugin panel. Opening a panel replaces the previous one, and each handle's
|
||||
* signal aborts when its panel goes away so plugins can stop the work feeding it.
|
||||
*/
|
||||
export function createPluginPanelRuntime(deps: {
|
||||
openModal: (getSnapshot: () => PluginPanelSnapshot) => Promise<boolean>;
|
||||
/** Sends to every overlay window; renderers ignore panel ids they are not showing. */
|
||||
broadcast: (channel: string, payload: unknown) => void;
|
||||
onOpenFailed: () => void;
|
||||
}) {
|
||||
let nextPanelId = 0;
|
||||
let active: PanelState | null = null;
|
||||
|
||||
const snapshot = (state: PanelState): PluginPanelSnapshot => ({
|
||||
panelId: state.panelId,
|
||||
...state.content,
|
||||
});
|
||||
|
||||
const finish = (state: PanelState): void => {
|
||||
if (active === state) active = null;
|
||||
state.controller.abort();
|
||||
};
|
||||
|
||||
return {
|
||||
open(content: PluginPanelContent): PluginPanelHandle {
|
||||
if (active) finish(active);
|
||||
const state: PanelState = {
|
||||
panelId: ++nextPanelId,
|
||||
content: { ...content },
|
||||
controller: new AbortController(),
|
||||
};
|
||||
active = state;
|
||||
|
||||
const handleOpenFailure = (): void => {
|
||||
if (state.controller.signal.aborted) return;
|
||||
finish(state);
|
||||
deps.onOpenFailed();
|
||||
};
|
||||
deps
|
||||
.openModal(() => snapshot(state))
|
||||
.then((opened) => {
|
||||
if (!opened) {
|
||||
handleOpenFailure();
|
||||
return;
|
||||
}
|
||||
// Updates sent before the renderer acknowledged the open were dropped there.
|
||||
if (!state.controller.signal.aborted) {
|
||||
deps.broadcast(IPC_CHANNELS.event.pluginPanelUpdate, snapshot(state));
|
||||
}
|
||||
})
|
||||
.catch(handleOpenFailure);
|
||||
|
||||
return {
|
||||
signal: state.controller.signal,
|
||||
get closed() {
|
||||
return state.controller.signal.aborted;
|
||||
},
|
||||
update: (next) => {
|
||||
if (state.controller.signal.aborted) return;
|
||||
state.content = { ...state.content, ...next };
|
||||
deps.broadcast(IPC_CHANNELS.event.pluginPanelUpdate, snapshot(state));
|
||||
},
|
||||
close: () => {
|
||||
if (state.controller.signal.aborted) return;
|
||||
finish(state);
|
||||
deps.broadcast(IPC_CHANNELS.event.pluginPanelClose, state.panelId);
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* The overlay closed `panelId`. Returns false for a late notice about a panel that a newer one
|
||||
* already replaced, so the caller skips the modal teardown that would hide the newer panel.
|
||||
*/
|
||||
handleModalClosed(panelId: number): boolean {
|
||||
if (!active) return true;
|
||||
if (active.panelId !== panelId) return false;
|
||||
finish(active);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { BrowserWindowConstructorOptions } from 'electron';
|
||||
|
||||
import { createPluginWindowRuntime } from './plugin-window-runtime';
|
||||
|
||||
function createFakeWindow(loadResult: Promise<void>) {
|
||||
const events = new EventEmitter();
|
||||
const calls: string[] = [];
|
||||
const window = {
|
||||
calls,
|
||||
destroyed: false,
|
||||
isDestroyed: () => window.destroyed,
|
||||
show: () => calls.push('show'),
|
||||
focus: () => calls.push('focus'),
|
||||
loadFile: (filePath: string) => {
|
||||
calls.push(`load:${filePath}`);
|
||||
return loadResult;
|
||||
},
|
||||
destroy: () => {
|
||||
window.destroyed = true;
|
||||
events.emit('closed');
|
||||
},
|
||||
once: (event: 'closed', listener: () => void) => events.once(event, listener),
|
||||
};
|
||||
return window;
|
||||
}
|
||||
|
||||
const request = {
|
||||
key: 'viewer:/plugins/viewer/index.html',
|
||||
htmlPath: '/plugins/viewer/index.html',
|
||||
preloadPath: '/plugins/viewer/preload.js',
|
||||
title: 'Viewer',
|
||||
width: 400,
|
||||
height: 300,
|
||||
};
|
||||
|
||||
test('reopening a key focuses the open window and a closed window is recreated', () => {
|
||||
const created: Array<ReturnType<typeof createFakeWindow>> = [];
|
||||
const options: BrowserWindowConstructorOptions[] = [];
|
||||
const promoted: unknown[] = [];
|
||||
const runtime = createPluginWindowRuntime({
|
||||
createBrowserWindow: (windowOptions) => {
|
||||
options.push(windowOptions);
|
||||
const window = createFakeWindow(Promise.resolve());
|
||||
created.push(window);
|
||||
return window;
|
||||
},
|
||||
promoteAboveOverlay: (window) => promoted.push(window),
|
||||
logError: () => {},
|
||||
});
|
||||
|
||||
const first = runtime.open(request);
|
||||
const again = runtime.open(request);
|
||||
assert.equal(first.created, true);
|
||||
assert.equal(again.created, false);
|
||||
assert.equal(again.window, first.window);
|
||||
assert.deepEqual(first.window.calls, [
|
||||
'load:/plugins/viewer/index.html',
|
||||
'show',
|
||||
'focus',
|
||||
'show',
|
||||
'focus',
|
||||
]);
|
||||
assert.equal(promoted.length, 2);
|
||||
assert.equal(options[0]?.webPreferences?.preload, '/plugins/viewer/preload.js');
|
||||
assert.equal(options[0]?.webPreferences?.contextIsolation, true);
|
||||
|
||||
first.window.destroy();
|
||||
const reopened = runtime.open(request);
|
||||
assert.equal(reopened.created, true);
|
||||
assert.equal(created.length, 2);
|
||||
});
|
||||
|
||||
test('a window whose page fails to load is destroyed and logged', async () => {
|
||||
const errors: string[] = [];
|
||||
const window = createFakeWindow(Promise.reject(new Error('ENOENT')));
|
||||
const runtime = createPluginWindowRuntime({
|
||||
createBrowserWindow: () => window,
|
||||
promoteAboveOverlay: () => {},
|
||||
logError: (message) => errors.push(message),
|
||||
});
|
||||
|
||||
runtime.open(request);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.equal(window.destroyed, true);
|
||||
assert.deepEqual(errors, ['Failed to load plugin window /plugins/viewer/index.html: ENOENT']);
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { BrowserWindowConstructorOptions } from 'electron';
|
||||
|
||||
import type { PluginWindowRequest } from '../../core/services/plugins/types';
|
||||
|
||||
interface PluginWindowLike {
|
||||
isDestroyed(): boolean;
|
||||
show(): void;
|
||||
focus(): void;
|
||||
loadFile(filePath: string): Promise<void>;
|
||||
destroy(): void;
|
||||
once(event: 'closed', listener: () => void): unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates plugin windows and keeps one per request key, so a plugin that reopens the same page
|
||||
* focuses the existing window. Windows are raised above the overlay like the settings windows.
|
||||
*/
|
||||
export function createPluginWindowRuntime<TWindow extends PluginWindowLike>(deps: {
|
||||
createBrowserWindow: (options: BrowserWindowConstructorOptions) => TWindow;
|
||||
promoteAboveOverlay: (window: TWindow) => void;
|
||||
logError: (message: string) => void;
|
||||
}) {
|
||||
const windows = new Map<string, TWindow>();
|
||||
|
||||
const showAndFocus = (window: TWindow): void => {
|
||||
window.show();
|
||||
window.focus();
|
||||
deps.promoteAboveOverlay(window);
|
||||
};
|
||||
|
||||
return {
|
||||
open(request: PluginWindowRequest): { window: TWindow; created: boolean } {
|
||||
const existing = windows.get(request.key);
|
||||
if (existing && !existing.isDestroyed()) {
|
||||
showAndFocus(existing);
|
||||
return { window: existing, created: false };
|
||||
}
|
||||
|
||||
const window = deps.createBrowserWindow({
|
||||
width: request.width,
|
||||
height: request.height,
|
||||
title: request.title,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
...(request.preloadPath ? { preload: request.preloadPath } : {}),
|
||||
},
|
||||
});
|
||||
windows.set(request.key, window);
|
||||
window.once('closed', () => {
|
||||
if (windows.get(request.key) === window) windows.delete(request.key);
|
||||
});
|
||||
window.loadFile(request.htmlPath).catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
deps.logError(`Failed to load plugin window ${request.htmlPath}: ${message}`);
|
||||
if (!window.isDestroyed()) window.destroy();
|
||||
});
|
||||
showAndFocus(window);
|
||||
return { window, created: true };
|
||||
},
|
||||
};
|
||||
}
|
||||
+28
-2
@@ -105,6 +105,7 @@ import type {
|
||||
OverlayNotificationEventPayload,
|
||||
OverlayNotificationPosition,
|
||||
ChangelogSnapshot,
|
||||
PluginPanelSnapshot,
|
||||
MediaTimingReviewActionResult,
|
||||
MediaTimingReviewOpenPayload,
|
||||
MediaTimingReviewPreviewPosition,
|
||||
@@ -140,6 +141,20 @@ const onOpenSessionHelpEvent = createQueuedIpcListenerWithPayload<SessionHelpOpe
|
||||
}),
|
||||
);
|
||||
const onOpenChangelogEvent = createQueuedIpcListener(IPC_CHANNELS.event.changelogOpen);
|
||||
function toPluginPanelSnapshot(payload: unknown): PluginPanelSnapshot {
|
||||
const value = (typeof payload === 'object' && payload !== null ? payload : {}) as Partial<
|
||||
Record<keyof PluginPanelSnapshot, unknown>
|
||||
>;
|
||||
return {
|
||||
panelId: typeof value.panelId === 'number' ? value.panelId : -1,
|
||||
title: typeof value.title === 'string' ? value.title : '',
|
||||
markdown: typeof value.markdown === 'string' ? value.markdown : '',
|
||||
};
|
||||
}
|
||||
const onOpenPluginPanelEvent = createQueuedIpcListenerWithPayload(
|
||||
IPC_CHANNELS.event.pluginPanelOpen,
|
||||
toPluginPanelSnapshot,
|
||||
);
|
||||
const onOpenCharacterDictionaryManagerEvent = createQueuedIpcListener(
|
||||
IPC_CHANNELS.event.characterDictionaryManagerOpen,
|
||||
);
|
||||
@@ -415,6 +430,17 @@ const electronAPI: ElectronAPI = {
|
||||
onOpenChangelog: onOpenChangelogEvent,
|
||||
getChangelogSnapshot: (options?: { refresh?: boolean }): Promise<ChangelogSnapshot> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.getChangelogSnapshot, options),
|
||||
onOpenPluginPanel: onOpenPluginPanelEvent,
|
||||
onPluginPanelUpdate: (callback: (snapshot: PluginPanelSnapshot) => void): void => {
|
||||
ipcRenderer.on(IPC_CHANNELS.event.pluginPanelUpdate, (_event, payload: unknown) => {
|
||||
callback(toPluginPanelSnapshot(payload));
|
||||
});
|
||||
},
|
||||
onPluginPanelClose: (callback: (panelId: number) => void): void => {
|
||||
ipcRenderer.on(IPC_CHANNELS.event.pluginPanelClose, (_event, panelId: unknown) => {
|
||||
if (typeof panelId === 'number') callback(panelId);
|
||||
});
|
||||
},
|
||||
onOpenControllerSelect: onOpenControllerSelectEvent,
|
||||
onOpenControllerDebug: onOpenControllerDebugEvent,
|
||||
onOpenJimaku: onOpenJimakuEvent,
|
||||
@@ -506,8 +532,8 @@ const electronAPI: ElectronAPI = {
|
||||
mediaId,
|
||||
direction,
|
||||
),
|
||||
notifyOverlayModalClosed: (modal) => {
|
||||
ipcRenderer.send(IPC_CHANNELS.command.overlayModalClosed, modal);
|
||||
notifyOverlayModalClosed: (modal, instanceId) => {
|
||||
ipcRenderer.send(IPC_CHANNELS.command.overlayModalClosed, modal, instanceId);
|
||||
},
|
||||
notifyOverlayModalOpened: (modal) => {
|
||||
ipcRenderer.send(IPC_CHANNELS.command.overlayModalOpened, modal);
|
||||
|
||||
@@ -595,6 +595,7 @@ function createKeyboardHandlerHarness() {
|
||||
changelogKeydownCount += 1;
|
||||
return true;
|
||||
},
|
||||
handlePluginPanelKeydown: () => false,
|
||||
openSessionHelpModal: () => {},
|
||||
openControllerSelectModal: () => {
|
||||
openControllerSelectCount += 1;
|
||||
|
||||
@@ -32,6 +32,7 @@ export function createKeyboardHandlers(
|
||||
handleControllerDebugKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleSessionHelpKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleChangelogKeydown: (e: KeyboardEvent) => boolean;
|
||||
handlePluginPanelKeydown: (e: KeyboardEvent) => boolean;
|
||||
openSessionHelpModal: (opening: {
|
||||
bindingKey: 'KeyH' | 'KeyK';
|
||||
fallbackUsed: boolean;
|
||||
@@ -1172,6 +1173,11 @@ export function createKeyboardHandlers(
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.state.pluginPanelModalOpen) {
|
||||
options.handlePluginPanelKeydown(e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.state.runtimeOptionsModalOpen) {
|
||||
options.handleRuntimeOptionsKeydown(e);
|
||||
return;
|
||||
|
||||
@@ -1019,6 +1019,15 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="pluginPanelModal" class="modal hidden" aria-hidden="true">
|
||||
<div class="modal-content plugin-panel-content">
|
||||
<div class="modal-header">
|
||||
<div id="pluginPanelTitle" class="modal-title"></div>
|
||||
<button id="pluginPanelClose" class="modal-close" type="button">Close</button>
|
||||
</div>
|
||||
<div id="pluginPanelBody" class="modal-body plugin-panel-body" tabindex="0"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="playlistBrowserModal" class="modal hidden" aria-hidden="true">
|
||||
<div class="modal-content playlist-browser-content">
|
||||
<div class="modal-header">
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { parseInlineMarkdown, parsePluginPanelMarkdown } from './plugin-panel-render';
|
||||
|
||||
test('parses the block structure of a typical grammar explanation', () => {
|
||||
const blocks = parsePluginPanelMarkdown(
|
||||
[
|
||||
'## Translation',
|
||||
'I already told you,',
|
||||
"didn't I?",
|
||||
'',
|
||||
'- **言った** past of 言う',
|
||||
'- `じゃない` seeks agreement',
|
||||
'',
|
||||
'1. first',
|
||||
'2. second',
|
||||
'',
|
||||
'> 言ったじゃない',
|
||||
'---',
|
||||
'```',
|
||||
'<b>kept literal</b>',
|
||||
'```',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
blocks.map((block) => block.kind),
|
||||
['heading', 'paragraph', 'list', 'list', 'quote', 'rule', 'code'],
|
||||
);
|
||||
assert.deepEqual(blocks[1], {
|
||||
kind: 'paragraph',
|
||||
lines: [
|
||||
[{ kind: 'text', value: 'I already told you,' }],
|
||||
[{ kind: 'text', value: "didn't I?" }],
|
||||
],
|
||||
});
|
||||
assert.deepEqual(blocks[2], {
|
||||
kind: 'list',
|
||||
ordered: false,
|
||||
items: [
|
||||
[
|
||||
{ kind: 'strong', value: '言った' },
|
||||
{ kind: 'text', value: ' past of 言う' },
|
||||
],
|
||||
[
|
||||
{ kind: 'code', value: 'じゃない' },
|
||||
{ kind: 'text', value: ' seeks agreement' },
|
||||
],
|
||||
],
|
||||
});
|
||||
assert.deepEqual(blocks[6], { kind: 'code', value: '<b>kept literal</b>' });
|
||||
});
|
||||
|
||||
test('inline parsing keeps HTML and unmatched markers as text', () => {
|
||||
assert.deepEqual(parseInlineMarkdown('<img src=x> and *em* and 2 * 3'), [
|
||||
{ kind: 'text', value: '<img src=x> and ' },
|
||||
{ kind: 'em', value: 'em' },
|
||||
{ kind: 'text', value: ' and 2 * 3' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('underscores inside words stay literal while delimited underscore emphasis still works', () => {
|
||||
assert.deepEqual(parseInlineMarkdown('set ctx_settings_value and _note_ or __bold__'), [
|
||||
{ kind: 'text', value: 'set ctx_settings_value and ' },
|
||||
{ kind: 'em', value: 'note' },
|
||||
{ kind: 'text', value: ' or ' },
|
||||
{ kind: 'strong', value: 'bold' },
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
export type PluginPanelInline =
|
||||
| { kind: 'text'; value: string }
|
||||
| { kind: 'code'; value: string }
|
||||
| { kind: 'strong'; value: string }
|
||||
| { kind: 'em'; value: string };
|
||||
|
||||
export type PluginPanelBlock =
|
||||
| { kind: 'heading'; level: number; inline: PluginPanelInline[] }
|
||||
| { kind: 'paragraph'; lines: PluginPanelInline[][] }
|
||||
| { kind: 'list'; ordered: boolean; items: PluginPanelInline[][] }
|
||||
| { kind: 'quote'; lines: PluginPanelInline[][] }
|
||||
| { kind: 'code'; value: string }
|
||||
| { kind: 'rule' };
|
||||
|
||||
// Underscore emphasis needs non-word characters around it, so snake_case names stay literal.
|
||||
const INLINE_PATTERN =
|
||||
/`([^`]+)`|\*\*([^*]+)\*\*|(?<!\w)__([^_]+)__(?!\w)|\*([^*\s][^*]*)\*|(?<!\w)_([^_\s][^_]*)_(?!\w)/g;
|
||||
|
||||
export function parseInlineMarkdown(text: string): PluginPanelInline[] {
|
||||
const tokens: PluginPanelInline[] = [];
|
||||
let lastIndex = 0;
|
||||
for (const match of text.matchAll(INLINE_PATTERN)) {
|
||||
const index = match.index ?? 0;
|
||||
if (index > lastIndex) tokens.push({ kind: 'text', value: text.slice(lastIndex, index) });
|
||||
if (match[1] !== undefined) tokens.push({ kind: 'code', value: match[1] });
|
||||
else if (match[2] !== undefined || match[3] !== undefined) {
|
||||
tokens.push({ kind: 'strong', value: (match[2] ?? match[3])! });
|
||||
} else tokens.push({ kind: 'em', value: (match[4] ?? match[5])! });
|
||||
lastIndex = index + match[0].length;
|
||||
}
|
||||
if (lastIndex < text.length) tokens.push({ kind: 'text', value: text.slice(lastIndex) });
|
||||
return tokens;
|
||||
}
|
||||
|
||||
const HEADING = /^(#{1,6})\s+(.*)$/;
|
||||
const UNORDERED_ITEM = /^\s*[-*+]\s+(.*)$/;
|
||||
const ORDERED_ITEM = /^\s*\d+[.)]\s+(.*)$/;
|
||||
const QUOTE = /^>\s?(.*)$/;
|
||||
const RULE = /^\s*([-*_])(\s*\1){2,}\s*$/;
|
||||
const FENCE = /^\s*```/;
|
||||
|
||||
/**
|
||||
* Parses the markdown subset model replies lean on: headings, lists, quotes, fenced code, rules,
|
||||
* and paragraphs with code/bold/italic spans. Single newlines inside a paragraph are kept as
|
||||
* line breaks. Anything else, raw HTML included, stays literal text.
|
||||
*/
|
||||
export function parsePluginPanelMarkdown(markdown: string): PluginPanelBlock[] {
|
||||
const blocks: PluginPanelBlock[] = [];
|
||||
const lines = markdown.replace(/\r\n?/g, '\n').split('\n');
|
||||
let index = 0;
|
||||
|
||||
while (index < lines.length) {
|
||||
const line = lines[index]!;
|
||||
if (!line.trim()) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (FENCE.test(line)) {
|
||||
const body: string[] = [];
|
||||
index += 1;
|
||||
while (index < lines.length && !FENCE.test(lines[index]!)) {
|
||||
body.push(lines[index]!);
|
||||
index += 1;
|
||||
}
|
||||
index += 1;
|
||||
blocks.push({ kind: 'code', value: body.join('\n') });
|
||||
continue;
|
||||
}
|
||||
|
||||
const heading = HEADING.exec(line);
|
||||
if (heading) {
|
||||
blocks.push({
|
||||
kind: 'heading',
|
||||
level: heading[1]!.length,
|
||||
inline: parseInlineMarkdown(heading[2]!.trim()),
|
||||
});
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (RULE.test(line)) {
|
||||
blocks.push({ kind: 'rule' });
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const ordered = ORDERED_ITEM.test(line);
|
||||
if (ordered || UNORDERED_ITEM.test(line)) {
|
||||
const pattern = ordered ? ORDERED_ITEM : UNORDERED_ITEM;
|
||||
const items: PluginPanelInline[][] = [];
|
||||
while (index < lines.length) {
|
||||
const item = pattern.exec(lines[index]!);
|
||||
if (!item) break;
|
||||
items.push(parseInlineMarkdown(item[1]!.trim()));
|
||||
index += 1;
|
||||
}
|
||||
blocks.push({ kind: 'list', ordered, items });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (QUOTE.test(line)) {
|
||||
const quoted: PluginPanelInline[][] = [];
|
||||
while (index < lines.length) {
|
||||
const quote = QUOTE.exec(lines[index]!);
|
||||
if (!quote) break;
|
||||
quoted.push(parseInlineMarkdown(quote[1]!));
|
||||
index += 1;
|
||||
}
|
||||
blocks.push({ kind: 'quote', lines: quoted });
|
||||
continue;
|
||||
}
|
||||
|
||||
const paragraph: PluginPanelInline[][] = [];
|
||||
while (index < lines.length) {
|
||||
const next = lines[index]!;
|
||||
if (
|
||||
!next.trim() ||
|
||||
FENCE.test(next) ||
|
||||
HEADING.test(next) ||
|
||||
RULE.test(next) ||
|
||||
UNORDERED_ITEM.test(next) ||
|
||||
ORDERED_ITEM.test(next) ||
|
||||
QUOTE.test(next)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
paragraph.push(parseInlineMarkdown(next.trim()));
|
||||
index += 1;
|
||||
}
|
||||
blocks.push({ kind: 'paragraph', lines: paragraph });
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function appendInline(parent: HTMLElement, tokens: PluginPanelInline[]): void {
|
||||
for (const token of tokens) {
|
||||
if (token.kind === 'text') {
|
||||
parent.append(document.createTextNode(token.value));
|
||||
continue;
|
||||
}
|
||||
const tag = token.kind === 'code' ? 'code' : token.kind;
|
||||
const element = document.createElement(tag);
|
||||
element.textContent = token.value;
|
||||
parent.append(element);
|
||||
}
|
||||
}
|
||||
|
||||
function appendLines(parent: HTMLElement, lines: PluginPanelInline[][]): void {
|
||||
lines.forEach((line, index) => {
|
||||
if (index > 0) parent.append(document.createElement('br'));
|
||||
appendInline(parent, line);
|
||||
});
|
||||
}
|
||||
|
||||
/** Builds DOM nodes from parsed blocks. Only text nodes carry content, so nothing is interpreted. */
|
||||
export function renderPluginPanelMarkdown(container: HTMLElement, markdown: string): void {
|
||||
container.replaceChildren();
|
||||
for (const block of parsePluginPanelMarkdown(markdown)) {
|
||||
switch (block.kind) {
|
||||
case 'heading': {
|
||||
// The panel title is the top heading, so content headings start one step down.
|
||||
const element = document.createElement(`h${Math.min(6, block.level + 2)}`);
|
||||
appendInline(element, block.inline);
|
||||
container.append(element);
|
||||
break;
|
||||
}
|
||||
case 'paragraph': {
|
||||
const element = document.createElement('p');
|
||||
appendLines(element, block.lines);
|
||||
container.append(element);
|
||||
break;
|
||||
}
|
||||
case 'list': {
|
||||
const list = document.createElement(block.ordered ? 'ol' : 'ul');
|
||||
for (const item of block.items) {
|
||||
const element = document.createElement('li');
|
||||
appendInline(element, item);
|
||||
list.append(element);
|
||||
}
|
||||
container.append(list);
|
||||
break;
|
||||
}
|
||||
case 'quote': {
|
||||
const element = document.createElement('blockquote');
|
||||
appendLines(element, block.lines);
|
||||
container.append(element);
|
||||
break;
|
||||
}
|
||||
case 'code': {
|
||||
const pre = document.createElement('pre');
|
||||
const code = document.createElement('code');
|
||||
code.textContent = block.value;
|
||||
pre.append(code);
|
||||
container.append(pre);
|
||||
break;
|
||||
}
|
||||
case 'rule':
|
||||
container.append(document.createElement('hr'));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { PluginPanelSnapshot } from '../../types/runtime';
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
import { createModalFocusGuard } from './modal-focus-guard';
|
||||
import { renderPluginPanelMarkdown } from './plugin-panel-render';
|
||||
|
||||
const SCROLL_STEP_PX = 60;
|
||||
|
||||
/** Read-only panel whose content plugins push from the main process through `ctx.ui.panel`. */
|
||||
export function createPluginPanelModal(
|
||||
ctx: RendererContext,
|
||||
options: {
|
||||
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
||||
syncSettingsModalSubtitleSuppression: () => void;
|
||||
},
|
||||
) {
|
||||
let priorFocus: Element | null = null;
|
||||
let panelId: number | null = null;
|
||||
|
||||
const focus = createModalFocusGuard({
|
||||
isOpen: () => ctx.state.pluginPanelModalOpen,
|
||||
getModalRoot: () => ctx.dom.pluginPanelModal,
|
||||
getPreferredFocusTargets: () => [ctx.dom.pluginPanelBody],
|
||||
getFallbackFocusTarget: () => ctx.dom.pluginPanelClose,
|
||||
isModalLayer: ctx.platform.isModalLayer,
|
||||
});
|
||||
|
||||
function render(snapshot: PluginPanelSnapshot): void {
|
||||
ctx.dom.pluginPanelTitle.textContent = snapshot.title;
|
||||
renderPluginPanelMarkdown(ctx.dom.pluginPanelBody, snapshot.markdown);
|
||||
}
|
||||
|
||||
function openPluginPanelModal(snapshot: PluginPanelSnapshot): void {
|
||||
panelId = snapshot.panelId;
|
||||
render(snapshot);
|
||||
ctx.dom.pluginPanelBody.scrollTop = 0;
|
||||
if (ctx.state.pluginPanelModalOpen) return;
|
||||
priorFocus = document.activeElement;
|
||||
|
||||
ctx.state.pluginPanelModalOpen = true;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.overlay.classList.add('interactive');
|
||||
ctx.dom.pluginPanelModal.classList.remove('hidden');
|
||||
ctx.dom.pluginPanelModal.setAttribute('aria-hidden', 'false');
|
||||
ctx.dom.pluginPanelModal.setAttribute('tabindex', '-1');
|
||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||
window.electronAPI.setIgnoreMouseEvents(false);
|
||||
}
|
||||
|
||||
focus.attach();
|
||||
focus.requestOverlayFocus();
|
||||
window.focus();
|
||||
focus.enforceModalFocus();
|
||||
}
|
||||
|
||||
function updatePluginPanel(snapshot: PluginPanelSnapshot): void {
|
||||
if (!ctx.state.pluginPanelModalOpen || snapshot.panelId !== panelId) return;
|
||||
render(snapshot);
|
||||
}
|
||||
|
||||
function closePluginPanelModal(): void {
|
||||
if (!ctx.state.pluginPanelModalOpen) return;
|
||||
|
||||
const closedPanelId = panelId ?? undefined;
|
||||
ctx.state.pluginPanelModalOpen = false;
|
||||
panelId = null;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.pluginPanelModal.classList.add('hidden');
|
||||
ctx.dom.pluginPanelModal.setAttribute('aria-hidden', 'true');
|
||||
window.electronAPI.notifyOverlayModalClosed('plugin-panel', closedPanelId);
|
||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||
ctx.dom.overlay.classList.remove('interactive');
|
||||
}
|
||||
|
||||
focus.detach();
|
||||
|
||||
if (priorFocus instanceof HTMLElement && priorFocus.isConnected) {
|
||||
priorFocus.focus({ preventScroll: true });
|
||||
} else if (ctx.dom.overlay instanceof HTMLElement) {
|
||||
ctx.dom.overlay.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
|
||||
} else {
|
||||
window.electronAPI.setIgnoreMouseEvents(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The plugin closed its panel; ignore requests for a panel that was already replaced. */
|
||||
function closePluginPanelFromMain(closedPanelId: number): void {
|
||||
if (closedPanelId === panelId) closePluginPanelModal();
|
||||
}
|
||||
|
||||
function handlePluginPanelKeydown(e: KeyboardEvent): boolean {
|
||||
if (!ctx.state.pluginPanelModalOpen) return false;
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closePluginPanelModal();
|
||||
return true;
|
||||
}
|
||||
|
||||
const body = ctx.dom.pluginPanelBody;
|
||||
const key = e.key.toLowerCase();
|
||||
if (key === 'arrowdown' || key === 'j') {
|
||||
e.preventDefault();
|
||||
body.scrollBy({ top: SCROLL_STEP_PX });
|
||||
} else if (key === 'arrowup' || key === 'k') {
|
||||
e.preventDefault();
|
||||
body.scrollBy({ top: -SCROLL_STEP_PX });
|
||||
} else if (key === 'pagedown' || key === ' ') {
|
||||
e.preventDefault();
|
||||
body.scrollBy({ top: body.clientHeight * 0.9 });
|
||||
} else if (key === 'pageup') {
|
||||
e.preventDefault();
|
||||
body.scrollBy({ top: -body.clientHeight * 0.9 });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function wireDomEvents(): void {
|
||||
ctx.dom.pluginPanelClose.addEventListener('click', () => {
|
||||
closePluginPanelModal();
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
closePluginPanelModal,
|
||||
closePluginPanelFromMain,
|
||||
handlePluginPanelKeydown,
|
||||
openPluginPanelModal,
|
||||
updatePluginPanel,
|
||||
wireDomEvents,
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
CompiledSessionActionBinding,
|
||||
CompiledSessionBinding,
|
||||
SessionActionId,
|
||||
SessionActionPayload,
|
||||
SessionKeyModifier,
|
||||
SessionKeySpec,
|
||||
} from '../../types';
|
||||
@@ -189,10 +190,7 @@ function formatSessionKeySpec(key: SessionKeySpec): string {
|
||||
.join(' + ');
|
||||
}
|
||||
|
||||
function describeSessionAction(
|
||||
actionId: SessionActionId,
|
||||
payload?: { runtimeOptionId?: string; direction?: 1 | -1 },
|
||||
): string {
|
||||
function describeSessionAction(actionId: SessionActionId, payload?: SessionActionPayload): string {
|
||||
switch (actionId) {
|
||||
case 'toggleStatsOverlay':
|
||||
return 'Toggle stats overlay';
|
||||
@@ -254,6 +252,8 @@ function describeSessionAction(
|
||||
return `Cycle runtime option ${payload?.runtimeOptionId ?? 'option'} ${
|
||||
payload?.direction === -1 ? 'previous' : 'next'
|
||||
}`;
|
||||
case 'runPluginAction':
|
||||
return `Plugin action ${payload?.pluginActionId ?? ''}`.trim();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,6 +297,8 @@ function sectionForSessionBinding(binding: CompiledSessionBinding): string {
|
||||
return 'Playback and navigation';
|
||||
case 'cycleRuntimeOption':
|
||||
return 'Runtime settings';
|
||||
case 'runPluginAction':
|
||||
return 'Plugins';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ export async function setup() {
|
||||
],
|
||||
currentSubtitle: { text: '最初の台詞', startTime: 0, endTime: 1 },
|
||||
currentTimeSec: 0,
|
||||
subDelay: 0,
|
||||
config: {
|
||||
...SUBTITLE_DEFAULT_CONFIG.subtitleSidebar,
|
||||
enabled: true,
|
||||
@@ -102,6 +101,7 @@ export async function setup() {
|
||||
handleControllerDebugKeydown: () => false,
|
||||
handleSessionHelpKeydown: () => false,
|
||||
handleChangelogKeydown: () => false,
|
||||
handlePluginPanelKeydown: () => false,
|
||||
openSessionHelpModal: () => {},
|
||||
getPlaybackPaused: async () => false,
|
||||
});
|
||||
|
||||
Loaded 100 of 123 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user