Compare commits

..
Author SHA1 Message Date
sudacode 7a526531d7 fix(plugins): await plugin unload on quit with deactivate timeout
- Move plugin unload into the will-quit cleanup handler so it starts first and quit waits for it
- Give each plugin's deactivate 3 s (configurable via deactivateTimeoutMs) before disposing it anyway
- Document the quit timeout in docs-site/plugins.md
2026-10-26 23:22:25 -07:00
sudacode 04c91add22 fix(plugins): skip modal teardown for stale plugin panel closes
handleModalClosed now reports whether teardown should continue. A late
close notice for a replaced panel returns before the generic modal-close
flow, so it no longer hides the modal window showing the newer panel.
2026-10-26 22:57:45 -07:00
sudacode d5fb43caa3 feat(plugins): add plugin windows, CLI actions, and data dirs
- Add `--plugin-action <id>/<name>` CLI flag, dispatched via runPluginAction
- Add ctx.ui.openWindow for plugin-owned BrowserWindows with sandboxed preload
- Add ctx.dataDir (`plugin-data/<id>/`), ctx.actions.run, ctx.mpv.ensureRunning
- Emit mpv end-file events to plugins with reason, entry id, and error
- Run Windows .cmd/.bat shims through cmd.exe in exec, killing the tree on abort
- Update plugin docs, usage, architecture notes, and e2e coverage
2026-10-26 22:40:25 -07:00
sudacode 7b92511f23 fix(plugins): ignore stale plugin panel close notices
The overlay's modal-closed notice now carries the panel id, and the
runtime only finishes the active panel when the ids match. A late notice
for a replaced panel no longer aborts the newer one.
2026-10-26 22:08:35 -07:00
sudacode 0f53b8beca fix(plugins): SIGKILL exec children that ignore SIGTERM
- Escalate timed-out or aborted plugin exec children to SIGKILL after a grace period
- Move ai-explain example to assets/plugins and convert it to CommonJS
- Update plugin docs for the new path and the .js/.mjs module formats
2026-10-26 21:55:51 -07:00
sudacode ca534cd232 feat(plugins): add JavaScript plugin system with overlay panel
- Load plugins from <config dir>/plugins/<id>/ when enabled under `plugins` in config
- Expose ctx with mpv events/commands, timers, subtitle context, actions, notify, exec, and panel
- Bind plugin actions via `__plugin-action:<id>/<action>` keybindings (runPluginAction session action)
- Add plugin-panel modal with markdown rendering; closing aborts the panel signal
- Add ai-explain example plugin that queries `codex exec` for the current line
- Add e2e harness `files` option and plugin e2e scenario; document plugins in docs-site and internal docs
2026-10-26 21:38:51 -07:00
123 changed files with 4546 additions and 1325 deletions

No files matched your search

-23
View File
@@ -1,28 +1,5 @@
# Changelog # 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) ## v0.21.0 (2026-10-24)
### Added ### Added
+5 -1
View File
@@ -102,6 +102,10 @@ A stats dashboard tracks watch time, vocabulary growth, mining throughput, sessi
<td><b>WebSocket</b></td> <td><b>WebSocket</b></td>
<td>Plain subtitle feed plus a dedicated annotated feed for texthooker pages and custom tools</td> <td>Plain subtitle feed plus a dedicated annotated feed for texthooker pages and custom tools</td>
</tr> </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> </table>
<br> <br>
@@ -267,7 +271,7 @@ SubMiner builds on the work of these open-source projects:
| [jellyfin-mpv-shim](https://github.com/jellyfin/jellyfin-mpv-shim) | Jellyfin integration | | [jellyfin-mpv-shim](https://github.com/jellyfin/jellyfin-mpv-shim) | Jellyfin integration |
| [Jimaku.cc](https://jimaku.cc) | Japanese subtitle search and downloads | | [Jimaku.cc](https://jimaku.cc) | Japanese subtitle search and downloads |
| [Renji's Texthooker Page](https://github.com/Renji-XD/texthooker-ui) | Base for the WebSocket texthooker integration | | [Renji's Texthooker Page](https://github.com/Renji-XD/texthooker-ui) | Base for the WebSocket texthooker integration |
| [Yomitan](https://github.com/yomidevs/yomitan) | Default dictionary engine and morphological parser | | [Yomitan](https://github.com/yomidevs/yomitan) | Default dictionary engine and morphological parser |
| [Hachidori](https://github.com/bee-san/hachidori) | Alternative dictionary backend, powered by HoshiDicts | | [Hachidori](https://github.com/bee-san/hachidori) | Alternative dictionary backend, powered by HoshiDicts |
| [yomitan-jlpt-vocab](https://github.com/stephenmk/yomitan-jlpt-vocab) | JLPT level tags for vocabulary | | [yomitan-jlpt-vocab](https://github.com/stephenmk/yomitan-jlpt-vocab) | JLPT level tags for vocabulary |
+134
View File
@@ -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 };
+5
View File
@@ -0,0 +1,5 @@
{
"name": "AI Explain",
"version": "1.0.0",
"main": "main.js"
}
+5
View File
@@ -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.
+4
View File
@@ -0,0 +1,4 @@
type: docs
area: readme
- Refreshed the README's anime screenshots using the current SubMiner build.
+5
View File
@@ -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.
+8
View File
@@ -218,6 +218,14 @@
"overlayPosition": "top-right" // Position for in-overlay notification cards. Values: top-left | top | top-right "overlayPosition": "top-right" // Position for in-overlay notification cards. Values: top-left | top | top-right
}, // Overlay notification display behavior. }, // 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 // Keyboard Shortcuts
// Overlay keyboard shortcuts. Set a shortcut to null to disable. // Overlay keyboard shortcuts. Set a shortcut to null to disable.
+1
View File
@@ -221,6 +221,7 @@ const sidebar: DefaultTheme.SidebarItem[] = [
{ text: 'AniList', link: '/anilist-integration' }, { text: 'AniList', link: '/anilist-integration' },
{ text: 'AniSkip', link: '/aniskip-integration' }, { text: 'AniSkip', link: '/aniskip-integration' },
{ text: 'Character Dictionary', link: '/character-dictionary' }, { text: 'Character Dictionary', link: '/character-dictionary' },
{ text: 'Plugins', link: '/plugins' },
], ],
}, },
{ {
-23
View File
@@ -1,28 +1,5 @@
# Changelog # 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) ## v0.21.0 (2026-10-24)
**Added** **Added**
+10 -1
View File
@@ -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)). 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 ### 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. 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`. - `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. - `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. - 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 ### Shortcuts configuration
+1 -1
View File
@@ -70,7 +70,7 @@ Charts for watch time, cards, words, and sessions per day or month, running tota
### Sessions ### 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.
![Stats Sessions](/screenshots/stats-sessions.png) ![Stats Sessions](/screenshots/stats-sessions.png)
+116
View File
@@ -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`.
+8
View File
@@ -218,6 +218,14 @@
"overlayPosition": "top-right" // Position for in-overlay notification cards. Values: top-left | top | top-right "overlayPosition": "top-right" // Position for in-overlay notification cards. Values: top-left | top | top-right
}, // Overlay notification display behavior. }, // 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 // Keyboard Shortcuts
// Overlay keyboard shortcuts. Set a shortcut to null to disable. // Overlay keyboard shortcuts. Set a shortcut to null to disable.
+2
View File
@@ -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. 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 ## Automatic mpv bindings
+1
View File
@@ -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 generate-subs video.mkv # make Japanese subtitles from the audio
subminer logs -e # export a log ZIP for bug reports subminer logs -e # export a log ZIP for bug reports
subminer app --setup # reopen first-run setup subminer app --setup # reopen first-run setup
subminer app --plugin-action <id>/<name> # run a plugin action
subminer -u # update SubMiner subminer -u # update SubMiner
``` ```
+1
View File
@@ -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. - `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/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/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/renderer/` owns overlay rendering and input behavior.
- `src/config/` owns config definitions, defaults, loading, and resolution. - `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. - `src/types/` owns shared cross-runtime contracts via domain entrypoints; `src/types.ts` stays a compatibility barrel.
+1
View File
@@ -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/` - 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. - 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-*` - 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/` - Window trackers: `src/window-trackers/`
- Stats HTTP app: `src/core/services/stats-server.ts`, with route groups and shared route support - Stats HTTP app: `src/core/services/stats-server.ts`, with route groups and shared route support
in `src/core/services/stats-server/` in `src/core/services/stats-server/`
+2 -1
View File
@@ -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 per-window screenshots), mpv over its JSON IPC socket, and app commands through the
normal CLI handoff (`--mine-sentence`, `--toggle-subtitle-sidebar`, ...). normal CLI handoff (`--mine-sentence`, `--toggle-subtitle-sidebar`, ...).
- Scenarios live in `e2e/*.e2e.test.ts` and share one session per file through - 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 - 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`). `SUBMINER_E2E_KEEP=1` to keep it after any run (`app-stdio.log`, `app.log`, `mpv.log`).
+5 -35
View File
@@ -37,12 +37,10 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value); return typeof value === 'object' && value !== null && !Array.isArray(value);
} }
// A recording AnkiConnect stand-in: one deck and one Lapis-shaped note type. // A recording AnkiConnect stand-in: an empty collection with one deck and one
// Added notes (with later `updateNoteFields` writes) are echoed back by `notesInfo` // Lapis-shaped note type. Scenarios assert on `requests`, not on stored notes.
// so the app can resolve their fields; scenarios still assert on `requests`.
export async function startFakeAnki(): Promise<FakeAnki> { export async function startFakeAnki(): Promise<FakeAnki> {
const requests: AnkiRequest[] = []; const requests: AnkiRequest[] = [];
const addedNotes = new Map<number, Record<string, unknown>>();
let nextNoteId = 1000; let nextNoteId = 1000;
const handle = (body: unknown): unknown => { const handle = (body: unknown): unknown => {
@@ -61,38 +59,10 @@ export async function startFakeAnki(): Promise<FakeAnki> {
return [FAKE_ANKI_MODEL]; return [FAKE_ANKI_MODEL];
case 'modelFieldNames': case 'modelFieldNames':
return MODEL_FIELDS; return MODEL_FIELDS;
case 'addNote': { case 'addNote':
const noteId = nextNoteId++; return 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 'findNotes': case 'findNotes':
case 'notesInfo':
return []; return [];
case 'storeMediaFile': case 'storeMediaFile':
return params.filename ?? null; return params.filename ?? null;
+11
View File
@@ -28,6 +28,8 @@ type JsonValue = string | number | boolean | null | JsonValue[] | JsonObject;
export type E2eSessionOptions = { export type E2eSessionOptions = {
/** Deep-merged over the harness config, for scenarios that need other settings. */ /** Deep-merged over the harness config, for scenarios that need other settings. */
config?: JsonObject; config?: JsonObject;
/** Files written before launch, keyed by path relative to the config dir (e.g. plugins). */
files?: Record<string, string>;
}; };
export type AppTarget = { export type AppTarget = {
@@ -193,6 +195,15 @@ export async function startE2eSession(options: E2eSessionOptions = {}): Promise<
const config = mergeConfig(buildConfig(mpvSocketPath, anki.url), options.config ?? {}); const config = mergeConfig(buildConfig(mpvSocketPath, anki.url), options.config ?? {});
fs.writeFileSync(path.join(userDataDir, 'config.jsonc'), JSON.stringify(config, null, 2)); 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 = { const env: EnvDelta = {
set: { set: {
+4 -87
View File
@@ -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 { function hasToken(tokens: RenderedToken[], surface: string, headword: string): boolean {
return tokens.some((token) => token.surface === surface && token.headword === headword); return tokens.some((token) => token.surface === surface && token.headword === headword);
} }
@@ -134,10 +126,10 @@ test(
session.anki.requests.length = 0; session.anki.requests.length = 0;
await session.runAppCommand('--mine-sentence'); await session.runAppCommand('--mine-sentence');
const update = await waitUntil(() => findMediaUpdate(session), { const update = await waitUntil(
description: 'media fields to be written to the mined note', () => session.anki.requests.find((request) => request.action === 'updateNoteFields'),
timeoutMs: 30_000, { description: 'media fields to be written to the mined note', timeoutMs: 30_000 },
}); );
const added = session.anki.requests.filter((request) => request.action === 'addNote'); const added = session.anki.requests.filter((request) => request.action === 'addNote');
assert.equal(added.length, 1); 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( test(
'open subtitle sidebar lists the parsed cues and follows playback', 'open subtitle sidebar lists the parsed cues and follows playback',
{ timeout: TEST_TIMEOUT_MS }, { 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. // Runs last: it leaves the lookup popup open over the overlay.
test( test(
'holding Shift over a word opens the Yomitan popup with its dictionary entry', 'holding Shift over a word opens the Yomitan popup with its dictionary entry',
+217
View File
@@ -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' },
);
});
+1 -2
View File
@@ -174,9 +174,8 @@ test('runAppCommandCaptureOutput strips ELECTRON_RUN_AS_NODE from app child env'
test( test(
'runAppCommandCaptureOutput transports Linux AppImage args through environment', 'runAppCommandCaptureOutput transports Linux AppImage args through environment',
{ skip: process.platform !== 'linux' },
async () => { async () => {
// Bun's node:test shim ignores the `skip` option, so gate the platform here.
if (process.platform !== 'linux') return;
await withTempCase(({ dir }) => { await withTempCase(({ dir }) => {
const appPath = writeFakeApp( const appPath = writeFakeApp(
dir, dir,
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "subminer", "name": "subminer",
"productName": "SubMiner", "productName": "SubMiner",
"desktopName": "SubMiner.desktop", "desktopName": "SubMiner.desktop",
"version": "0.21.1", "version": "0.21.0",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration", "description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5", "packageManager": "bun@1.3.5",
"main": "dist/main-entry.js", "main": "dist/main-entry.js",
+36 -8
View File
@@ -1,19 +1,47 @@
## Highlights ## Highlights
### Changed ### Added
- **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. - **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 ### 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. - **Media Timing Review**: Opens on the first try and keeps playback paused while open, even when a dictionary popup closes.
- **Subtitle Sidebar**: mpv keys and mouse buttons, such as volume keys and double-click fullscreen, work again while the sidebar is open. - **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 ### 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 ## What's Changed
- fix(overlay): forward mpv input while subtitle sidebar is open by @ksyasuda in #300 - feat(dictionary): add Hachidori backend support by @ksyasuda in #266
- fix(subtitles): follow mpv sub-delay in mining, sidebar, and navigation by @ksyasuda in #302 - fix(jellyfin): make terminal play work with a running app and report progress by @ksyasuda in #288
- feat(stats): redesign expanded session view with density chart by @ksyasuda in #304 - 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 ## Installation
+6 -7
View File
@@ -45,7 +45,6 @@ import {
} from './anki-field-config'; } from './anki-field-config';
import { createLogger } from './logger'; import { createLogger } from './logger';
import { captureLiveSubtitleMiningContext } from './core/services/mining'; import { captureLiveSubtitleMiningContext } from './core/services/mining';
import { shiftSubtitleRangeToVideoClock } from './core/services/subtitle-clock';
import { import {
createUiFeedbackState, createUiFeedbackState,
beginUpdateProgress, beginUpdateProgress,
@@ -1030,10 +1029,10 @@ export class AnkiIntegration {
Number.isFinite(this.mpvClient.currentSubEnd) && Number.isFinite(this.mpvClient.currentSubEnd) &&
this.mpvClient.currentSubEnd > this.mpvClient.currentSubStart this.mpvClient.currentSubEnd > this.mpvClient.currentSubStart
) { ) {
return shiftSubtitleRangeToVideoClock( return {
{ startTime: this.mpvClient.currentSubStart, endTime: this.mpvClient.currentSubEnd }, startTime: this.mpvClient.currentSubStart,
this.mpvClient.currentSubDelay, endTime: this.mpvClient.currentSubEnd,
); };
} }
const currentTime = this.mpvClient.currentTimePos || 0; const currentTime = this.mpvClient.currentTimePos || 0;
@@ -1673,7 +1672,7 @@ export class AnkiIntegration {
if (this.config.fields?.miscInfo) { if (this.config.fields?.miscInfo) {
result.miscInfoValue = this.formatMiscInfoPattern( result.miscInfoValue = this.formatMiscInfoPattern(
audioFilename, audioFilename,
this.mpvClient.currentSubStart + this.mpvClient.currentSubDelay, this.mpvClient.currentSubStart,
); );
} }
} }
@@ -1696,7 +1695,7 @@ export class AnkiIntegration {
if (this.config.fields?.miscInfo && !result.miscInfoValue) { if (this.config.fields?.miscInfo && !result.miscInfoValue) {
result.miscInfoValue = this.formatMiscInfoPattern( result.miscInfoValue = this.formatMiscInfoPattern(
imageFilename, imageFilename,
this.mpvClient.currentSubStart + this.mpvClient.currentSubDelay, this.mpvClient.currentSubStart,
); );
} }
} }
+3 -6
View File
@@ -10,7 +10,6 @@ import {
type MediaTimingReviewRequest, type MediaTimingReviewRequest,
type WordCardKind, type WordCardKind,
} from '../types/anki'; } from '../types/anki';
import { shiftSubtitleRangeToVideoClock } from '../core/services/subtitle-clock';
import { createLogger } from '../logger'; import { createLogger } from '../logger';
import type { MediaInput } from '../media-input'; import type { MediaInput } from '../media-input';
import { SubtitleTimingTracker } from '../subtitle-timing-tracker'; import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
@@ -429,12 +428,10 @@ export class CardCreationService {
return; return;
} }
let { startTime, endTime } = shiftSubtitleRangeToVideoClock( let startTime = mpvClient.currentSubStart;
{ startTime: mpvClient.currentSubStart, endTime: mpvClient.currentSubEnd }, let endTime = mpvClient.currentSubEnd;
mpvClient.currentSubDelay,
);
if (mpvClient.currentSubStart === undefined || mpvClient.currentSubEnd === undefined) { if (startTime === undefined || endTime === undefined) {
const currentTime = mpvClient.currentTimePos || 0; const currentTime = mpvClient.currentTimePos || 0;
const fallback = this.deps.getFallbackDurationSeconds() / 2; const fallback = this.deps.getFallbackDurationSeconds() / 2;
startTime = currentTime - fallback; startTime = currentTime - fallback;
+8
View File
@@ -340,6 +340,14 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
assert.equal(shouldStartApp(sessionAction), true); assert.equal(shouldStartApp(sessionAction), true);
assert.equal(commandNeedsOverlayRuntime(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']); const toggleStatsOverlayRuntime = parseArgs(['--toggle-stats-overlay']);
assert.equal(commandNeedsOverlayRuntime(toggleStatsOverlayRuntime), true); assert.equal(commandNeedsOverlayRuntime(toggleStatsOverlayRuntime), true);
+12
View File
@@ -48,6 +48,8 @@ export interface CliArgs {
cycleRuntimeOptionId?: string; cycleRuntimeOptionId?: string;
cycleRuntimeOptionDirection?: 1 | -1; cycleRuntimeOptionDirection?: 1 | -1;
sessionAction?: SessionActionDispatchRequest; sessionAction?: SessionActionDispatchRequest;
/** `<plugin id>/<action name>` to run, like a `__plugin-action:` keybinding. */
pluginAction?: string;
copySubtitleCount?: number; copySubtitleCount?: number;
mineSentenceCount?: number; mineSentenceCount?: number;
anilistStatus: boolean; anilistStatus: boolean;
@@ -338,6 +340,12 @@ export function parseArgs(argv: string[]): CliArgs {
} else if (arg === '--session-action') { } else if (arg === '--session-action') {
const parsed = parseSessionAction(readValue(argv[i + 1])); const parsed = parseSessionAction(readValue(argv[i + 1]));
if (parsed) args.sessionAction = parsed; 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=')) { } else if (arg.startsWith('--copy-subtitle-count=')) {
const value = Number(arg.split('=', 2)[1]); const value = Number(arg.split('=', 2)[1]);
if (Number.isInteger(value) && value > 0) args.copySubtitleCount = value; if (Number.isInteger(value) && value > 0) args.copySubtitleCount = value;
@@ -601,6 +609,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.playbackFeedback !== undefined || args.playbackFeedback !== undefined ||
args.cycleRuntimeOptionId !== undefined || args.cycleRuntimeOptionId !== undefined ||
args.sessionAction !== undefined || args.sessionAction !== undefined ||
args.pluginAction !== undefined ||
args.copySubtitleCount !== undefined || args.copySubtitleCount !== undefined ||
args.mineSentenceCount !== undefined || args.mineSentenceCount !== undefined ||
args.anilistStatus || args.anilistStatus ||
@@ -682,6 +691,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
args.playbackFeedback === undefined && args.playbackFeedback === undefined &&
args.cycleRuntimeOptionId === undefined && args.cycleRuntimeOptionId === undefined &&
args.sessionAction === undefined && args.sessionAction === undefined &&
args.pluginAction === undefined &&
args.copySubtitleCount === undefined && args.copySubtitleCount === undefined &&
args.mineSentenceCount === undefined && args.mineSentenceCount === undefined &&
!args.anilistStatus && !args.anilistStatus &&
@@ -752,6 +762,7 @@ export function shouldStartApp(args: CliArgs): boolean {
args.playbackFeedback !== undefined || args.playbackFeedback !== undefined ||
args.cycleRuntimeOptionId !== undefined || args.cycleRuntimeOptionId !== undefined ||
args.sessionAction !== undefined || args.sessionAction !== undefined ||
args.pluginAction !== undefined ||
args.copySubtitleCount !== undefined || args.copySubtitleCount !== undefined ||
args.mineSentenceCount !== undefined || args.mineSentenceCount !== undefined ||
args.dictionary || args.dictionary ||
@@ -819,6 +830,7 @@ export function shouldRunDictionarySettingsOnlyStartup(args: CliArgs): boolean {
args.playbackFeedback === undefined && args.playbackFeedback === undefined &&
args.cycleRuntimeOptionId === undefined && args.cycleRuntimeOptionId === undefined &&
args.sessionAction === undefined && args.sessionAction === undefined &&
args.pluginAction === undefined &&
args.copySubtitleCount === undefined && args.copySubtitleCount === undefined &&
args.mineSentenceCount === undefined && args.mineSentenceCount === undefined &&
!args.anilistStatus && !args.anilistStatus &&
+1
View File
@@ -46,6 +46,7 @@ ${B}Mining${R}
--open-session-help Open session help modal --open-session-help Open session help modal
--open-controller-select Open controller select modal --open-controller-select Open controller select modal
--open-controller-debug Open controller debug modal --open-controller-debug Open controller debug modal
--plugin-action ${D}<id>/<name>${R} Run a plugin action
${B}AniList${R} ${B}AniList${R}
--anilist-setup Open AniList authentication flow --anilist-setup Open AniList authentication flow
+2
View File
@@ -37,6 +37,7 @@ const {
startupWarmups, startupWarmups,
updates, updates,
notifications, notifications,
plugins,
auto_start_overlay, auto_start_overlay,
} = CORE_DEFAULT_CONFIG; } = CORE_DEFAULT_CONFIG;
const { const {
@@ -74,6 +75,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
startupWarmups, startupWarmups,
updates, updates,
notifications, notifications,
plugins,
subtitleStyle, subtitleStyle,
subtitleSidebar, subtitleSidebar,
auto_start_overlay, auto_start_overlay,
+2
View File
@@ -17,6 +17,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
| 'startupWarmups' | 'startupWarmups'
| 'updates' | 'updates'
| 'notifications' | 'notifications'
| 'plugins'
| 'auto_start_overlay' | 'auto_start_overlay'
> = { > = {
dictionaryBackend: 'yomitan', dictionaryBackend: 'yomitan',
@@ -145,5 +146,6 @@ export const CORE_DEFAULT_CONFIG: Pick<
notifications: { notifications: {
overlayPosition: 'top-right', overlayPosition: 'top-right',
}, },
plugins: {},
auto_start_overlay: true, auto_start_overlay: true,
}; };
+7
View File
@@ -546,6 +546,13 @@ export function buildCoreConfigOptionRegistry(
defaultValue: defaultConfig.notifications.overlayPosition, defaultValue: defaultConfig.notifications.overlayPosition,
description: 'Position for in-overlay notification cards.', 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', path: 'shortcuts.multiCopyTimeoutMs',
kind: 'number', kind: 'number',
+1
View File
@@ -57,6 +57,7 @@ export const SPECIAL_COMMANDS = {
ANIMETOSHO_OPEN: '__animetosho-open', ANIMETOSHO_OPEN: '__animetosho-open',
TSUKIHIME_OPEN: '__tsukihime-open', TSUKIHIME_OPEN: '__tsukihime-open',
RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:', RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
PLUGIN_ACTION_PREFIX: '__plugin-action:',
REPLAY_SUBTITLE: '__replay-subtitle', REPLAY_SUBTITLE: '__replay-subtitle',
PLAY_NEXT_SUBTITLE: '__play-next-subtitle', PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
YOUTUBE_PICKER_OPEN: '__youtube-picker-open', 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.'], notes: ['Hot-reload: position changes apply to the next overlay notification.'],
key: 'notifications', 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', title: 'Keyboard Shortcuts',
description: ['Overlay keyboard shortcuts. Set a shortcut to null to disable.'], description: ['Overlay keyboard shortcuts. Set a shortcut to null to disable.'],
+2
View File
@@ -4,6 +4,7 @@ import { createResolveContext } from './resolve/context';
import { applyCoreDomainConfig } from './resolve/core-domains'; import { applyCoreDomainConfig } from './resolve/core-domains';
import { applyImmersionTrackingConfig } from './resolve/immersion-tracking'; import { applyImmersionTrackingConfig } from './resolve/immersion-tracking';
import { applyIntegrationConfig } from './resolve/integrations'; import { applyIntegrationConfig } from './resolve/integrations';
import { applyPluginsConfig } from './resolve/plugins';
import { applyStatsConfig } from './resolve/stats'; import { applyStatsConfig } from './resolve/stats';
import { applySubtitleDomainConfig } from './resolve/subtitle-domains'; import { applySubtitleDomainConfig } from './resolve/subtitle-domains';
import { applyTopLevelConfig } from './resolve/top-level'; import { applyTopLevelConfig } from './resolve/top-level';
@@ -15,6 +16,7 @@ const APPLY_RESOLVE_STEPS = [
applyIntegrationConfig, applyIntegrationConfig,
applyImmersionTrackingConfig, applyImmersionTrackingConfig,
applyStatsConfig, applyStatsConfig,
applyPluginsConfig,
applyAnkiConnectResolution, applyAnkiConnectResolution,
] as const; ] as const;
+33
View File
@@ -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'],
);
});
+30
View File
@@ -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 } : {},
};
}
}
+4
View File
@@ -81,6 +81,7 @@ const JSON_OBJECT_FIELDS = new Set([
'keybindings', 'keybindings',
'controller.bindings', 'controller.bindings',
'controller.profiles', 'controller.profiles',
'plugins',
'ankiConnect.knownWords.decks', 'ankiConnect.knownWords.decks',
'subtitleStyle.css', 'subtitleStyle.css',
'subtitleStyle.secondary.css', 'subtitleStyle.secondary.css',
@@ -441,6 +442,9 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
if (path.startsWith('notifications.')) { if (path.startsWith('notifications.')) {
return { category: 'behavior', section: 'Notifications' }; return { category: 'behavior', section: 'Notifications' };
} }
if (path === 'plugins') {
return { category: 'advanced', section: 'Plugins' };
}
if (path === 'mpv.aniskipButtonKey') { if (path === 'mpv.aniskipButtonKey') {
return { category: 'input', section: 'Overlay Shortcuts' }; return { category: 'input', section: 'Overlay Shortcuts' };
} }
+5
View File
@@ -13,6 +13,7 @@ function makeDeps(overrides: Partial<AppReadyRuntimeDeps> = {}) {
loadSubtitlePosition: () => calls.push('loadSubtitlePosition'), loadSubtitlePosition: () => calls.push('loadSubtitlePosition'),
resolveKeybindings: () => calls.push('resolveKeybindings'), resolveKeybindings: () => calls.push('resolveKeybindings'),
createMpvClient: () => calls.push('createMpvClient'), createMpvClient: () => calls.push('createMpvClient'),
startPlugins: () => calls.push('startPlugins'),
reloadConfig: () => calls.push('reloadConfig'), reloadConfig: () => calls.push('reloadConfig'),
getResolvedConfig: () => ({ getResolvedConfig: () => ({
websocket: { enabled: 'auto' }, 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('loadSubtitlePosition'), false);
assert.equal(calls.includes('resolveKeybindings'), false); assert.equal(calls.includes('resolveKeybindings'), false);
assert.equal(calls.includes('createMpvClient'), false); assert.equal(calls.includes('createMpvClient'), false);
assert.equal(calls.includes('startPlugins'), false);
assert.equal(calls.includes('initRuntimeOptionsManager'), false); assert.equal(calls.includes('initRuntimeOptionsManager'), false);
assert.equal(calls.includes('createImmersionTracker'), false); assert.equal(calls.includes('createImmersionTracker'), false);
assert.equal(calls.includes('startJellyfinRemoteSession'), 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('loadSubtitlePosition') < calls.indexOf('startBackgroundWarmups'));
assert.ok(calls.indexOf('createMpvClient') < 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('initializeOverlayRuntime') < calls.indexOf('startBackgroundWarmups'));
assert.ok(calls.indexOf('startBackgroundWarmups') < calls.indexOf('handleInitialArgs')); assert.ok(calls.indexOf('startBackgroundWarmups') < calls.indexOf('handleInitialArgs'));
}); });
+17
View File
@@ -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 () => { test('handleCliCommand dispatches mark-watched session action', async () => {
let request: unknown = null; let request: unknown = null;
const { deps } = createDeps({ const { deps } = createDeps({
+6
View File
@@ -422,6 +422,12 @@ export function handleCliCommand(
`sessionAction:${args.sessionAction.actionId}`, `sessionAction:${args.sessionAction.actionId}`,
'Session action failed', '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) { } else if (args.toggle || args.toggleVisibleOverlay) {
deps.toggleVisibleOverlay(); deps.toggleVisibleOverlay();
} else if (args.togglePrimarySubtitleBar) { } else if (args.togglePrimarySubtitleBar) {
-1
View File
@@ -55,7 +55,6 @@ export {
subtitleCueListSeekTime, subtitleCueListSeekTime,
subtitleCueSeekTime, subtitleCueSeekTime,
} from './subtitle-cue-navigation'; } from './subtitle-cue-navigation';
export { shiftSubtitleRangeToVideoClock, toSubtitleClock, toVideoClock } from './subtitle-clock';
export { createFrequencyDictionaryLookup } from './frequency-dictionary'; export { createFrequencyDictionaryLookup } from './frequency-dictionary';
export { createJlptVocabularyLookup } from './jlpt-vocab'; export { createJlptVocabularyLookup } from './jlpt-vocab';
export { export {
+5 -4
View File
@@ -89,7 +89,6 @@ function createSubtitleSidebarSnapshotFixture(): SubtitleSidebarSnapshot {
cuesRevision: 1, cuesRevision: 1,
cues: [], cues: [],
currentTimeSec: null, currentTimeSec: null,
subDelay: 0,
currentSubtitle: { text: '', startTime: null, endTime: null }, currentSubtitle: { text: '', startTime: null, endTime: null },
config: { config: {
enabled: false, enabled: false,
@@ -576,8 +575,8 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
const openedModals: unknown[] = []; const openedModals: unknown[] = [];
registerIpcHandlers( registerIpcHandlers(
{ {
onOverlayModalClosed: (modal) => { onOverlayModalClosed: (modal, _senderWindow, instanceId) => {
closedModals.push(modal); closedModals.push(instanceId === undefined ? modal : [modal, instanceId]);
}, },
onOverlayModalOpened: (modal) => { onOverlayModalOpened: (modal) => {
openedModals.push(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)!({}, 'not-a-modal');
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'subsync'); handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'subsync');
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'kiku'); 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)!({}, 'bad');
handlers.on.get(IPC_CHANNELS.command.overlayModalOpened)!({}, 'subsync'); handlers.on.get(IPC_CHANNELS.command.overlayModalOpened)!({}, 'subsync');
+16 -7
View File
@@ -50,6 +50,7 @@ export interface IpcServiceDeps {
onOverlayModalClosed: ( onOverlayModalClosed: (
modal: OverlayHostedModal, modal: OverlayHostedModal,
senderWindow: ElectronBrowserWindow | null, senderWindow: ElectronBrowserWindow | null,
instanceId?: number,
) => void; ) => void;
onOverlayModalOpened?: ( onOverlayModalOpened?: (
modal: OverlayHostedModal, modal: OverlayHostedModal,
@@ -317,6 +318,7 @@ export interface IpcDepsRuntimeOptions {
onOverlayModalClosed: ( onOverlayModalClosed: (
modal: OverlayHostedModal, modal: OverlayHostedModal,
senderWindow: ElectronBrowserWindow | null, senderWindow: ElectronBrowserWindow | null,
instanceId?: number,
) => void; ) => void;
onOverlayModalOpened?: ( onOverlayModalOpened?: (
modal: OverlayHostedModal, 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(
const parsedModal = parseOverlayHostedModal(modal); IPC_CHANNELS.command.overlayModalClosed,
if (!parsedModal) return; (event: unknown, modal: unknown, instanceId: unknown) => {
const senderWindow = const parsedModal = parseOverlayHostedModal(modal);
electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null; if (!parsedModal) return;
deps.onOverlayModalClosed(parsedModal, senderWindow); const senderWindow =
}); electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null;
deps.onOverlayModalClosed(
parsedModal,
senderWindow,
Number.isInteger(instanceId) ? (instanceId as number) : undefined,
);
},
);
ipc.on(IPC_CHANNELS.command.overlayModalOpened, (event: unknown, modal: unknown) => { ipc.on(IPC_CHANNELS.command.overlayModalOpened, (event: unknown, modal: unknown) => {
const parsedModal = parseOverlayHostedModal(modal); const parsedModal = parseOverlayHostedModal(modal);
if (!parsedModal) return; if (!parsedModal) return;
+1 -19
View File
@@ -77,7 +77,6 @@ test('mineSentenceCard handles missing integration and disconnected mpv', async
currentSubText: 'line', currentSubText: 'line',
currentSubStart: 1, currentSubStart: 1,
currentSubEnd: 2, currentSubEnd: 2,
currentSubDelay: 0,
}, },
showMpvOsd: (text) => osd.push(text), showMpvOsd: (text) => osd.push(text),
}), }),
@@ -110,7 +109,6 @@ test('mineSentenceCard creates sentence card from mpv subtitle state', async ()
currentSubText: 'subtitle line', currentSubText: 'subtitle line',
currentSubStart: 10, currentSubStart: 10,
currentSubEnd: 12, currentSubEnd: 12,
currentSubDelay: 0,
currentSecondarySubText: 'secondary line', currentSecondarySubText: 'secondary line',
}, },
showMpvOsd: () => {}, showMpvOsd: () => {},
@@ -150,7 +148,6 @@ test('mineSentenceCard prefers a canonical primary subtitle snapshot', async ()
currentSubText: '今今今手手手', currentSubText: '今今今手手手',
currentSubStart: 11.4, currentSubStart: 11.4,
currentSubEnd: 11.8, currentSubEnd: 11.8,
currentSubDelay: 0,
currentSecondarySubText: 'English subtitle', currentSecondarySubText: 'English subtitle',
}, },
primarySubtitle: { primarySubtitle: {
@@ -190,7 +187,6 @@ test('mineSentenceCard uses normalized secondary subtitle state instead of raw m
currentSubText: '日本語字幕', currentSubText: '日本語字幕',
currentSubStart: 10, currentSubStart: 10,
currentSubEnd: 12, currentSubEnd: 12,
currentSubDelay: 0,
currentSecondarySubText: 'Your\nmosaic', currentSecondarySubText: 'Your\nmosaic',
requestProperty: async () => { requestProperty: async () => {
requestedRawSecondaryText = true; requestedRawSecondaryText = true;
@@ -222,7 +218,6 @@ test('mineSentenceCard omits normalized secondary text that matches the primary
currentSubText: '日本語字幕', currentSubText: '日本語字幕',
currentSubStart: 10, currentSubStart: 10,
currentSubEnd: 12, currentSubEnd: 12,
currentSubDelay: 0,
currentSecondarySubText: '日本語字幕', currentSecondarySubText: '日本語字幕',
}, },
showMpvOsd: () => {}, showMpvOsd: () => {},
@@ -401,7 +396,7 @@ test('handleMineSentenceDigit joins per-entry secondary subtitles when available
test('captureLiveSubtitleMiningContext snapshots the current line and timings', () => { test('captureLiveSubtitleMiningContext snapshots the current line and timings', () => {
const context = captureLiveSubtitleMiningContext( const context = captureLiveSubtitleMiningContext(
{ currentSubText: ' 食べる ', currentSubStart: 12.5, currentSubEnd: 15.25, currentSubDelay: 0 }, { currentSubText: ' 食べる ', currentSubStart: 12.5, currentSubEnd: 15.25 },
() => 1234, () => 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', () => { test('captureLiveSubtitleMiningContext rejects missing client, empty text and bad timings', () => {
assert.equal(captureLiveSubtitleMiningContext(null), null); assert.equal(captureLiveSubtitleMiningContext(null), null);
assert.equal( assert.equal(
@@ -431,7 +416,6 @@ test('captureLiveSubtitleMiningContext rejects missing client, empty text and ba
currentSubText: ' ', currentSubText: ' ',
currentSubStart: 1, currentSubStart: 1,
currentSubEnd: 2, currentSubEnd: 2,
currentSubDelay: 0,
}), }),
null, null,
); );
@@ -440,7 +424,6 @@ test('captureLiveSubtitleMiningContext rejects missing client, empty text and ba
currentSubText: 'line', currentSubText: 'line',
currentSubStart: 5, currentSubStart: 5,
currentSubEnd: 5, currentSubEnd: 5,
currentSubDelay: 0,
}), }),
null, null,
); );
@@ -449,7 +432,6 @@ test('captureLiveSubtitleMiningContext rejects missing client, empty text and ba
currentSubText: 'line', currentSubText: 'line',
currentSubStart: NaN, currentSubStart: NaN,
currentSubEnd: 2, currentSubEnd: 2,
currentSubDelay: 0,
}), }),
null, null,
); );
+4 -19
View File
@@ -1,6 +1,5 @@
import type { SubtitleTimingBlock } from '../../subtitle-timing-tracker'; import type { SubtitleTimingBlock } from '../../subtitle-timing-tracker';
import type { SubtitleMiningContext } from '../../types/subtitle'; import type { SubtitleMiningContext } from '../../types/subtitle';
import { shiftSubtitleRangeToVideoClock } from './subtitle-clock';
interface SubtitleTimingTrackerLike { interface SubtitleTimingTrackerLike {
getRecentBlocks: (count: number) => string[]; getRecentBlocks: (count: number) => string[];
@@ -26,7 +25,6 @@ interface MpvClientLike {
currentSubText: string; currentSubText: string;
currentSubStart: number; currentSubStart: number;
currentSubEnd: number; currentSubEnd: number;
currentSubDelay: number;
currentSecondarySubText?: string; currentSecondarySubText?: string;
requestProperty?: (name: string) => Promise<unknown>; 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. * line is on screen by then; callers capture here at mining time instead.
*/ */
export function captureLiveSubtitleMiningContext( export function captureLiveSubtitleMiningContext(
client: Pick< client: Pick<MpvClientLike, 'currentSubText' | 'currentSubStart' | 'currentSubEnd'> | null,
MpvClientLike,
'currentSubText' | 'currentSubStart' | 'currentSubEnd' | 'currentSubDelay'
> | null,
now: () => number = Date.now, now: () => number = Date.now,
): SubtitleMiningContext | null { ): SubtitleMiningContext | null {
if (!client) { if (!client) {
@@ -78,10 +73,7 @@ export function captureLiveSubtitleMiningContext(
if (!text || !Number.isFinite(startTime) || !Number.isFinite(endTime) || endTime <= startTime) { if (!text || !Number.isFinite(startTime) || !Number.isFinite(endTime) || endTime <= startTime) {
return null; return null;
} }
return shiftSubtitleRangeToVideoClock<SubtitleMiningContext>( return { source: 'overlay', text, startTime, endTime, capturedAtMs: now() };
{ source: 'overlay', text, startTime, endTime, capturedAtMs: now() },
client.currentSubDelay,
);
} }
export function copyCurrentSubtitle(deps: { export function copyCurrentSubtitle(deps: {
@@ -175,7 +167,6 @@ export async function markLastCardAsAudioCard(deps: {
export async function mineSentenceCard(deps: { export async function mineSentenceCard(deps: {
ankiIntegration: AnkiIntegrationLike | null; ankiIntegration: AnkiIntegrationLike | null;
mpvClient: MpvClientLike | null; mpvClient: MpvClientLike | null;
/** Already on the video clock, like every `SubtitleMiningContext`. */
primarySubtitle?: Pick<SubtitleMiningContext, 'text' | 'startTime' | 'endTime'>; primarySubtitle?: Pick<SubtitleMiningContext, 'text' | 'startTime' | 'endTime'>;
showMpvOsd: (text: string) => void; showMpvOsd: (text: string) => void;
}): Promise<boolean> { }): Promise<boolean> {
@@ -194,16 +185,10 @@ export async function mineSentenceCard(deps: {
} }
const secondarySubText = getCurrentSecondarySubTextForSentenceCard(mpvClient, primaryText); const secondarySubText = getCurrentSecondarySubTextForSentenceCard(mpvClient, primaryText);
const range =
deps.primarySubtitle ??
shiftSubtitleRangeToVideoClock(
{ startTime: mpvClient.currentSubStart, endTime: mpvClient.currentSubEnd },
mpvClient.currentSubDelay,
);
return await anki.createSentenceCard( return await anki.createSentenceCard(
primaryText, primaryText,
range.startTime, deps.primarySubtitle?.startTime ?? mpvClient.currentSubStart,
range.endTime, deps.primarySubtitle?.endTime ?? mpvClient.currentSubEnd,
secondarySubText, secondarySubText,
); );
} }
-1
View File
@@ -38,7 +38,6 @@ const MPV_SUBTITLE_PROPERTY_OBSERVATIONS: string[] = [
'path', 'path',
'sub-start', 'sub-start',
'sub-end', 'sub-end',
'sub-delay',
'time-pos', 'time-pos',
'secondary-sub-text', 'secondary-sub-text',
'aid', 'aid',
+16 -41
View File
@@ -73,8 +73,6 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
getCurrentSubStart: () => 0, getCurrentSubStart: () => 0,
setCurrentSubEnd: () => {}, setCurrentSubEnd: () => {},
getCurrentSubEnd: () => 0, getCurrentSubEnd: () => 0,
setCurrentSubDelay: () => {},
getCurrentSubDelay: () => 0,
emitMediaPathChange: (payload) => { emitMediaPathChange: (payload) => {
state.mediaPath = payload.path; state.mediaPath = payload.path;
}, },
@@ -133,22 +131,6 @@ test('dispatchMpvProtocolMessage emits subtitle text on property change', async
assert.deepEqual(state.events, [{ text: '字幕', isOverlayVisible: false }]); 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 () => { test('dispatchMpvProtocolMessage widens halfwidth katakana in subtitle text', async () => {
const { deps, state } = createDeps(); 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 () => { test('dispatchMpvProtocolMessage updates current time before emitting time-pos change', async () => {
const calls: string[] = []; const calls: string[] = [];
let currentTimePos = 0; let currentTimePos = 0;
@@ -486,3 +445,19 @@ test('dispatchMpvProtocolMessage emits client-message string args', async () =>
assert.deepEqual(received, [{ args: ['subminer-skip-intro', 'extra'] }]); 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 },
]);
});
+26 -15
View File
@@ -1,7 +1,6 @@
import { MpvSubtitleRenderMetrics } from '../../types'; import { MpvSubtitleRenderMetrics } from '../../types';
import { sanitizeMediaTitle } from '../../shared/media-identity'; import { sanitizeMediaTitle } from '../../shared/media-identity';
import { widenHalfwidthKana } from '../utils/halfwidth-kana'; import { widenHalfwidthKana } from '../utils/halfwidth-kana';
import { toVideoClock } from './subtitle-clock';
export type MpvMessage = { export type MpvMessage = {
event?: string; event?: string;
@@ -10,8 +9,30 @@ export type MpvMessage = {
args?: unknown; args?: unknown;
request_id?: number; request_id?: number;
error?: string; 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_SUBTEXT = 101;
export const MPV_REQUEST_ID_PATH = 102; export const MPV_REQUEST_ID_PATH = 102;
export const MPV_REQUEST_ID_SECONDARY_SUBTEXT = 103; export const MPV_REQUEST_ID_SECONDARY_SUBTEXT = 103;
@@ -65,8 +86,6 @@ export interface MpvProtocolHandleMessageDeps {
getCurrentSubStart: () => number; getCurrentSubStart: () => number;
setCurrentSubEnd: (value: number) => void; setCurrentSubEnd: (value: number) => void;
getCurrentSubEnd: () => number; getCurrentSubEnd: () => number;
setCurrentSubDelay: (value: number) => void;
getCurrentSubDelay: () => number;
emitMediaPathChange: (payload: { path: string }) => void; emitMediaPathChange: (payload: { path: string }) => void;
emitMediaTitleChange: (payload: { title: string | null }) => void; emitMediaTitleChange: (payload: { title: string | null }) => void;
emitTimePosChange: (payload: { time: number }) => void; emitTimePosChange: (payload: { time: number }) => void;
@@ -103,6 +122,7 @@ export interface MpvProtocolHandleMessageDeps {
shouldQuitOnMpvShutdown: () => boolean; shouldQuitOnMpvShutdown: () => boolean;
requestAppQuit: () => void; requestAppQuit: () => void;
emitClientMessage?: (payload: { args: string[] }) => void; emitClientMessage?: (payload: { args: string[] }) => void;
emitEndFile?: (payload: MpvEndFileEvent) => void;
} }
type SubtitleTrackCandidate = { type SubtitleTrackCandidate = {
@@ -266,8 +286,7 @@ export async function dispatchMpvProtocolMessage(
const subEnd = (msg.data as number) || 0; const subEnd = (msg.data as number) || 0;
deps.setCurrentSubEnd(subEnd); deps.setCurrentSubEnd(subEnd);
if (deps.getPendingPauseAtSubEnd() && subEnd > 0) { if (deps.getPendingPauseAtSubEnd() && subEnd > 0) {
// `sub-end` is on the subtitle clock; the pause check compares it with `time-pos`. deps.setPauseAtTime(subEnd);
deps.setPauseAtTime(toVideoClock(subEnd, deps.getCurrentSubDelay()));
deps.setPendingPauseAtSubEnd(false); deps.setPendingPauseAtSubEnd(false);
deps.sendCommand({ command: ['set_property', 'pause', false] }); deps.sendCommand({ command: ['set_property', 'pause', false] });
} }
@@ -276,16 +295,6 @@ export async function dispatchMpvProtocolMessage(
start: deps.getCurrentSubStart(), start: deps.getCurrentSubStart(),
end: deps.getCurrentSubEnd(), 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') { } else if (msg.name === 'secondary-sub-text') {
const nextSubText = (msg.data as string) || ''; const nextSubText = (msg.data as string) || '';
deps.setCurrentSecondarySubText(nextSubText); deps.setCurrentSecondarySubText(nextSubText);
@@ -433,6 +442,8 @@ export async function dispatchMpvProtocolMessage(
if (args.length > 0) { if (args.length > 0) {
deps.emitClientMessage?.({ args }); deps.emitClientMessage?.({ args });
} }
} else if (msg.event === 'end-file') {
deps.emitEndFile?.(parseEndFileEvent(msg));
} else if (msg.event === 'shutdown') { } else if (msg.event === 'shutdown') {
deps.restorePreviousSecondarySubVisibility(); deps.restorePreviousSecondarySubVisibility();
if (deps.shouldQuitOnMpvShutdown()) { if (deps.shouldQuitOnMpvShutdown()) {
+5 -6
View File
@@ -4,6 +4,7 @@ import {
dispatchMpvProtocolMessage, dispatchMpvProtocolMessage,
MPV_REQUEST_ID_TRACK_LIST_AUDIO, MPV_REQUEST_ID_TRACK_LIST_AUDIO,
MPV_REQUEST_ID_TRACK_LIST_SECONDARY, MPV_REQUEST_ID_TRACK_LIST_SECONDARY,
type MpvEndFileEvent,
MpvMessage, MpvMessage,
MpvProtocolHandleMessageDeps, MpvProtocolHandleMessageDeps,
splitMpvMessagesFromBuffer, splitMpvMessagesFromBuffer,
@@ -138,6 +139,7 @@ export interface MpvIpcClientEventMap {
'subtitle-metrics-change': { patch: Partial<MpvSubtitleRenderMetrics> }; 'subtitle-metrics-change': { patch: Partial<MpvSubtitleRenderMetrics> };
'secondary-subtitle-visibility': { visible: boolean }; 'secondary-subtitle-visibility': { visible: boolean };
'client-message': { args: string[] }; 'client-message': { args: string[] };
'end-file': MpvEndFileEvent;
} }
type MpvIpcClientEventName = keyof MpvIpcClientEventMap; type MpvIpcClientEventName = keyof MpvIpcClientEventMap;
@@ -160,8 +162,6 @@ export class MpvIpcClient implements MpvClient {
public currentTimePos = 0; public currentTimePos = 0;
public currentSubStart = 0; public currentSubStart = 0;
public currentSubEnd = 0; public currentSubEnd = 0;
/** mpv `sub-delay`: seconds each primary line plays after its `sub-start`/`sub-end`. */
public currentSubDelay = 0;
public currentSubText = ''; public currentSubText = '';
public currentSecondarySubText = ''; public currentSecondarySubText = '';
public currentAudioStreamIndex: number | null = null; public currentAudioStreamIndex: number | null = null;
@@ -461,10 +461,6 @@ export class MpvIpcClient implements MpvClient {
this.currentSubEnd = value; this.currentSubEnd = value;
}, },
getCurrentSubEnd: () => this.currentSubEnd, getCurrentSubEnd: () => this.currentSubEnd,
setCurrentSubDelay: (value: number) => {
this.currentSubDelay = value;
},
getCurrentSubDelay: () => this.currentSubDelay,
emitMediaPathChange: (payload) => { emitMediaPathChange: (payload) => {
this.emit('media-path-change', payload); this.emit('media-path-change', payload);
}, },
@@ -525,6 +521,9 @@ export class MpvIpcClient implements MpvClient {
emitClientMessage: (payload) => { emitClientMessage: (payload) => {
this.emit('client-message', payload); this.emit('client-message', payload);
}, },
emitEndFile: (payload) => {
this.emit('end-file', payload);
},
}; };
} }
+137
View File
@@ -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 });
}
});
+134
View File
@@ -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 ?? '');
});
}
+47
View File
@@ -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();
}
});
+400
View File
@@ -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: [],
};
}
+183
View File
@@ -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 };
}
+23
View File
@@ -55,6 +55,10 @@ function createDeps(overrides: Partial<SessionActionExecutorDeps> = {}) {
playNextSubtitle: () => calls.push('play-next'), playNextSubtitle: () => calls.push('play-next'),
cycleRuntimeOption: () => ({ ok: true }), cycleRuntimeOption: () => ({ ok: true }),
playNextPlaylistItem: () => calls.push('playlist-next'), playNextPlaylistItem: () => calls.push('playlist-next'),
runPluginAction: async (pluginActionId) => {
calls.push(`plugin:${pluginActionId}`);
return pluginActionId === 'ai-explain/explain';
},
showMpvOsd: (text) => calls.push(`osd:${text}`), showMpvOsd: (text) => calls.push(`osd:${text}`),
...overrides, ...overrides,
}; };
@@ -81,3 +85,22 @@ test('dispatchSessionAction does not advance playlist when mark watched no-ops',
assert.deepEqual(calls, ['mark-watched']); 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',
]);
});
+9
View File
@@ -32,6 +32,8 @@ export interface SessionActionExecutorDeps {
playNextSubtitle: () => void; playNextSubtitle: () => void;
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult; cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
playNextPlaylistItem: () => void; playNextPlaylistItem: () => void;
/** Returns false when no loaded plugin registered the action. */
runPluginAction: (pluginActionId: string) => Promise<boolean>;
showMpvOsd: (text: string) => void; showMpvOsd: (text: string) => void;
} }
@@ -151,6 +153,13 @@ export async function dispatchSessionAction(
} }
return; return;
} }
case 'runPluginAction': {
const pluginActionId = request.payload?.pluginActionId;
if (!pluginActionId || !(await deps.runPluginAction(pluginActionId))) {
deps.showMpvOsd(`Plugin action not available: ${pluginActionId ?? 'unknown'}`);
}
return;
}
default: default:
return assertUnreachableSessionAction(request.actionId); 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', () => { test('compileSessionBindings leaves retired subtitle-delay shift tokens as mpv commands', () => {
const result = compileSessionBindings({ const result = compileSessionBindings({
shortcuts: createShortcuts(), shortcuts: createShortcuts(),
+10
View File
@@ -342,6 +342,16 @@ function resolveCommandBinding(
if (command.length !== 1) return null; if (command.length !== 1) return null;
return { actionType: 'session-action', actionId: 'playNextSubtitle' }; 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 (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) {
if (command.length !== 1) { if (command.length !== 1) {
return null; return null;
+3
View File
@@ -117,6 +117,8 @@ export interface AppReadyRuntimeDeps {
loadSubtitlePosition: () => void; loadSubtitlePosition: () => void;
resolveKeybindings: () => void; resolveKeybindings: () => void;
createMpvClient: () => void; createMpvClient: () => void;
/** Loads enabled plugins; full startup only. */
startPlugins?: () => void;
reloadConfig: () => void; reloadConfig: () => void;
getResolvedConfig: () => AppReadyConfigLike; getResolvedConfig: () => AppReadyConfigLike;
getConfigWarnings: () => ConfigValidationWarning[]; getConfigWarnings: () => ConfigValidationWarning[];
@@ -310,6 +312,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
deps.createMpvClient(); deps.createMpvClient();
deps.initRuntimeOptionsManager(); deps.initRuntimeOptionsManager();
deps.setSecondarySubMode(config.secondarySub?.defaultMode ?? deps.defaultSecondarySubMode); deps.setSecondarySubMode(config.secondarySub?.defaultMode ?? deps.defaultSecondarySubMode);
deps.startPlugins?.();
const wsConfig = config.websocket || {}; const wsConfig = config.websocket || {};
const wsEnabled = wsConfig.enabled ?? 'auto'; const wsEnabled = wsConfig.enabled ?? 'auto';
-32
View File
@@ -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', 'seek',
13.08, 13.08,
'absolute+exact', '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' }, { 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', 'seek',
13.08, 13.08,
'absolute+exact', 'absolute+exact',
@@ -55,7 +55,7 @@ test('next subtitle navigation advances past the latest overlapping lyric', () =
{ startTime: 16, endTime: 19, text: 'next 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', 'seek',
16.08, 16.08,
'absolute+exact', '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' }, { 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', 'seek',
10.08, 10.08,
'absolute+exact', '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', () => { test('subtitle navigation falls back when no sanitized destination exists', () => {
const cues = [{ startTime: 10, endTime: 13, text: 'only line' }]; const cues = [{ startTime: 10, endTime: 13, text: 'only line' }];
assert.equal(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.2, 0), null); assert.equal(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.2), null);
assert.equal(resolveSanitizedSubtitleSeekCommand(['seek', 5], cues, 10.2, 0), null); assert.equal(resolveSanitizedSubtitleSeekCommand(['seek', 5], cues, 10.2), 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',
]);
}); });
test('sidebar cue seeks share the boundary-safe sanitized cue timestamp', () => { test('sidebar cue seeks share the boundary-safe sanitized cue timestamp', () => {
+2 -5
View File
@@ -1,4 +1,3 @@
import { toSubtitleClock, toVideoClock } from './subtitle-clock';
import type { SubtitleCue } from './subtitle-cue-parser'; import type { SubtitleCue } from './subtitle-cue-parser';
const CUE_START_GROUP_TOLERANCE_SECONDS = 0.05; const CUE_START_GROUP_TOLERANCE_SECONDS = 0.05;
@@ -81,10 +80,8 @@ export function subtitleCueListSeekTime(
export function resolveSanitizedSubtitleSeekCommand( export function resolveSanitizedSubtitleSeekCommand(
command: readonly (string | number)[], command: readonly (string | number)[],
cues: readonly SubtitleCue[], cues: readonly SubtitleCue[],
videoTimeSec: number, currentTimeSec: number,
subDelay: number,
): (string | number)[] | null { ): (string | number)[] | null {
const currentTimeSec = toSubtitleClock(videoTimeSec, subDelay);
if ( if (
command.length < 2 || command.length < 2 ||
command[0] !== 'sub-seek' || command[0] !== 'sub-seek' ||
@@ -127,5 +124,5 @@ export function resolveSanitizedSubtitleSeekCommand(
if (!destination) { if (!destination) {
return null; return null;
} }
return ['seek', toVideoClock(subtitleCueSeekTime(destination.cue), subDelay), 'absolute+exact']; return ['seek', subtitleCueSeekTime(destination.cue), 'absolute+exact'];
} }
+1 -17
View File
@@ -1,22 +1,6 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { import { createNotifySendReplacer, resolveDefaultNotificationIconPath } from './notification';
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');
});
test('default notification icon resolves packaged SubMiner asset when no per-notification icon is provided', () => { test('default notification icon resolves packaged SubMiner asset when no per-notification icon is provided', () => {
const path = resolveDefaultNotificationIconPath({ const path = resolveDefaultNotificationIconPath({
+2 -12
View File
@@ -3,6 +3,7 @@ import electron from 'electron';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { createLogger } from '../../logger'; import { createLogger } from '../../logger';
import { buildSystemBinaryEnv } from './system-binary-env';
const { Notification, nativeImage } = electron; const { Notification, nativeImage } = electron;
const logger = createLogger('core:notification'); 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) => 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 ?? ''), callback(error, stdout ?? ''),
), ),
); );
+16
View File
@@ -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');
});
+10
View File
@@ -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\\^"^""',
],
});
});
+83
View File
@@ -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}"`],
};
}
+120 -36
View File
@@ -328,11 +328,9 @@ import {
setMpvSubVisibilityRuntime, setMpvSubVisibilityRuntime,
setOverlayDebugVisualizationEnabledRuntime, setOverlayDebugVisualizationEnabledRuntime,
setVisibleOverlayVisible as setVisibleOverlayVisibleCore, setVisibleOverlayVisible as setVisibleOverlayVisibleCore,
shiftSubtitleRangeToVideoClock,
showMpvOsdRuntime, showMpvOsdRuntime,
JellyfinPlaybackReporter, JellyfinPlaybackReporter,
tokenizeSubtitle as tokenizeSubtitleCore, tokenizeSubtitle as tokenizeSubtitleCore,
toSubtitleClock,
triggerFieldGrouping as triggerFieldGroupingCore, triggerFieldGrouping as triggerFieldGroupingCore,
upsertYomitanDictionarySettings, upsertYomitanDictionarySettings,
updateLastCardFromClipboard as updateLastCardFromClipboardCore, updateLastCardFromClipboard as updateLastCardFromClipboardCore,
@@ -514,6 +512,14 @@ import { openSubsyncManualModal as openSubsyncManualModalRuntime } from './main/
import { openSessionHelpModal as openSessionHelpModalRuntime } from './main/runtime/session-help-open'; import { openSessionHelpModal as openSessionHelpModalRuntime } from './main/runtime/session-help-open';
import { openChangelogModal as openChangelogModalRuntime } from './main/runtime/changelog-open'; import { openChangelogModal as openChangelogModalRuntime } from './main/runtime/changelog-open';
import { createChangelogRuntime } from './main/runtime/changelog/changelog-runtime'; 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 { openCharacterDictionaryManagerModal as openCharacterDictionaryManagerModalRuntime } from './main/runtime/character-dictionary-open';
import { openControllerSelectModal as openControllerSelectModalRuntime } from './main/runtime/controller-select-open'; import { openControllerSelectModal as openControllerSelectModalRuntime } from './main/runtime/controller-select-open';
import { openControllerDebugModal as openControllerDebugModalRuntime } from './main/runtime/controller-debug-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 { function withCurrentSubtitleTiming(payload: SubtitleData): SubtitleData {
const canonical = resolveCanonicalPrimarySubtitle({ const canonical = resolveCanonicalPrimarySubtitle({
liveText: payload.text, liveText: payload.text,
currentTimeSec: toSubtitleClock( currentTimeSec: Number(appState.mpvClient?.currentTimePos),
Number(appState.mpvClient?.currentTimePos),
appState.mpvClient?.currentSubDelay ?? 0,
),
cues: appState.activeParsedSubtitleCues, cues: appState.activeParsedSubtitleCues,
}); });
return { 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 // 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 // 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. // 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({ const resolved = resolvePrimarySubtitle({
liveText: appState.mpvClient?.currentSubText ?? '', liveText: appState.mpvClient?.currentSubText ?? '',
currentTimeSec: toSubtitleClock(Number(appState.mpvClient?.currentTimePos), subDelay), currentTimeSec: Number(appState.mpvClient?.currentTimePos),
cues: appState.activeParsedSubtitleCues, cues: appState.activeParsedSubtitleCues,
}); });
// Same validity bar as the live capture path: an unusable resolved span must fall // 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 captureLiveSubtitleMiningContext(appState.mpvClient);
} }
return shiftSubtitleRangeToVideoClock<SubtitleMiningContext>( return {
{ source: 'overlay',
source: 'overlay', text: resolvedText,
text: resolvedText, startTime: resolved.startTime,
startTime: resolved.startTime, endTime: resolved.endTime,
endTime: resolved.endTime, capturedAtMs: Date.now(),
capturedAtMs: Date.now(), };
},
subDelay,
);
} }
function emitSubtitlePayload(payload: SubtitleData, options?: { resumePrefetch?: boolean }): void { function emitSubtitlePayload(payload: SubtitleData, options?: { resumePrefetch?: boolean }): void {
const timedPayload = withCurrentSubtitleTiming(payload); const timedPayload = withCurrentSubtitleTiming(payload);
@@ -2126,18 +2124,13 @@ function getSubtitleSidebarPlaybackState(): SubtitleSidebarPlaybackState {
subtitleSidebarCuesRef = appState.activeParsedSubtitleCues; subtitleSidebarCuesRef = appState.activeParsedSubtitleCues;
subtitleSidebarCuesRevision += 1; subtitleSidebarCuesRevision += 1;
} }
const subDelay = appState.mpvClient?.currentSubDelay ?? 0;
return { return {
sourceKey: JSON.stringify([ sourceKey: JSON.stringify([
appState.activeParsedSubtitleMediaPath, appState.activeParsedSubtitleMediaPath,
appState.activeParsedSubtitleSource, appState.activeParsedSubtitleSource,
]), ]),
cuesRevision: subtitleSidebarCuesRevision, cuesRevision: subtitleSidebarCuesRevision,
currentTimeSec: currentTimeSec: appState.mpvClient?.currentTimePos ?? null,
appState.mpvClient?.currentTimePos === undefined
? null
: toSubtitleClock(appState.mpvClient.currentTimePos, subDelay),
subDelay,
currentSubtitle: { currentSubtitle: {
text: appState.currentSubText, text: appState.currentSubText,
startTime: appState.mpvClient?.currentSubStart ?? null, startTime: appState.mpvClient?.currentSubStart ?? null,
@@ -2176,7 +2169,6 @@ function sendRendererMpvCommand(rawCommand: (string | number)[]): void {
rawCommand, rawCommand,
appState.activeParsedSubtitleCues, appState.activeParsedSubtitleCues,
appState.mpvClient?.currentTimePos ?? Number.NaN, appState.mpvClient?.currentTimePos ?? Number.NaN,
appState.mpvClient?.currentSubDelay ?? 0,
) ?? rawCommand; ) ?? rawCommand;
if (isExplicitMpvSeekCommand(command)) { if (isExplicitMpvSeekCommand(command)) {
explicitSeekIntentExpiresAtMs = Date.now() + EXPLICIT_SEEK_INTENT_TTL_MS; 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 mediaTimingFrameExtractor = createMediaTimingFrameExtractor();
const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({ const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({
getMpvClient: () => appState.mpvClient, getMpvClient: () => appState.mpvClient,
@@ -3131,10 +3204,7 @@ const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({
acquireMediaWindow: (source, range) => getSharedRemoteMediaWindowCache().acquire(source, range), acquireMediaWindow: (source, range) => getSharedRemoteMediaWindowCache().acquire(source, range),
getSubtitleContextLines: (range) => getSubtitleContextLines: (range) =>
collectMediaTimingContextLines({ collectMediaTimingContextLines({
// Review ranges and tracker entries are on the video clock; parsed cues are not. cues: appState.activeParsedSubtitleCues,
cues: appState.activeParsedSubtitleCues.map((cue) =>
shiftSubtitleRangeToVideoClock(cue, appState.mpvClient?.currentSubDelay ?? 0),
),
fallbackPrevious: appState.subtitleTimingTracker?.getRecentEntries(40) ?? [], fallbackPrevious: appState.subtitleTimingTracker?.getRecentEntries(40) ?? [],
startTime: range.startTime, startTime: range.startTime,
endTime: range.endTime, endTime: range.endTime,
@@ -4333,6 +4403,7 @@ const {
void appState.discordPresenceService?.stop(); void appState.discordPresenceService?.stop();
appState.discordPresenceService = null; appState.discordPresenceService = null;
}, },
unloadPlugins: () => pluginHost.unloadAll(),
}, },
shouldRestoreWindowsOnActivateMainDeps: { shouldRestoreWindowsOnActivateMainDeps: {
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
@@ -4602,6 +4673,9 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
createMpvClient: () => { createMpvClient: () => {
appState.mpvClient = createMpvClientRuntimeService(); appState.mpvClient = createMpvClientRuntimeService();
}, },
startPlugins: () => {
void pluginHost.loadAll();
},
getResolvedConfig: () => configService.getConfig(), getResolvedConfig: () => configService.getConfig(),
getConfigWarnings: () => configService.getWarnings(), getConfigWarnings: () => configService.getWarnings(),
logConfigWarning: (warning) => appLogger.logConfigWarning(warning), logConfigWarning: (warning) => appLogger.logConfigWarning(warning),
@@ -5298,6 +5372,7 @@ function createMpvClientRuntimeService(): MpvIpcClient {
client.on('media-path-change', aniSkipRuntime.handleMediaPathChange); client.on('media-path-change', aniSkipRuntime.handleMediaPathChange);
client.on('time-pos-change', aniSkipRuntime.handleTimePosChange); client.on('time-pos-change', aniSkipRuntime.handleTimePosChange);
client.on('client-message', aniSkipRuntime.handleClientMessage); client.on('client-message', aniSkipRuntime.handleClientMessage);
pluginHost.attachMpvClient(client);
return client; return client;
} }
@@ -5879,6 +5954,11 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
}, },
playNextPlaylistItem: () => playNextPlaylistItem: () =>
sendMpvCommandRuntime(appState.mpvClient, ['playlist-next', 'force']), 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), showMpvOsd: (text) => showConfiguredPlaybackFeedback(text),
}); });
} }
@@ -5960,7 +6040,15 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
return false; 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') { if (modal === 'media-timing-review') {
void mediaTimingReviewRuntime.dispose(); 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 // live mpv sub timings at lookup time so media generation clips the mined line even
// when extraction finishes long after playback has moved on. // when extraction finishes long after playback has moved on.
recordSubtitleMiningContext: (context) => recordSubtitleMiningContext: (context) =>
recordSubtitleMiningContext( recordSubtitleMiningContext(context ?? captureCurrentPrimarySubtitleMiningContext()),
context
? shiftSubtitleRangeToVideoClock(context, appState.mpvClient?.currentSubDelay ?? 0)
: captureCurrentPrimarySubtitleMiningContext(),
),
tokenizeCurrentSubtitle: async () => { tokenizeCurrentSubtitle: async () => {
const tokenizeSubtitleForCurrent = tokenizeSubtitleDeferred; const tokenizeSubtitleForCurrent = tokenizeSubtitleDeferred;
return resolveCurrentSubtitleForRenderer({ return resolveCurrentSubtitleForRenderer({
+2
View File
@@ -24,6 +24,7 @@ export interface AppReadyRuntimeDepsFactoryInput {
loadSubtitlePosition: AppReadyRuntimeDeps['loadSubtitlePosition']; loadSubtitlePosition: AppReadyRuntimeDeps['loadSubtitlePosition'];
resolveKeybindings: AppReadyRuntimeDeps['resolveKeybindings']; resolveKeybindings: AppReadyRuntimeDeps['resolveKeybindings'];
createMpvClient: AppReadyRuntimeDeps['createMpvClient']; createMpvClient: AppReadyRuntimeDeps['createMpvClient'];
startPlugins?: AppReadyRuntimeDeps['startPlugins'];
reloadConfig: AppReadyRuntimeDeps['reloadConfig']; reloadConfig: AppReadyRuntimeDeps['reloadConfig'];
getResolvedConfig: AppReadyRuntimeDeps['getResolvedConfig']; getResolvedConfig: AppReadyRuntimeDeps['getResolvedConfig'];
getConfigWarnings: AppReadyRuntimeDeps['getConfigWarnings']; getConfigWarnings: AppReadyRuntimeDeps['getConfigWarnings'];
@@ -94,6 +95,7 @@ export function createAppReadyRuntimeDeps(
loadSubtitlePosition: params.loadSubtitlePosition, loadSubtitlePosition: params.loadSubtitlePosition,
resolveKeybindings: params.resolveKeybindings, resolveKeybindings: params.resolveKeybindings,
createMpvClient: params.createMpvClient, createMpvClient: params.createMpvClient,
startPlugins: params.startPlugins,
reloadConfig: params.reloadConfig, reloadConfig: params.reloadConfig,
getResolvedConfig: params.getResolvedConfig, getResolvedConfig: params.getResolvedConfig,
getConfigWarnings: params.getConfigWarnings, getConfigWarnings: params.getConfigWarnings,
+1 -1
View File
@@ -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 \},/, /onOverlayModalOpened:\s*\(modal,\s*senderWindow\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
)?.groups?.body; )?.groups?.body;
const closedBlock = source.match( 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; )?.groups?.body;
const depsBlock = source.match(/getSubtitleSidebarOpen:\s*\(\)\s*=>\s*(?<body>[^\n,]+)/)?.groups const depsBlock = source.match(/getSubtitleSidebarOpen:\s*\(\)\s*=>\s*(?<body>[^\n,]+)/)?.groups
?.body; ?.body;
@@ -55,6 +55,7 @@ function makeCleanupDeps(calls: string[], overrides: Partial<CleanupDeps> = {}):
cleanupRemoteMediaWindows: record('cleanupRemoteMediaWindows'), cleanupRemoteMediaWindows: record('cleanupRemoteMediaWindows'),
cleanupJellyfinSubtitleCache: record('cleanupJellyfinSubtitleCache'), cleanupJellyfinSubtitleCache: record('cleanupJellyfinSubtitleCache'),
stopDiscordPresenceService: record('stopDiscordPresenceService'), stopDiscordPresenceService: record('stopDiscordPresenceService'),
unloadPlugins: record('unloadPlugins'),
...overrides, ...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')); 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 () => { test('on will quit cleanup handler cleans subtitle caches when stopping remote session fails', async () => {
const calls: string[] = []; const calls: string[] = [];
const cleanup = createOnWillQuitCleanupHandler( const cleanup = createOnWillQuitCleanupHandler(
+7 -1
View File
@@ -59,8 +59,14 @@ export function createOnWillQuitCleanupHandler(deps: {
cleanupRemoteMediaWindows: () => void; cleanupRemoteMediaWindows: () => void;
cleanupJellyfinSubtitleCache: () => void; cleanupJellyfinSubtitleCache: () => void;
stopDiscordPresenceService: () => void; stopDiscordPresenceService: () => void;
unloadPlugins: () => void | Promise<void>;
}) { }) {
return async (): 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.destroyTray();
deps.stopConfigHotReload(); deps.stopConfigHotReload();
deps.restorePreviousSecondarySubVisibility(); deps.restorePreviousSecondarySubVisibility();
@@ -68,7 +74,6 @@ export function createOnWillQuitCleanupHandler(deps: {
deps.unregisterAllGlobalShortcuts(); deps.unregisterAllGlobalShortcuts();
deps.stopSubtitleWebsocket(); deps.stopSubtitleWebsocket();
deps.stopTexthookerService(); deps.stopTexthookerService();
const cleanupErrors: unknown[] = [];
const stopSyncAutoScheduler = Promise.resolve(deps.stopSyncAutoScheduler()).catch( const stopSyncAutoScheduler = Promise.resolve(deps.stopSyncAutoScheduler()).catch(
(error: unknown) => { (error: unknown) => {
cleanupErrors.push(error); cleanupErrors.push(error);
@@ -118,6 +123,7 @@ export function createOnWillQuitCleanupHandler(deps: {
deps.cleanupRemoteMediaWindows(); deps.cleanupRemoteMediaWindows();
deps.stopDiscordPresenceService(); deps.stopDiscordPresenceService();
await stopSyncAutoScheduler; await stopSyncAutoScheduler;
await unloadPlugins;
if (cleanupErrors.length > 0) { if (cleanupErrors.length > 0) {
throw cleanupErrors[0]; throw cleanupErrors[0];
} }
@@ -52,6 +52,7 @@ function buildCleanupDeps(overrides: Partial<BuilderDeps> = {}) {
cleanupRemoteMediaWindows: noop, cleanupRemoteMediaWindows: noop,
cleanupJellyfinSubtitleCache: noop, cleanupJellyfinSubtitleCache: noop,
stopDiscordPresenceService: noop, stopDiscordPresenceService: noop,
unloadPlugins: noop,
...overrides, ...overrides,
})(); })();
} }
@@ -65,6 +65,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
cleanupRemoteMediaWindows: () => void; cleanupRemoteMediaWindows: () => void;
cleanupJellyfinSubtitleCache: () => void; cleanupJellyfinSubtitleCache: () => void;
stopDiscordPresenceService: () => void; stopDiscordPresenceService: () => void;
unloadPlugins: () => void | Promise<void>;
}) { }) {
return () => ({ return () => ({
destroyTray: () => deps.destroyTray(), destroyTray: () => deps.destroyTray(),
@@ -154,5 +155,6 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
cleanupRemoteMediaWindows: () => deps.cleanupRemoteMediaWindows(), cleanupRemoteMediaWindows: () => deps.cleanupRemoteMediaWindows(),
cleanupJellyfinSubtitleCache: () => deps.cleanupJellyfinSubtitleCache(), cleanupJellyfinSubtitleCache: () => deps.cleanupJellyfinSubtitleCache(),
stopDiscordPresenceService: () => deps.stopDiscordPresenceService(), stopDiscordPresenceService: () => deps.stopDiscordPresenceService(),
unloadPlugins: () => deps.unloadPlugins(),
}); });
} }
+1
View File
@@ -6,6 +6,7 @@ export function createBuildAppReadyRuntimeMainDepsHandler(deps: AppReadyRuntimeD
loadSubtitlePosition: deps.loadSubtitlePosition, loadSubtitlePosition: deps.loadSubtitlePosition,
resolveKeybindings: deps.resolveKeybindings, resolveKeybindings: deps.resolveKeybindings,
createMpvClient: deps.createMpvClient, createMpvClient: deps.createMpvClient,
startPlugins: deps.startPlugins,
reloadConfig: deps.reloadConfig, reloadConfig: deps.reloadConfig,
getResolvedConfig: deps.getResolvedConfig, getResolvedConfig: deps.getResolvedConfig,
getConfigWarnings: deps.getConfigWarnings, getConfigWarnings: deps.getConfigWarnings,
@@ -1,4 +1,3 @@
import { toSubtitleClock } from '../../core/services/subtitle-clock';
import type { SubtitleCue, SubtitleData } from '../../types'; import type { SubtitleCue, SubtitleData } from '../../types';
import { selectAutoplayStartupCue } from './autoplay-subtitle-primer'; import { selectAutoplayStartupCue } from './autoplay-subtitle-primer';
import { primeVisibleOverlaySubtitleFromMpv } from './current-subtitle-snapshot'; import { primeVisibleOverlaySubtitleFromMpv } from './current-subtitle-snapshot';
@@ -13,7 +12,6 @@ type AutoplaySubtitlePrimingMpvClient = {
requestProperty: (name: string) => Promise<unknown>; requestProperty: (name: string) => Promise<unknown>;
currentVideoPath?: string; currentVideoPath?: string;
currentTimePos?: number; currentTimePos?: number;
currentSubDelay?: number;
currentSubText?: string; currentSubText?: string;
currentSecondarySubText?: string; currentSecondarySubText?: string;
setCurrentSecondarySubText?: (text: string) => void; setCurrentSecondarySubText?: (text: string) => void;
@@ -158,7 +156,7 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
const currentTimeSec = Number(client?.currentTimePos ?? deps.getLastObservedTimePos()); const currentTimeSec = Number(client?.currentTimePos ?? deps.getLastObservedTimePos());
return resolvePrimarySubtitleText({ return resolvePrimarySubtitleText({
liveText: text, liveText: text,
currentTimeSec: toSubtitleClock(currentTimeSec, client?.currentSubDelay ?? 0), currentTimeSec,
cues: deps.getActiveParsedSubtitleCues(), cues: deps.getActiveParsedSubtitleCues(),
}); });
} }
@@ -272,10 +270,7 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
const currentTimeSeconds = Number( const currentTimeSeconds = Number(
timePosRaw ?? client?.currentTimePos ?? deps.getLastObservedTimePos() ?? 0, timePosRaw ?? client?.currentTimePos ?? deps.getLastObservedTimePos() ?? 0,
); );
const resolvedTimeSeconds = toSubtitleClock( const resolvedTimeSeconds = Number.isFinite(currentTimeSeconds) ? currentTimeSeconds : 0;
Number.isFinite(currentTimeSeconds) ? currentTimeSeconds : 0,
client?.currentSubDelay ?? 0,
);
const cue = selectAutoplayStartupCue( const cue = selectAutoplayStartupCue(
cues, cues,
resolvedTimeSeconds, resolvedTimeSeconds,
+8 -43
View File
@@ -2,6 +2,11 @@ import { spawn } from 'node:child_process';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import {
buildWindowsShimInvocation,
isWindowsCommandShim,
} from '../../core/utils/windows-command-shim';
export interface RunCommandResult { export interface RunCommandResult {
exitCode: number | null; exitCode: number | null;
stdout: string; stdout: string;
@@ -142,43 +147,7 @@ export function failureMessage(result: RunCommandResult, fallback: string): stri
} }
function needsWindowsShell(command: string): boolean { function needsWindowsShell(command: string): boolean {
return process.platform === 'win32' && /\.(cmd|bat)$/i.test(command); return process.platform === 'win32' && isWindowsCommandShim(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');
} }
function createDefaultRunCommand(): RunCommand { function createDefaultRunCommand(): RunCommand {
@@ -189,12 +158,8 @@ function createDefaultRunCommand(): RunCommand {
try { try {
const env = options.env ?? process.env; const env = options.env ?? process.env;
if (useShell) { if (useShell) {
const shellCommand = [ const invocation = buildWindowsShimInvocation(command, args, env);
escapeWindowsShellArgument(command), child = spawn(invocation.file, invocation.args, {
...args.map(escapeWindowsShellArgument),
].join(' ');
const commandProcessor = env.ComSpec ?? env.COMSPEC ?? process.env.ComSpec ?? 'cmd.exe';
child = spawn(commandProcessor, ['/d', '/s', '/v:off', '/c', `"${shellCommand}"`], {
env, env,
windowsHide: false, windowsHide: false,
windowsVerbatimArguments: true, windowsVerbatimArguments: true,
@@ -176,21 +176,6 @@ test('subtitle-track transitions ignore stale parsed cues until replacement cues
assert.deepEqual(recordedStarts.slice(-1), [20]); 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', () => { test('canonical ASS cues replace live glyph spam for display, history, and immersion', () => {
const immersion: Array<{ text: string; start: number; end: number }> = []; const immersion: Array<{ text: string; start: number; end: number }> = [];
const timing: Array<{ text: string; start: number; end: number }> = []; const timing: Array<{ text: string; start: number; end: number }> = [];
+5 -13
View File
@@ -1,4 +1,3 @@
import { toSubtitleClock } from '../../core/services/subtitle-clock';
import { createSubtitleLineDedupGate } from '../../core/services/subtitle-line-dedup-gate'; import { createSubtitleLineDedupGate } from '../../core/services/subtitle-line-dedup-gate';
import type { MergedToken, SubtitleCue, SubtitleData } from '../../types'; import type { MergedToken, SubtitleCue, SubtitleData } from '../../types';
import { import {
@@ -24,7 +23,6 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
currentSubText?: string; currentSubText?: string;
currentSecondarySubText?: string; currentSecondarySubText?: string;
currentTimePos?: number; currentTimePos?: number;
currentSubDelay?: number;
requestProperty?: (name: string) => Promise<unknown>; requestProperty?: (name: string) => Promise<unknown>;
} | null; } | null;
immersionTracker: { immersionTracker: {
@@ -163,10 +161,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
resolveSubtitleText: (liveText: string) => resolveSubtitleText: (liveText: string) =>
resolvePrimarySubtitleText({ resolvePrimarySubtitleText({
liveText, liveText,
currentTimeSec: toSubtitleClock( currentTimeSec: Number(deps.appState.mpvClient?.currentTimePos),
Number(deps.appState.mpvClient?.currentTimePos),
deps.appState.mpvClient?.currentSubDelay ?? 0,
),
cues: deps.appState.activeParsedSubtitleCues, cues: deps.appState.activeParsedSubtitleCues,
}), }),
getCurrentLiveSubtitleText: () => deps.appState.mpvClient?.currentSubText ?? '', getCurrentLiveSubtitleText: () => deps.appState.mpvClient?.currentSubText ?? '',
@@ -236,9 +231,6 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
hasSubtitleTimingTracker: () => Boolean(deps.appState.subtitleTimingTracker), hasSubtitleTimingTracker: () => Boolean(deps.appState.subtitleTimingTracker),
recordSubtitleTiming: (text: string, start: number, end: number) => { recordSubtitleTiming: (text: string, start: number, end: number) => {
const secondaryText = deps.appState.mpvClient?.currentSecondarySubText || undefined; 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); const canonical = resolveCanonicalSample(text, start);
if (!canonical) { if (!canonical) {
const recordableText = resolveTextForRecording(text, start); const recordableText = resolveTextForRecording(text, start);
@@ -247,8 +239,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
} }
deps.appState.subtitleTimingTracker?.recordSubtitle?.( deps.appState.subtitleTimingTracker?.recordSubtitle?.(
recordableText, recordableText,
start + subDelay, start,
end + subDelay, end,
secondaryText, secondaryText,
); );
return; return;
@@ -261,8 +253,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
recordedTimingCanonicalKeys.add(key); recordedTimingCanonicalKeys.add(key);
deps.appState.subtitleTimingTracker?.recordSubtitle?.( deps.appState.subtitleTimingTracker?.recordSubtitle?.(
cue.text, cue.text,
cue.startTime + subDelay, cue.startTime,
cue.endTime + subDelay, cue.endTime,
secondaryText, 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);
});
+117
View File
@@ -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']);
});
+64
View File
@@ -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
View File
@@ -105,6 +105,7 @@ import type {
OverlayNotificationEventPayload, OverlayNotificationEventPayload,
OverlayNotificationPosition, OverlayNotificationPosition,
ChangelogSnapshot, ChangelogSnapshot,
PluginPanelSnapshot,
MediaTimingReviewActionResult, MediaTimingReviewActionResult,
MediaTimingReviewOpenPayload, MediaTimingReviewOpenPayload,
MediaTimingReviewPreviewPosition, MediaTimingReviewPreviewPosition,
@@ -140,6 +141,20 @@ const onOpenSessionHelpEvent = createQueuedIpcListenerWithPayload<SessionHelpOpe
}), }),
); );
const onOpenChangelogEvent = createQueuedIpcListener(IPC_CHANNELS.event.changelogOpen); 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( const onOpenCharacterDictionaryManagerEvent = createQueuedIpcListener(
IPC_CHANNELS.event.characterDictionaryManagerOpen, IPC_CHANNELS.event.characterDictionaryManagerOpen,
); );
@@ -415,6 +430,17 @@ const electronAPI: ElectronAPI = {
onOpenChangelog: onOpenChangelogEvent, onOpenChangelog: onOpenChangelogEvent,
getChangelogSnapshot: (options?: { refresh?: boolean }): Promise<ChangelogSnapshot> => getChangelogSnapshot: (options?: { refresh?: boolean }): Promise<ChangelogSnapshot> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getChangelogSnapshot, options), 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, onOpenControllerSelect: onOpenControllerSelectEvent,
onOpenControllerDebug: onOpenControllerDebugEvent, onOpenControllerDebug: onOpenControllerDebugEvent,
onOpenJimaku: onOpenJimakuEvent, onOpenJimaku: onOpenJimakuEvent,
@@ -506,8 +532,8 @@ const electronAPI: ElectronAPI = {
mediaId, mediaId,
direction, direction,
), ),
notifyOverlayModalClosed: (modal) => { notifyOverlayModalClosed: (modal, instanceId) => {
ipcRenderer.send(IPC_CHANNELS.command.overlayModalClosed, modal); ipcRenderer.send(IPC_CHANNELS.command.overlayModalClosed, modal, instanceId);
}, },
notifyOverlayModalOpened: (modal) => { notifyOverlayModalOpened: (modal) => {
ipcRenderer.send(IPC_CHANNELS.command.overlayModalOpened, modal); ipcRenderer.send(IPC_CHANNELS.command.overlayModalOpened, modal);
+1
View File
@@ -595,6 +595,7 @@ function createKeyboardHandlerHarness() {
changelogKeydownCount += 1; changelogKeydownCount += 1;
return true; return true;
}, },
handlePluginPanelKeydown: () => false,
openSessionHelpModal: () => {}, openSessionHelpModal: () => {},
openControllerSelectModal: () => { openControllerSelectModal: () => {
openControllerSelectCount += 1; openControllerSelectCount += 1;
+6
View File
@@ -32,6 +32,7 @@ export function createKeyboardHandlers(
handleControllerDebugKeydown: (e: KeyboardEvent) => boolean; handleControllerDebugKeydown: (e: KeyboardEvent) => boolean;
handleSessionHelpKeydown: (e: KeyboardEvent) => boolean; handleSessionHelpKeydown: (e: KeyboardEvent) => boolean;
handleChangelogKeydown: (e: KeyboardEvent) => boolean; handleChangelogKeydown: (e: KeyboardEvent) => boolean;
handlePluginPanelKeydown: (e: KeyboardEvent) => boolean;
openSessionHelpModal: (opening: { openSessionHelpModal: (opening: {
bindingKey: 'KeyH' | 'KeyK'; bindingKey: 'KeyH' | 'KeyK';
fallbackUsed: boolean; fallbackUsed: boolean;
@@ -1172,6 +1173,11 @@ export function createKeyboardHandlers(
return; return;
} }
if (ctx.state.pluginPanelModalOpen) {
options.handlePluginPanelKeydown(e);
return;
}
if (ctx.state.runtimeOptionsModalOpen) { if (ctx.state.runtimeOptionsModalOpen) {
options.handleRuntimeOptionsKeydown(e); options.handleRuntimeOptionsKeydown(e);
return; return;
+9
View File
@@ -1019,6 +1019,15 @@
</div> </div>
</div> </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 id="playlistBrowserModal" class="modal hidden" aria-hidden="true">
<div class="modal-content playlist-browser-content"> <div class="modal-content playlist-browser-content">
<div class="modal-header"> <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' },
]);
});
+204
View File
@@ -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;
}
}
}
+137
View File
@@ -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,
};
}
+6 -4
View File
@@ -3,6 +3,7 @@ import type {
CompiledSessionActionBinding, CompiledSessionActionBinding,
CompiledSessionBinding, CompiledSessionBinding,
SessionActionId, SessionActionId,
SessionActionPayload,
SessionKeyModifier, SessionKeyModifier,
SessionKeySpec, SessionKeySpec,
} from '../../types'; } from '../../types';
@@ -189,10 +190,7 @@ function formatSessionKeySpec(key: SessionKeySpec): string {
.join(' + '); .join(' + ');
} }
function describeSessionAction( function describeSessionAction(actionId: SessionActionId, payload?: SessionActionPayload): string {
actionId: SessionActionId,
payload?: { runtimeOptionId?: string; direction?: 1 | -1 },
): string {
switch (actionId) { switch (actionId) {
case 'toggleStatsOverlay': case 'toggleStatsOverlay':
return 'Toggle stats overlay'; return 'Toggle stats overlay';
@@ -254,6 +252,8 @@ function describeSessionAction(
return `Cycle runtime option ${payload?.runtimeOptionId ?? 'option'} ${ return `Cycle runtime option ${payload?.runtimeOptionId ?? 'option'} ${
payload?.direction === -1 ? 'previous' : 'next' 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'; return 'Playback and navigation';
case 'cycleRuntimeOption': case 'cycleRuntimeOption':
return 'Runtime settings'; return 'Runtime settings';
case 'runPluginAction':
return 'Plugins';
} }
} }
@@ -28,7 +28,6 @@ export async function setup() {
], ],
currentSubtitle: { text: '最初の台詞', startTime: 0, endTime: 1 }, currentSubtitle: { text: '最初の台詞', startTime: 0, endTime: 1 },
currentTimeSec: 0, currentTimeSec: 0,
subDelay: 0,
config: { config: {
...SUBTITLE_DEFAULT_CONFIG.subtitleSidebar, ...SUBTITLE_DEFAULT_CONFIG.subtitleSidebar,
enabled: true, enabled: true,
@@ -102,6 +101,7 @@ export async function setup() {
handleControllerDebugKeydown: () => false, handleControllerDebugKeydown: () => false,
handleSessionHelpKeydown: () => false, handleSessionHelpKeydown: () => false,
handleChangelogKeydown: () => false, handleChangelogKeydown: () => false,
handlePluginPanelKeydown: () => false,
openSessionHelpModal: () => {}, openSessionHelpModal: () => {},
getPlaybackPaused: async () => false, getPlaybackPaused: async () => false,
}); });
Loaded 100 of 123 files, more files were not shown because too many files have changed in this diff. Show more