Compare commits

..
Author SHA1 Message Date
sudacode 203a61a20f chore(release): v0.21.1 2026-10-26 23:27:24 -07:00
sudacode a681bbaf25 test(launcher): gate Linux AppImage env test with early return
Bun's node:test shim ignores the skip option, so the Linux-only test ran
and failed on macOS.
2026-10-26 23:27:24 -07:00
sudacode 6219a81144 feat(stats): redesign expanded session view with density chart (#304) 2026-10-26 23:08:48 -07:00
sudacode cc9af79d6e fix(subtitles): follow mpv sub-delay in mining, sidebar, and navigation (#302) 2026-10-26 22:19:08 -07:00
123 changed files with 1226 additions and 4447 deletions

No files matched your search

+23
View File
@@ -1,5 +1,28 @@
# Changelog
## v0.21.1 (2026-10-26)
### Changed
- **Stats Session View**: Redesigned the expanded session view in the stats dashboard.
- New stat tiles show pace, lines, known-word rate, lookups, and paused time.
- A reading density chart of known and unknown words replaces the cumulative chart.
- Yomitan lookups and mined cards now appear in their own lane below the chart.
### Fixed
- **Subtitle Retiming**: Mining, the subtitle sidebar, subtitle navigation, and replay now use the timing you set with mpv's subtitle delay keys (z/x and sub-step), so mined audio and images match the retimed line.
- **Subtitle Sidebar**: mpv keys and mouse actions, like volume keys and double-click fullscreen, work again while the sidebar is open.
### Docs
- **README**: Refreshed the anime screenshots using the current build.
<details>
<summary>Internal changes</summary>
### Internal
- **Packaging Layout**: Packaging inputs now live under `packaging/`, and all build output lands in `dist/`, with packaged apps in `dist/release/`.
</details>
## v0.21.0 (2026-10-24)
### Added
-4
View File
@@ -102,10 +102,6 @@ A stats dashboard tracks watch time, vocabulary growth, mining throughput, sessi
<td><b>WebSocket</b></td>
<td>Plain subtitle feed plus a dedicated annotated feed for texthooker pages and custom tools</td>
</tr>
<tr>
<td><b>Plugins</b></td>
<td>Load your own JavaScript at startup to react to playback, run timers, add key-bound actions, and show results in an overlay panel. The bundled example explains the current line with Codex. <a href="https://docs.subminer.moe/plugins">Plugin guide</a></td>
</tr>
</table>
<br>
-134
View File
@@ -1,134 +0,0 @@
// 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
@@ -1,5 +0,0 @@
{
"name": "AI Explain",
"version": "1.0.0",
"main": "main.js"
}
-5
View File
@@ -1,5 +0,0 @@
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
@@ -1,4 +0,0 @@
type: docs
area: readme
- Refreshed the README's anime screenshots using the current SubMiner build.
-5
View File
@@ -1,5 +0,0 @@
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/`.
@@ -1,4 +0,0 @@
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,14 +218,6 @@
"overlayPosition": "top-right" // Position for in-overlay notification cards. Values: top-left | top | top-right
}, // Overlay notification display behavior.
// ==========================================
// Plugins
// JavaScript plugins loaded from <config dir>/plugins/<id>/ at startup.
// Plugins run inside SubMiner with full access to your system. Only enable code you trust.
// Restart SubMiner after changing plugins.
// ==========================================
"plugins": {}, // Plugins to load at startup, keyed by directory name under <config dir>/plugins/. Each entry is { "enabled": true, "settings": { ... } }; settings are passed to the plugin as ctx.settings.
// ==========================================
// Keyboard Shortcuts
// Overlay keyboard shortcuts. Set a shortcut to null to disable.
-1
View File
@@ -221,7 +221,6 @@ const sidebar: DefaultTheme.SidebarItem[] = [
{ text: 'AniList', link: '/anilist-integration' },
{ text: 'AniSkip', link: '/aniskip-integration' },
{ text: 'Character Dictionary', link: '/character-dictionary' },
{ text: 'Plugins', link: '/plugins' },
],
},
{
+23
View File
@@ -1,5 +1,28 @@
# Changelog
## v0.21.1 (2026-10-26)
**Changed**
- **Stats Session View**: Redesigned the expanded session view in the stats dashboard.
- New stat tiles show pace, lines, known-word rate, lookups, and paused time.
- A reading density chart of known and unknown words replaces the cumulative chart.
- Yomitan lookups and mined cards now appear in their own lane below the chart.
**Fixed**
- **Subtitle Retiming**: Mining, the subtitle sidebar, subtitle navigation, and replay now use the timing you set with mpv's subtitle delay keys (z/x and sub-step), so mined audio and images match the retimed line.
- **Subtitle Sidebar**: mpv keys and mouse actions, like volume keys and double-click fullscreen, work again while the sidebar is open.
**Docs**
- **README**: Refreshed the anime screenshots using the current build.
<details>
<summary>Internal changes</summary>
**Internal**
- **Packaging Layout**: Packaging inputs now live under `packaging/`, and all build output lands in `dist/`, with packaged apps in `dist/release/`.
</details>
## v0.21.0 (2026-10-24)
**Added**
+1 -10
View File
@@ -106,15 +106,6 @@ Overlay notifications are also kept in a session-only history panel. Toggle it w
Mining and startup status notifications use `ankiConnect.behavior.notificationType` (see [AnkiConnect](#ankiconnect)).
### Plugins
`plugins` enables [plugins](/plugins) from the `plugins/` folder next to this config file. Each key is a plugin folder name. Changes apply after a restart.
| Key | Default | What it does |
| ----------------------- | ------- | -------------------------------------- |
| `plugins.<id>.enabled` | `false` | Load the plugin at startup |
| `plugins.<id>.settings` | `{}` | Passed to the plugin as `ctx.settings` |
### Auto-start overlay
When mpv is started by SubMiner or the `subminer` launcher, the launcher passes these settings to the bundled mpv plugin. There is no separate plugin config file. `mpv.autoStartSubMiner` and `mpv.pauseUntilOverlayReady` (see [MPV launcher](#mpv-launcher)) control the background start and the initial pause.
@@ -277,7 +268,7 @@ Adds a modal for choosing mpv's primary and secondary subtitle tracks. Open it w
- `key` uses `KeyboardEvent.code` names (`Space`, `KeyR`, `ArrowRight`) with optional `Ctrl+`, `Alt+`, `Shift+`, `Meta+`. Mouse buttons are `MBTN_LEFT`, `MBTN_MID`, `MBTN_RIGHT`, `MBTN_BACK`, `MBTN_FORWARD`. Scroll wheel keys are `WHEEL_UP`, `WHEEL_DOWN`, `WHEEL_LEFT`, `WHEEL_RIGHT`.
- `command` is any mpv JSON IPC command array. Set it to `null` to disable a default.
- Commands starting with `__` run inside SubMiner: `__playlist-browser-open`, `__youtube-picker-open`, `__replay-subtitle`, `__play-next-subtitle`, `__runtime-options-open`, `__runtime-option-cycle:<id>[:next|prev]`, and `__plugin-action:<plugin id>/<action>` (see [Plugins](/plugins)).
- 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]`.
- 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
+1 -1
View File
@@ -70,7 +70,7 @@ Charts for watch time, cards, words, and sessions per day or month, running tota
### Sessions
Session history with new-word activity and pause, seek, and card markers. The **↗** button on a row opens that show's detail view.
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.
![Stats Sessions](/screenshots/stats-sessions.png)
-116
View File
@@ -1,116 +0,0 @@
# 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,14 +218,6 @@
"overlayPosition": "top-right" // Position for in-overlay notification cards. Values: top-left | top | top-right
}, // Overlay notification display behavior.
// ==========================================
// Plugins
// JavaScript plugins loaded from <config dir>/plugins/<id>/ at startup.
// Plugins run inside SubMiner with full access to your system. Only enable code you trust.
// Restart SubMiner after changing plugins.
// ==========================================
"plugins": {}, // Plugins to load at startup, keyed by directory name under <config dir>/plugins/. Each entry is { "enabled": true, "settings": { ... } }; settings are passed to the plugin as ctx.settings.
// ==========================================
// Keyboard Shortcuts
// Overlay keyboard shortcuts. Set a shortcut to null to disable.
-2
View File
@@ -138,8 +138,6 @@ The plugin's `v` replaces mpv's own subtitle visibility toggle. When the overlay
}
```
To run a [plugin](/plugins) action, use `["__plugin-action:<plugin id>/<action>"]` as the command.
Mouse button names are `MBTN_LEFT`, `MBTN_MID`, `MBTN_RIGHT`, `MBTN_BACK`, and `MBTN_FORWARD`. Scroll wheel names are `WHEEL_UP`, `WHEEL_DOWN`, `WHEEL_LEFT`, and `WHEEL_RIGHT`. See [keybindings](/configuration#keybindings) and [shortcuts configuration](/configuration#shortcuts-configuration) in the config reference.
## Automatic mpv bindings
-1
View File
@@ -104,7 +104,6 @@ subminer doctor # check dependencies, config, and the mpv soc
subminer generate-subs video.mkv # make Japanese subtitles from the audio
subminer logs -e # export a log ZIP for bug reports
subminer app --setup # reopen first-run setup
subminer app --plugin-action <id>/<name> # run a plugin action
subminer -u # update SubMiner
```
-1
View File
@@ -37,7 +37,6 @@ Update checks and startup launcher migration share a serialized update-state sto
- `subtitle-generation-reference.ts` ranks mpv's loaded text subtitle tracks, excludes signs/songs and forced references, and extracts timing hints with FFmpeg. The overlay and launcher snapshot references only for matching media, including active subtitle delays. Hints guide long-passage cuts with or without VAD; they never limit audio coverage or replace Whisper timestamps.
- `src/main/runtime/subtitle-selection.ts` reads and validates mpv subtitle tracks and applies primary/secondary selections. Its opt-in session shortcut opens the shared overlay modal window, with renderer focus and subtitle suppression handled by the modal registry.
- `src/shared/session-key-sequences.ts` rejects sequence prefixes reserved by single-key actions. The session-binding compiler reserves configured and built-in overlay keys; `src/main/runtime/session-bindings-runtime.ts` adds active mpv bindings and publishes the effective list to both the plugin artifact and the renderer through `session-bindings:changed`. mpv no-op `ignore` bindings do not reserve prefixes.
- `src/core/services/plugins/` owns user plugins: discovery from `<config dir>/plugins/<id>/plugin.json` for ids enabled in `plugins`, the per-plugin `ctx`, guarded callbacks, and `exec` (no shell, AppImage `LD_LIBRARY_PATH` dropped). `main.ts` supplies the host's deps, attaches each new mpv client so plugin listeners survive client replacement, starts plugins from the app-ready path after logging is configured, and unloads them on `before-quit`. Plugins run in the main process with no sandbox. Plugin actions are the `runPluginAction` session action (keybinding command `__plugin-action:<id>/<name>`, CLI `--plugin-action <id>/<name>`); `runAction` waits for an in-progress load so a cold-start CLI action is not dropped. Each plugin gets `<config dir>/plugin-data/<id>/` as `ctx.dataDir`. `src/main/runtime/plugin-window-runtime.ts` owns plugin windows (`ctx.ui.openWindow`): one window per plugin + HTML path, raised above the overlay like the settings windows, destroyed on unload. `src/main/runtime/plugin-panel-runtime.ts` owns the single `plugin-panel` modal: a new panel aborts the previous handle's signal, updates broadcast to both overlay windows keyed by `panelId`, and the latest content is re-sent once the renderer acknowledges the open.
- `src/renderer/` owns overlay rendering and input behavior.
- `src/config/` owns config definitions, defaults, loading, and resolution.
- `src/types/` owns shared cross-runtime contracts via domain entrypoints; `src/types.ts` stays a compatibility barrel.
-1
View File
@@ -34,7 +34,6 @@ Read when: you need to find the owner module for a behavior or test surface
- AniList tracking + character dictionary: `src/core/services/anilist/`, `src/main/runtime/composers/anilist-*`, `src/main/character-dictionary-runtime.ts`, `src/main/character-dictionary-runtime/`
- TMDB live-action metadata: `src/core/services/tmdb/` (client + exact-title resolver), `src/core/services/immersion-tracker/live-action-link.ts` (links an entry to a TMDB title and merges other holders of the same title). The AniList cover-art fetcher calls the resolver as its fallback; `imm_anime.media_kind` marks the result and keeps the entry out of AniList season repair.
- Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*`
- User plugins: `src/core/services/plugins/` (host, `ctx`, `exec`), `src/main/runtime/plugin-panel-runtime.ts` (panel modal), `src/main/runtime/plugin-window-runtime.ts` (plugin windows), `src/renderer/modals/plugin-panel*.ts`; example in `assets/plugins/`
- Window trackers: `src/window-trackers/`
- Stats HTTP app: `src/core/services/stats-server.ts`, with route groups and shared route support
in `src/core/services/stats-server/`
+1 -2
View File
@@ -123,8 +123,7 @@ whenever the behavior does not depend on a specific compositor.
per-window screenshots), mpv over its JSON IPC socket, and app commands through the
normal CLI handoff (`--mine-sentence`, `--toggle-subtitle-sidebar`, ...).
- Scenarios live in `e2e/*.e2e.test.ts` and share one session per file through
`startE2eSession()`. Pass `config` to override settings for a scenario file, and `files`
to write files such as plugins into the isolated config dir before launch.
`startE2eSession()`. Pass `config` to override settings for a scenario file.
- 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`).
+35 -5
View File
@@ -37,10 +37,12 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
// A recording AnkiConnect stand-in: an empty collection with one deck and one
// Lapis-shaped note type. Scenarios assert on `requests`, not on stored notes.
// A recording AnkiConnect stand-in: one deck and one Lapis-shaped note type.
// Added notes (with later `updateNoteFields` writes) are echoed back by `notesInfo`
// so the app can resolve their fields; scenarios still assert on `requests`.
export async function startFakeAnki(): Promise<FakeAnki> {
const requests: AnkiRequest[] = [];
const addedNotes = new Map<number, Record<string, unknown>>();
let nextNoteId = 1000;
const handle = (body: unknown): unknown => {
@@ -59,10 +61,38 @@ export async function startFakeAnki(): Promise<FakeAnki> {
return [FAKE_ANKI_MODEL];
case 'modelFieldNames':
return MODEL_FIELDS;
case 'addNote':
return nextNoteId++;
case 'addNote': {
const noteId = nextNoteId++;
const note = isRecord(params.note) ? params.note : {};
addedNotes.set(noteId, isRecord(note.fields) ? { ...note.fields } : {});
return noteId;
}
case 'updateNoteFields': {
const note = isRecord(params.note) ? params.note : {};
const fields = typeof note.id === 'number' ? addedNotes.get(note.id) : undefined;
if (fields && isRecord(note.fields)) Object.assign(fields, note.fields);
return null;
}
case 'notesInfo': {
// Like AnkiConnect: one entry per requested id, `{}` for unknown notes.
const ids = Array.isArray(params.notes) ? params.notes : [];
return ids.map((id) => {
const fields = typeof id === 'number' ? addedNotes.get(id) : undefined;
if (!fields) return {};
return {
noteId: id,
modelName: FAKE_ANKI_MODEL,
tags: [],
fields: Object.fromEntries(
MODEL_FIELDS.map((name, order) => [
name,
{ value: String(fields[name] ?? ''), order },
]),
),
};
});
}
case 'findNotes':
case 'notesInfo':
return [];
case 'storeMediaFile':
return params.filename ?? null;
-11
View File
@@ -28,8 +28,6 @@ type JsonValue = string | number | boolean | null | JsonValue[] | JsonObject;
export type E2eSessionOptions = {
/** Deep-merged over the harness config, for scenarios that need other settings. */
config?: JsonObject;
/** Files written before launch, keyed by path relative to the config dir (e.g. plugins). */
files?: Record<string, string>;
};
export type AppTarget = {
@@ -195,15 +193,6 @@ export async function startE2eSession(options: E2eSessionOptions = {}): Promise<
const config = mergeConfig(buildConfig(mpvSocketPath, anki.url), options.config ?? {});
fs.writeFileSync(path.join(userDataDir, 'config.jsonc'), JSON.stringify(config, null, 2));
for (const [relativePath, content] of Object.entries(options.files ?? {})) {
const filePath = path.resolve(userDataDir, relativePath);
const inside = path.relative(userDataDir, filePath);
if (!inside || inside.startsWith('..') || path.isAbsolute(inside)) {
throw new Error(`E2E file path must stay inside the config dir: ${relativePath}`);
}
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content);
}
const env: EnvDelta = {
set: {
+87 -4
View File
@@ -91,6 +91,14 @@ async function showCue(cue: (typeof FIXTURE_CUES)[number]): Promise<RenderedToke
});
}
// Card-kind flags are written before media, so wait for the update carrying audio.
function findMediaUpdate(session: E2eSession) {
return session.anki.requests.find(
(request) =>
request.action === 'updateNoteFields' && JSON.stringify(request.params).includes('[sound:'),
);
}
function hasToken(tokens: RenderedToken[], surface: string, headword: string): boolean {
return tokens.some((token) => token.surface === surface && token.headword === headword);
}
@@ -126,10 +134,10 @@ test(
session.anki.requests.length = 0;
await session.runAppCommand('--mine-sentence');
const update = await waitUntil(
() => session.anki.requests.find((request) => request.action === 'updateNoteFields'),
{ description: 'media fields to be written to the mined note', timeoutMs: 30_000 },
);
const update = await waitUntil(() => findMediaUpdate(session), {
description: 'media fields to be written to the mined note',
timeoutMs: 30_000,
});
const added = session.anki.requests.filter((request) => request.action === 'addNote');
assert.equal(added.length, 1);
@@ -156,6 +164,38 @@ test(
},
);
test(
'mining a line retimed with sub-delay clips it where mpv plays it',
{ timeout: TEST_TIMEOUT_MS },
async () => {
const { session, overlay } = await useSession();
const cue = FIXTURE_CUES[1];
const delay = 2;
await session.mpv.command('set_property', 'sub-delay', delay);
try {
// Past the cue's own end, so the line is only on screen because of the delay.
await session.mpv.command('seek', cue.end + delay / 2, 'absolute+exact');
await overlay.waitFor(
`document.getElementById('subtitleRoot').textContent === ${JSON.stringify(cue.text)}`,
{ description: 'overlay to show the delayed line' },
);
session.anki.requests.length = 0;
await session.runAppCommand('--mine-sentence');
await waitUntil(() => findMediaUpdate(session), {
description: 'media fields to be written to the mined note',
timeoutMs: 30_000,
});
// MiscInfo stamps the clip start: the cue's start on the video clock.
const written = JSON.stringify(session.anki.requests);
assert.ok(written.includes(`(00:00:0${cue.start + delay})`), written);
} finally {
await session.mpv.command('set_property', 'sub-delay', 0);
}
},
);
test(
'open subtitle sidebar lists the parsed cues and follows playback',
{ timeout: TEST_TIMEOUT_MS },
@@ -184,6 +224,49 @@ test(
},
);
test(
'subtitle sidebar follows and seeks a sub-delay retimed track',
{ timeout: TEST_TIMEOUT_MS },
async () => {
const { session, overlay } = await useSession();
const delay = 2;
await session.mpv.command('set_property', 'sub-delay', delay);
try {
// At 9s cue 2 starts on the file's clock, but mpv is still showing cue 1.
await session.mpv.command('seek', FIXTURE_CUES[2].start, 'absolute+exact');
await overlay.waitFor(
`document.getElementById('subtitleRoot').textContent === ${JSON.stringify(FIXTURE_CUES[1].text)}`,
{ description: 'overlay to show the delayed line' },
);
await session.runAppCommand('--toggle-subtitle-sidebar');
await overlay.waitFor(
`document.querySelector('#subtitleSidebarList .subtitle-sidebar-item.active')?.dataset.index === '1'`,
{ description: 'sidebar to highlight the delayed line' },
);
await overlay.evaluate(
`document.querySelector('#subtitleSidebarList .subtitle-sidebar-item[data-index="2"]').click()`,
);
const target = FIXTURE_CUES[2].start + delay;
await waitUntil(
async () => {
const time = Number(await session.mpv.command('get_property', 'time-pos'));
return time >= target && time < target + 0.5;
},
{ description: `click to seek to cue 2 at ${target}s on the video clock` },
);
await session.runAppCommand('--toggle-subtitle-sidebar');
await overlay.waitFor(
`document.getElementById('subtitleSidebarModal').classList.contains('hidden')`,
{ description: 'sidebar to close' },
);
} finally {
await session.mpv.command('set_property', 'sub-delay', 0);
}
},
);
// Runs last: it leaves the lookup popup open over the overlay.
test(
'holding Shift over a word opens the Yomitan popup with its dictionary entry',
-217
View File
@@ -1,217 +0,0 @@
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' },
);
});
+2 -1
View File
@@ -174,8 +174,9 @@ test('runAppCommandCaptureOutput strips ELECTRON_RUN_AS_NODE from app child env'
test(
'runAppCommandCaptureOutput transports Linux AppImage args through environment',
{ skip: process.platform !== 'linux' },
async () => {
// Bun's node:test shim ignores the `skip` option, so gate the platform here.
if (process.platform !== 'linux') return;
await withTempCase(({ dir }) => {
const appPath = writeFakeApp(
dir,
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "subminer",
"productName": "SubMiner",
"desktopName": "SubMiner.desktop",
"version": "0.21.0",
"version": "0.21.1",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5",
"main": "dist/main-entry.js",
+8 -36
View File
@@ -1,47 +1,19 @@
## Highlights
### Added
- **Hachidori Dictionary Backend**: Bundled Hachidori backend as an alternative to Yomitan. Select it with `dictionaryBackend` and restart SubMiner.
- Works with subtitle scanning, popups, frequency highlighting, character dictionaries, Anki mining, and stats dashboard mining.
- First-run setup builds Hachidori's Anki template from your existing SubMiner deck, tags, and field mappings.
- Can link an external Hachidori app, browser, or Docker host while Anki mining stays in SubMiner.
- The tray and dictionary-settings shortcut open the selected backend's settings. `--hachidori` and `--yomitan` open a specific one.
- **Jellyfin Subtitle Generation**: Japanese subtitle generation now works while playing Jellyfin episodes, with both direct-play and transcoded playback.
### Changed
- **Stats Session View**: The expanded session view in the stats dashboard has been redesigned. It shows stat tiles for pace, lines, known-word rate, lookups, and paused time, plus a reading density chart of known and unknown words with lookup and card-mined markers.
### Fixed
- **Media Timing Review**: Opens on the first try and keeps playback paused while open, even when a dictionary popup closes.
- **Jellyfin Terminal Playback**:
- `subminer jellyfin -p` lists libraries when SubMiner is already running, and picker image previews work again.
- Real errors, like an expired session or an unreachable server, now appear instead of an empty list.
- Watch progress and played status reach Jellyfin even when cast discovery is off.
- When it starts SubMiner itself, SubMiner now shows a tray icon and quits when mpv closes.
- The rofi search step is now a compact input box.
- **macOS Casting**: Jellyfin casting finds mpv when SubMiner is opened from Finder, the Dock, or Spotlight. `mpv.executablePath` and `SUBMINER_MPV_PATH` are now honored on macOS and Linux.
- **macOS Launcher PATH**: The command-line launcher no longer shows "Not on PATH" when SubMiner is opened from Finder or the Dock.
- **Keyboard-Only Mode**: `h`, `l`, and the arrow keys type normally in modals and text fields instead of seeking mpv. Toggling the mode inside a modal no longer turns on a hidden keyboard-only mode.
- **Halfwidth Katakana**: Halfwidth katakana in subtitles now displays as fullwidth, including in mined cards and the stats dashboard. Stats sentence search matches katakana in either width.
- **Subtitle Annotations**: Lines shown while two cues overlap now get annotations right away. Prefetching no longer restarts during startup.
- **Subtitle Sidebar**: The open sidebar does far less work during playback.
- **Hyprland and Sway**: A slow or hung `hyprctl` or `swaymsg` no longer freezes the app.
- **Stats Mining**: Cards mined from the stats dashboard keep the selected history line and media.
- **Security Updates**: Updated Electron, undici, and follow-redirects to fix new security advisories.
- **Subtitle Delay**: Retiming subtitles in mpv (z/x or sub-step) now carries through SubMiner. Mined card audio, images, and timestamps match the retimed line, and so do the subtitle sidebar, subtitle navigation, and replay.
- **Subtitle Sidebar**: mpv keys and mouse buttons, such as volume keys and double-click fullscreen, work again while the sidebar is open.
### Docs
- **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.
- **README**: Refreshed the anime screenshots using the current build.
## What's Changed
- feat(dictionary): add Hachidori backend support by @ksyasuda in #266
- fix(jellyfin): make terminal play work with a running app and report progress by @ksyasuda in #288
- fix(overlay): stop Hyprland/Sway tracker polls from blocking the main thread by @ksyasuda in #289
- fix(overlay): poll cheap playback state in the open subtitle sidebar by @ksyasuda in #290
- fix(overlay): keep keyboard mode from swallowing modal and text-field keys by @ksyasuda in #291
- refactor: remove dead code and enable noUnusedLocals by @ksyasuda in #292
- feat(subtitles): generate Japanese subtitles during Jellyfin playback by @ksyasuda in #294
- fix(subtitles): widen halfwidth katakana where subtitle text enters the app by @ksyasuda in #295
- fix(jellyfin): find mpv when SubMiner is launched from Finder by @ksyasuda in #298
- fix(overlay): forward mpv input while subtitle sidebar is open by @ksyasuda in #300
- fix(subtitles): follow mpv sub-delay in mining, sidebar, and navigation by @ksyasuda in #302
- feat(stats): redesign expanded session view with density chart by @ksyasuda in #304
## Installation
+7 -6
View File
@@ -45,6 +45,7 @@ import {
} from './anki-field-config';
import { createLogger } from './logger';
import { captureLiveSubtitleMiningContext } from './core/services/mining';
import { shiftSubtitleRangeToVideoClock } from './core/services/subtitle-clock';
import {
createUiFeedbackState,
beginUpdateProgress,
@@ -1029,10 +1030,10 @@ export class AnkiIntegration {
Number.isFinite(this.mpvClient.currentSubEnd) &&
this.mpvClient.currentSubEnd > this.mpvClient.currentSubStart
) {
return {
startTime: this.mpvClient.currentSubStart,
endTime: this.mpvClient.currentSubEnd,
};
return shiftSubtitleRangeToVideoClock(
{ startTime: this.mpvClient.currentSubStart, endTime: this.mpvClient.currentSubEnd },
this.mpvClient.currentSubDelay,
);
}
const currentTime = this.mpvClient.currentTimePos || 0;
@@ -1672,7 +1673,7 @@ export class AnkiIntegration {
if (this.config.fields?.miscInfo) {
result.miscInfoValue = this.formatMiscInfoPattern(
audioFilename,
this.mpvClient.currentSubStart,
this.mpvClient.currentSubStart + this.mpvClient.currentSubDelay,
);
}
}
@@ -1695,7 +1696,7 @@ export class AnkiIntegration {
if (this.config.fields?.miscInfo && !result.miscInfoValue) {
result.miscInfoValue = this.formatMiscInfoPattern(
imageFilename,
this.mpvClient.currentSubStart,
this.mpvClient.currentSubStart + this.mpvClient.currentSubDelay,
);
}
}
+6 -3
View File
@@ -10,6 +10,7 @@ import {
type MediaTimingReviewRequest,
type WordCardKind,
} from '../types/anki';
import { shiftSubtitleRangeToVideoClock } from '../core/services/subtitle-clock';
import { createLogger } from '../logger';
import type { MediaInput } from '../media-input';
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
@@ -428,10 +429,12 @@ export class CardCreationService {
return;
}
let startTime = mpvClient.currentSubStart;
let endTime = mpvClient.currentSubEnd;
let { startTime, endTime } = shiftSubtitleRangeToVideoClock(
{ startTime: mpvClient.currentSubStart, endTime: mpvClient.currentSubEnd },
mpvClient.currentSubDelay,
);
if (startTime === undefined || endTime === undefined) {
if (mpvClient.currentSubStart === undefined || mpvClient.currentSubEnd === undefined) {
const currentTime = mpvClient.currentTimePos || 0;
const fallback = this.deps.getFallbackDurationSeconds() / 2;
startTime = currentTime - fallback;
-8
View File
@@ -340,14 +340,6 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
assert.equal(shouldStartApp(sessionAction), true);
assert.equal(commandNeedsOverlayRuntime(sessionAction), true);
const pluginAction = parseArgs(['--plugin-action', 'anime/open']);
assert.equal(pluginAction.pluginAction, 'anime/open');
assert.equal(parseArgs(['--plugin-action=anime/open']).pluginAction, 'anime/open');
assert.equal(hasExplicitCommand(pluginAction), true);
assert.equal(shouldStartApp(pluginAction), true);
// The plugin decides what it needs; a window-only action must not force the overlay up.
assert.equal(commandNeedsOverlayRuntime(pluginAction), false);
const toggleStatsOverlayRuntime = parseArgs(['--toggle-stats-overlay']);
assert.equal(commandNeedsOverlayRuntime(toggleStatsOverlayRuntime), true);
-12
View File
@@ -48,8 +48,6 @@ export interface CliArgs {
cycleRuntimeOptionId?: string;
cycleRuntimeOptionDirection?: 1 | -1;
sessionAction?: SessionActionDispatchRequest;
/** `<plugin id>/<action name>` to run, like a `__plugin-action:` keybinding. */
pluginAction?: string;
copySubtitleCount?: number;
mineSentenceCount?: number;
anilistStatus: boolean;
@@ -340,12 +338,6 @@ export function parseArgs(argv: string[]): CliArgs {
} else if (arg === '--session-action') {
const parsed = parseSessionAction(readValue(argv[i + 1]));
if (parsed) args.sessionAction = parsed;
} else if (arg.startsWith('--plugin-action=')) {
const value = arg.slice('--plugin-action='.length).trim();
if (value) args.pluginAction = value;
} else if (arg === '--plugin-action') {
const value = readValue(argv[i + 1])?.trim();
if (value) args.pluginAction = value;
} else if (arg.startsWith('--copy-subtitle-count=')) {
const value = Number(arg.split('=', 2)[1]);
if (Number.isInteger(value) && value > 0) args.copySubtitleCount = value;
@@ -609,7 +601,6 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.playbackFeedback !== undefined ||
args.cycleRuntimeOptionId !== undefined ||
args.sessionAction !== undefined ||
args.pluginAction !== undefined ||
args.copySubtitleCount !== undefined ||
args.mineSentenceCount !== undefined ||
args.anilistStatus ||
@@ -691,7 +682,6 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
args.playbackFeedback === undefined &&
args.cycleRuntimeOptionId === undefined &&
args.sessionAction === undefined &&
args.pluginAction === undefined &&
args.copySubtitleCount === undefined &&
args.mineSentenceCount === undefined &&
!args.anilistStatus &&
@@ -762,7 +752,6 @@ export function shouldStartApp(args: CliArgs): boolean {
args.playbackFeedback !== undefined ||
args.cycleRuntimeOptionId !== undefined ||
args.sessionAction !== undefined ||
args.pluginAction !== undefined ||
args.copySubtitleCount !== undefined ||
args.mineSentenceCount !== undefined ||
args.dictionary ||
@@ -830,7 +819,6 @@ export function shouldRunDictionarySettingsOnlyStartup(args: CliArgs): boolean {
args.playbackFeedback === undefined &&
args.cycleRuntimeOptionId === undefined &&
args.sessionAction === undefined &&
args.pluginAction === undefined &&
args.copySubtitleCount === undefined &&
args.mineSentenceCount === undefined &&
!args.anilistStatus &&
-1
View File
@@ -46,7 +46,6 @@ ${B}Mining${R}
--open-session-help Open session help modal
--open-controller-select Open controller select modal
--open-controller-debug Open controller debug modal
--plugin-action ${D}<id>/<name>${R} Run a plugin action
${B}AniList${R}
--anilist-setup Open AniList authentication flow
-2
View File
@@ -37,7 +37,6 @@ const {
startupWarmups,
updates,
notifications,
plugins,
auto_start_overlay,
} = CORE_DEFAULT_CONFIG;
const {
@@ -75,7 +74,6 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
startupWarmups,
updates,
notifications,
plugins,
subtitleStyle,
subtitleSidebar,
auto_start_overlay,
-2
View File
@@ -17,7 +17,6 @@ export const CORE_DEFAULT_CONFIG: Pick<
| 'startupWarmups'
| 'updates'
| 'notifications'
| 'plugins'
| 'auto_start_overlay'
> = {
dictionaryBackend: 'yomitan',
@@ -146,6 +145,5 @@ export const CORE_DEFAULT_CONFIG: Pick<
notifications: {
overlayPosition: 'top-right',
},
plugins: {},
auto_start_overlay: true,
};
-7
View File
@@ -546,13 +546,6 @@ export function buildCoreConfigOptionRegistry(
defaultValue: defaultConfig.notifications.overlayPosition,
description: 'Position for in-overlay notification cards.',
},
{
path: 'plugins',
kind: 'object',
defaultValue: defaultConfig.plugins,
description:
'Plugins to load at startup, keyed by directory name under <config dir>/plugins/. Each entry is { "enabled": true, "settings": { ... } }; settings are passed to the plugin as ctx.settings.',
},
{
path: 'shortcuts.multiCopyTimeoutMs',
kind: 'number',
-1
View File
@@ -57,7 +57,6 @@ export const SPECIAL_COMMANDS = {
ANIMETOSHO_OPEN: '__animetosho-open',
TSUKIHIME_OPEN: '__tsukihime-open',
RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
PLUGIN_ACTION_PREFIX: '__plugin-action:',
REPLAY_SUBTITLE: '__replay-subtitle',
PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
YOUTUBE_PICKER_OPEN: '__youtube-picker-open',
@@ -92,15 +92,6 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
notes: ['Hot-reload: position changes apply to the next overlay notification.'],
key: 'notifications',
},
{
title: 'Plugins',
description: [
'JavaScript plugins loaded from <config dir>/plugins/<id>/ at startup.',
'Plugins run inside SubMiner with full access to your system. Only enable code you trust.',
],
notes: ['Restart SubMiner after changing plugins.'],
key: 'plugins',
},
{
title: 'Keyboard Shortcuts',
description: ['Overlay keyboard shortcuts. Set a shortcut to null to disable.'],
-2
View File
@@ -4,7 +4,6 @@ import { createResolveContext } from './resolve/context';
import { applyCoreDomainConfig } from './resolve/core-domains';
import { applyImmersionTrackingConfig } from './resolve/immersion-tracking';
import { applyIntegrationConfig } from './resolve/integrations';
import { applyPluginsConfig } from './resolve/plugins';
import { applyStatsConfig } from './resolve/stats';
import { applySubtitleDomainConfig } from './resolve/subtitle-domains';
import { applyTopLevelConfig } from './resolve/top-level';
@@ -16,7 +15,6 @@ const APPLY_RESOLVE_STEPS = [
applyIntegrationConfig,
applyImmersionTrackingConfig,
applyStatsConfig,
applyPluginsConfig,
applyAnkiConnectResolution,
] as const;
-33
View File
@@ -1,33 +0,0 @@
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
@@ -1,30 +0,0 @@
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,7 +81,6 @@ const JSON_OBJECT_FIELDS = new Set([
'keybindings',
'controller.bindings',
'controller.profiles',
'plugins',
'ankiConnect.knownWords.decks',
'subtitleStyle.css',
'subtitleStyle.secondary.css',
@@ -442,9 +441,6 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
if (path.startsWith('notifications.')) {
return { category: 'behavior', section: 'Notifications' };
}
if (path === 'plugins') {
return { category: 'advanced', section: 'Plugins' };
}
if (path === 'mpv.aniskipButtonKey') {
return { category: 'input', section: 'Overlay Shortcuts' };
}
-5
View File
@@ -13,7 +13,6 @@ function makeDeps(overrides: Partial<AppReadyRuntimeDeps> = {}) {
loadSubtitlePosition: () => calls.push('loadSubtitlePosition'),
resolveKeybindings: () => calls.push('resolveKeybindings'),
createMpvClient: () => calls.push('createMpvClient'),
startPlugins: () => calls.push('startPlugins'),
reloadConfig: () => calls.push('reloadConfig'),
getResolvedConfig: () => ({
websocket: { enabled: 'auto' },
@@ -163,7 +162,6 @@ test('runAppReadyRuntime skips heavy startup when shouldSkipHeavyStartup returns
assert.equal(calls.includes('loadSubtitlePosition'), false);
assert.equal(calls.includes('resolveKeybindings'), false);
assert.equal(calls.includes('createMpvClient'), false);
assert.equal(calls.includes('startPlugins'), false);
assert.equal(calls.includes('initRuntimeOptionsManager'), false);
assert.equal(calls.includes('createImmersionTracker'), false);
assert.equal(calls.includes('startJellyfinRemoteSession'), false);
@@ -328,9 +326,6 @@ test('runAppReadyRuntime starts background warmups after overlay startup', async
assert.ok(calls.indexOf('loadSubtitlePosition') < calls.indexOf('startBackgroundWarmups'));
assert.ok(calls.indexOf('createMpvClient') < calls.indexOf('startBackgroundWarmups'));
assert.ok(calls.includes('setLogLevel:info:config'));
assert.ok(calls.indexOf('setLogLevel:info:config') < calls.indexOf('startPlugins'));
assert.ok(calls.indexOf('startPlugins') < calls.indexOf('startBackgroundWarmups'));
assert.ok(calls.indexOf('initializeOverlayRuntime') < calls.indexOf('startBackgroundWarmups'));
assert.ok(calls.indexOf('startBackgroundWarmups') < calls.indexOf('handleInitialArgs'));
});
-17
View File
@@ -737,23 +737,6 @@ test('handleCliCommand dispatches generic session action payloads', async () =>
});
});
test('handleCliCommand runs plugin actions through the session action dispatcher', async () => {
let request: unknown = null;
const { deps } = createDeps({
dispatchSessionAction: async (nextRequest) => {
request = nextRequest;
},
});
handleCliCommand(makeArgs({ pluginAction: 'anime/open' }), 'initial', deps);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(request, {
actionId: 'runPluginAction',
payload: { pluginActionId: 'anime/open' },
});
});
test('handleCliCommand dispatches mark-watched session action', async () => {
let request: unknown = null;
const { deps } = createDeps({
-6
View File
@@ -422,12 +422,6 @@ export function handleCliCommand(
`sessionAction:${args.sessionAction.actionId}`,
'Session action failed',
);
} else if (args.pluginAction) {
dispatchCliSessionAction(
{ actionId: 'runPluginAction', payload: { pluginActionId: args.pluginAction } },
`pluginAction:${args.pluginAction}`,
'Plugin action failed',
);
} else if (args.toggle || args.toggleVisibleOverlay) {
deps.toggleVisibleOverlay();
} else if (args.togglePrimarySubtitleBar) {
+1
View File
@@ -55,6 +55,7 @@ export {
subtitleCueListSeekTime,
subtitleCueSeekTime,
} from './subtitle-cue-navigation';
export { shiftSubtitleRangeToVideoClock, toSubtitleClock, toVideoClock } from './subtitle-clock';
export { createFrequencyDictionaryLookup } from './frequency-dictionary';
export { createJlptVocabularyLookup } from './jlpt-vocab';
export {
+4 -5
View File
@@ -89,6 +89,7 @@ function createSubtitleSidebarSnapshotFixture(): SubtitleSidebarSnapshot {
cuesRevision: 1,
cues: [],
currentTimeSec: null,
subDelay: 0,
currentSubtitle: { text: '', startTime: null, endTime: null },
config: {
enabled: false,
@@ -575,8 +576,8 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
const openedModals: unknown[] = [];
registerIpcHandlers(
{
onOverlayModalClosed: (modal, _senderWindow, instanceId) => {
closedModals.push(instanceId === undefined ? modal : [modal, instanceId]);
onOverlayModalClosed: (modal) => {
closedModals.push(modal);
},
onOverlayModalOpened: (modal) => {
openedModals.push(modal);
@@ -637,9 +638,7 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'not-a-modal');
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'subsync');
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'kiku');
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']);
assert.deepEqual(closedModals, ['subsync', 'kiku']);
handlers.on.get(IPC_CHANNELS.command.overlayModalOpened)!({}, 'bad');
handlers.on.get(IPC_CHANNELS.command.overlayModalOpened)!({}, 'subsync');
+3 -12
View File
@@ -50,7 +50,6 @@ export interface IpcServiceDeps {
onOverlayModalClosed: (
modal: OverlayHostedModal,
senderWindow: ElectronBrowserWindow | null,
instanceId?: number,
) => void;
onOverlayModalOpened?: (
modal: OverlayHostedModal,
@@ -318,7 +317,6 @@ export interface IpcDepsRuntimeOptions {
onOverlayModalClosed: (
modal: OverlayHostedModal,
senderWindow: ElectronBrowserWindow | null,
instanceId?: number,
) => void;
onOverlayModalOpened?: (
modal: OverlayHostedModal,
@@ -529,20 +527,13 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
},
);
ipc.on(
IPC_CHANNELS.command.overlayModalClosed,
(event: unknown, modal: unknown, instanceId: unknown) => {
ipc.on(IPC_CHANNELS.command.overlayModalClosed, (event: unknown, modal: unknown) => {
const parsedModal = parseOverlayHostedModal(modal);
if (!parsedModal) return;
const senderWindow =
electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null;
deps.onOverlayModalClosed(
parsedModal,
senderWindow,
Number.isInteger(instanceId) ? (instanceId as number) : undefined,
);
},
);
deps.onOverlayModalClosed(parsedModal, senderWindow);
});
ipc.on(IPC_CHANNELS.command.overlayModalOpened, (event: unknown, modal: unknown) => {
const parsedModal = parseOverlayHostedModal(modal);
if (!parsedModal) return;
+19 -1
View File
@@ -77,6 +77,7 @@ test('mineSentenceCard handles missing integration and disconnected mpv', async
currentSubText: 'line',
currentSubStart: 1,
currentSubEnd: 2,
currentSubDelay: 0,
},
showMpvOsd: (text) => osd.push(text),
}),
@@ -109,6 +110,7 @@ test('mineSentenceCard creates sentence card from mpv subtitle state', async ()
currentSubText: 'subtitle line',
currentSubStart: 10,
currentSubEnd: 12,
currentSubDelay: 0,
currentSecondarySubText: 'secondary line',
},
showMpvOsd: () => {},
@@ -148,6 +150,7 @@ test('mineSentenceCard prefers a canonical primary subtitle snapshot', async ()
currentSubText: '今今今手手手',
currentSubStart: 11.4,
currentSubEnd: 11.8,
currentSubDelay: 0,
currentSecondarySubText: 'English subtitle',
},
primarySubtitle: {
@@ -187,6 +190,7 @@ test('mineSentenceCard uses normalized secondary subtitle state instead of raw m
currentSubText: '日本語字幕',
currentSubStart: 10,
currentSubEnd: 12,
currentSubDelay: 0,
currentSecondarySubText: 'Your\nmosaic',
requestProperty: async () => {
requestedRawSecondaryText = true;
@@ -218,6 +222,7 @@ test('mineSentenceCard omits normalized secondary text that matches the primary
currentSubText: '日本語字幕',
currentSubStart: 10,
currentSubEnd: 12,
currentSubDelay: 0,
currentSecondarySubText: '日本語字幕',
},
showMpvOsd: () => {},
@@ -396,7 +401,7 @@ test('handleMineSentenceDigit joins per-entry secondary subtitles when available
test('captureLiveSubtitleMiningContext snapshots the current line and timings', () => {
const context = captureLiveSubtitleMiningContext(
{ currentSubText: ' 食べる ', currentSubStart: 12.5, currentSubEnd: 15.25 },
{ currentSubText: ' 食べる ', currentSubStart: 12.5, currentSubEnd: 15.25, currentSubDelay: 0 },
() => 1234,
);
@@ -409,6 +414,16 @@ test('captureLiveSubtitleMiningContext snapshots the current line and timings',
});
});
test('captureLiveSubtitleMiningContext moves sub-delay retimed lines onto the video clock', () => {
const context = captureLiveSubtitleMiningContext(
{ currentSubText: '食べる', currentSubStart: 5, currentSubEnd: 8, currentSubDelay: -1.5 },
() => 1234,
);
assert.equal(context?.startTime, 3.5);
assert.equal(context?.endTime, 6.5);
});
test('captureLiveSubtitleMiningContext rejects missing client, empty text and bad timings', () => {
assert.equal(captureLiveSubtitleMiningContext(null), null);
assert.equal(
@@ -416,6 +431,7 @@ test('captureLiveSubtitleMiningContext rejects missing client, empty text and ba
currentSubText: ' ',
currentSubStart: 1,
currentSubEnd: 2,
currentSubDelay: 0,
}),
null,
);
@@ -424,6 +440,7 @@ test('captureLiveSubtitleMiningContext rejects missing client, empty text and ba
currentSubText: 'line',
currentSubStart: 5,
currentSubEnd: 5,
currentSubDelay: 0,
}),
null,
);
@@ -432,6 +449,7 @@ test('captureLiveSubtitleMiningContext rejects missing client, empty text and ba
currentSubText: 'line',
currentSubStart: NaN,
currentSubEnd: 2,
currentSubDelay: 0,
}),
null,
);
+19 -4
View File
@@ -1,5 +1,6 @@
import type { SubtitleTimingBlock } from '../../subtitle-timing-tracker';
import type { SubtitleMiningContext } from '../../types/subtitle';
import { shiftSubtitleRangeToVideoClock } from './subtitle-clock';
interface SubtitleTimingTrackerLike {
getRecentBlocks: (count: number) => string[];
@@ -25,6 +26,7 @@ interface MpvClientLike {
currentSubText: string;
currentSubStart: number;
currentSubEnd: number;
currentSubDelay: number;
currentSecondarySubText?: string;
requestProperty?: (name: string) => Promise<unknown>;
}
@@ -62,7 +64,10 @@ export function handleMultiCopyDigit(
* line is on screen by then; callers capture here at mining time instead.
*/
export function captureLiveSubtitleMiningContext(
client: Pick<MpvClientLike, 'currentSubText' | 'currentSubStart' | 'currentSubEnd'> | null,
client: Pick<
MpvClientLike,
'currentSubText' | 'currentSubStart' | 'currentSubEnd' | 'currentSubDelay'
> | null,
now: () => number = Date.now,
): SubtitleMiningContext | null {
if (!client) {
@@ -73,7 +78,10 @@ export function captureLiveSubtitleMiningContext(
if (!text || !Number.isFinite(startTime) || !Number.isFinite(endTime) || endTime <= startTime) {
return null;
}
return { source: 'overlay', text, startTime, endTime, capturedAtMs: now() };
return shiftSubtitleRangeToVideoClock<SubtitleMiningContext>(
{ source: 'overlay', text, startTime, endTime, capturedAtMs: now() },
client.currentSubDelay,
);
}
export function copyCurrentSubtitle(deps: {
@@ -167,6 +175,7 @@ export async function markLastCardAsAudioCard(deps: {
export async function mineSentenceCard(deps: {
ankiIntegration: AnkiIntegrationLike | null;
mpvClient: MpvClientLike | null;
/** Already on the video clock, like every `SubtitleMiningContext`. */
primarySubtitle?: Pick<SubtitleMiningContext, 'text' | 'startTime' | 'endTime'>;
showMpvOsd: (text: string) => void;
}): Promise<boolean> {
@@ -185,10 +194,16 @@ export async function mineSentenceCard(deps: {
}
const secondarySubText = getCurrentSecondarySubTextForSentenceCard(mpvClient, primaryText);
const range =
deps.primarySubtitle ??
shiftSubtitleRangeToVideoClock(
{ startTime: mpvClient.currentSubStart, endTime: mpvClient.currentSubEnd },
mpvClient.currentSubDelay,
);
return await anki.createSentenceCard(
primaryText,
deps.primarySubtitle?.startTime ?? mpvClient.currentSubStart,
deps.primarySubtitle?.endTime ?? mpvClient.currentSubEnd,
range.startTime,
range.endTime,
secondarySubText,
);
}
+1
View File
@@ -38,6 +38,7 @@ const MPV_SUBTITLE_PROPERTY_OBSERVATIONS: string[] = [
'path',
'sub-start',
'sub-end',
'sub-delay',
'time-pos',
'secondary-sub-text',
'aid',
+41 -16
View File
@@ -73,6 +73,8 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
getCurrentSubStart: () => 0,
setCurrentSubEnd: () => {},
getCurrentSubEnd: () => 0,
setCurrentSubDelay: () => {},
getCurrentSubDelay: () => 0,
emitMediaPathChange: (payload) => {
state.mediaPath = payload.path;
},
@@ -131,6 +133,22 @@ test('dispatchMpvProtocolMessage emits subtitle text on property change', async
assert.deepEqual(state.events, [{ text: '字幕', isOverlayVisible: false }]);
});
test('dispatchMpvProtocolMessage tracks sub-delay retiming', async () => {
const delays: number[] = [];
const { deps } = createDeps({ setCurrentSubDelay: (value) => delays.push(value) });
await dispatchMpvProtocolMessage(
{ event: 'property-change', name: 'sub-delay', data: 1.5 },
deps,
);
await dispatchMpvProtocolMessage(
{ event: 'property-change', name: 'sub-delay', data: null },
deps,
);
assert.deepEqual(delays, [1.5, 0]);
});
test('dispatchMpvProtocolMessage widens halfwidth katakana in subtitle text', async () => {
const { deps, state } = createDeps();
@@ -379,6 +397,29 @@ test('dispatchMpvProtocolMessage pauses on sub-end when pendingPauseAtSubEnd is
});
});
test('dispatchMpvProtocolMessage pauses at the sub-delay retimed sub-end', async () => {
let pauseAtTime: number | null = null;
let subDelay = 1.5;
const { deps } = createDeps({
getPendingPauseAtSubEnd: () => true,
getCurrentSubDelay: () => subDelay,
setCurrentSubDelay: (next) => {
subDelay = next;
},
getPauseAtTime: () => pauseAtTime,
setPauseAtTime: (next) => {
pauseAtTime = next;
},
});
await dispatchMpvProtocolMessage({ event: 'property-change', name: 'sub-end', data: 42 }, deps);
assert.equal(pauseAtTime, 43.5);
// Retiming before the replay reaches its end moves the pause with the line.
await dispatchMpvProtocolMessage({ event: 'property-change', name: 'sub-delay', data: 2 }, deps);
assert.equal(pauseAtTime, 44);
});
test('dispatchMpvProtocolMessage updates current time before emitting time-pos change', async () => {
const calls: string[] = [];
let currentTimePos = 0;
@@ -445,19 +486,3 @@ test('dispatchMpvProtocolMessage emits client-message string args', async () =>
assert.deepEqual(received, [{ args: ['subminer-skip-intro', 'extra'] }]);
});
test('dispatchMpvProtocolMessage emits end-file with reason, entry id, and error', async () => {
const received: unknown[] = [];
const { deps } = createDeps({ emitEndFile: (payload) => received.push(payload) });
await dispatchMpvProtocolMessage(
{ event: 'end-file', reason: 'error', playlist_entry_id: 3, file_error: 'loading failed' },
deps,
);
await dispatchMpvProtocolMessage({ event: 'end-file', reason: 'something-new' }, deps);
assert.deepEqual(received, [
{ reason: 'error', playlistEntryId: 3, error: 'loading failed' },
{ reason: 'unknown', playlistEntryId: null, error: null },
]);
});
+15 -26
View File
@@ -1,6 +1,7 @@
import { MpvSubtitleRenderMetrics } from '../../types';
import { sanitizeMediaTitle } from '../../shared/media-identity';
import { widenHalfwidthKana } from '../utils/halfwidth-kana';
import { toVideoClock } from './subtitle-clock';
export type MpvMessage = {
event?: string;
@@ -9,30 +10,8 @@ export type MpvMessage = {
args?: unknown;
request_id?: number;
error?: string;
reason?: unknown;
playlist_entry_id?: unknown;
file_error?: unknown;
};
const MPV_END_FILE_REASONS = ['eof', 'stop', 'quit', 'error', 'redirect'] as const;
export type MpvEndFileReason = (typeof MPV_END_FILE_REASONS)[number] | 'unknown';
/** mpv's `end-file` event: why the current file stopped, and mpv's error text when it failed. */
export interface MpvEndFileEvent {
reason: MpvEndFileReason;
playlistEntryId: number | null;
error: string | null;
}
function parseEndFileEvent(msg: MpvMessage): MpvEndFileEvent {
const reason = MPV_END_FILE_REASONS.find((known) => known === msg.reason) ?? 'unknown';
return {
reason,
playlistEntryId: typeof msg.playlist_entry_id === 'number' ? msg.playlist_entry_id : null,
error: typeof msg.file_error === 'string' && msg.file_error ? msg.file_error : null,
};
}
export const MPV_REQUEST_ID_SUBTEXT = 101;
export const MPV_REQUEST_ID_PATH = 102;
export const MPV_REQUEST_ID_SECONDARY_SUBTEXT = 103;
@@ -86,6 +65,8 @@ export interface MpvProtocolHandleMessageDeps {
getCurrentSubStart: () => number;
setCurrentSubEnd: (value: number) => void;
getCurrentSubEnd: () => number;
setCurrentSubDelay: (value: number) => void;
getCurrentSubDelay: () => number;
emitMediaPathChange: (payload: { path: string }) => void;
emitMediaTitleChange: (payload: { title: string | null }) => void;
emitTimePosChange: (payload: { time: number }) => void;
@@ -122,7 +103,6 @@ export interface MpvProtocolHandleMessageDeps {
shouldQuitOnMpvShutdown: () => boolean;
requestAppQuit: () => void;
emitClientMessage?: (payload: { args: string[] }) => void;
emitEndFile?: (payload: MpvEndFileEvent) => void;
}
type SubtitleTrackCandidate = {
@@ -286,7 +266,8 @@ export async function dispatchMpvProtocolMessage(
const subEnd = (msg.data as number) || 0;
deps.setCurrentSubEnd(subEnd);
if (deps.getPendingPauseAtSubEnd() && subEnd > 0) {
deps.setPauseAtTime(subEnd);
// `sub-end` is on the subtitle clock; the pause check compares it with `time-pos`.
deps.setPauseAtTime(toVideoClock(subEnd, deps.getCurrentSubDelay()));
deps.setPendingPauseAtSubEnd(false);
deps.sendCommand({ command: ['set_property', 'pause', false] });
}
@@ -295,6 +276,16 @@ export async function dispatchMpvProtocolMessage(
start: deps.getCurrentSubStart(),
end: deps.getCurrentSubEnd(),
});
} else if (msg.name === 'sub-delay') {
const parsed = typeof msg.data === 'number' ? msg.data : Number(msg.data);
const delay = Number.isFinite(parsed) ? parsed : 0;
// A scheduled replay pause sits at the line's end on the video clock; retiming
// mid-replay moves that end, so the pause moves with it.
const pauseAtTime = deps.getPauseAtTime();
if (pauseAtTime !== null) {
deps.setPauseAtTime(pauseAtTime + delay - deps.getCurrentSubDelay());
}
deps.setCurrentSubDelay(delay);
} else if (msg.name === 'secondary-sub-text') {
const nextSubText = (msg.data as string) || '';
deps.setCurrentSecondarySubText(nextSubText);
@@ -442,8 +433,6 @@ export async function dispatchMpvProtocolMessage(
if (args.length > 0) {
deps.emitClientMessage?.({ args });
}
} else if (msg.event === 'end-file') {
deps.emitEndFile?.(parseEndFileEvent(msg));
} else if (msg.event === 'shutdown') {
deps.restorePreviousSecondarySubVisibility();
if (deps.shouldQuitOnMpvShutdown()) {
+6 -5
View File
@@ -4,7 +4,6 @@ import {
dispatchMpvProtocolMessage,
MPV_REQUEST_ID_TRACK_LIST_AUDIO,
MPV_REQUEST_ID_TRACK_LIST_SECONDARY,
type MpvEndFileEvent,
MpvMessage,
MpvProtocolHandleMessageDeps,
splitMpvMessagesFromBuffer,
@@ -139,7 +138,6 @@ export interface MpvIpcClientEventMap {
'subtitle-metrics-change': { patch: Partial<MpvSubtitleRenderMetrics> };
'secondary-subtitle-visibility': { visible: boolean };
'client-message': { args: string[] };
'end-file': MpvEndFileEvent;
}
type MpvIpcClientEventName = keyof MpvIpcClientEventMap;
@@ -162,6 +160,8 @@ export class MpvIpcClient implements MpvClient {
public currentTimePos = 0;
public currentSubStart = 0;
public currentSubEnd = 0;
/** mpv `sub-delay`: seconds each primary line plays after its `sub-start`/`sub-end`. */
public currentSubDelay = 0;
public currentSubText = '';
public currentSecondarySubText = '';
public currentAudioStreamIndex: number | null = null;
@@ -461,6 +461,10 @@ export class MpvIpcClient implements MpvClient {
this.currentSubEnd = value;
},
getCurrentSubEnd: () => this.currentSubEnd,
setCurrentSubDelay: (value: number) => {
this.currentSubDelay = value;
},
getCurrentSubDelay: () => this.currentSubDelay,
emitMediaPathChange: (payload) => {
this.emit('media-path-change', payload);
},
@@ -521,9 +525,6 @@ export class MpvIpcClient implements MpvClient {
emitClientMessage: (payload) => {
this.emit('client-message', payload);
},
emitEndFile: (payload) => {
this.emit('end-file', payload);
},
};
}
-137
View File
@@ -1,137 +0,0 @@
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
@@ -1,134 +0,0 @@
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
@@ -1,47 +0,0 @@
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;
}
@@ -1,382 +0,0 @@
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
@@ -1,400 +0,0 @@
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>;
@@ -1,63 +0,0 @@
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: [] },
);
});
@@ -1,86 +0,0 @@
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
@@ -1,183 +0,0 @@
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,10 +55,6 @@ function createDeps(overrides: Partial<SessionActionExecutorDeps> = {}) {
playNextSubtitle: () => calls.push('play-next'),
cycleRuntimeOption: () => ({ ok: true }),
playNextPlaylistItem: () => calls.push('playlist-next'),
runPluginAction: async (pluginActionId) => {
calls.push(`plugin:${pluginActionId}`);
return pluginActionId === 'ai-explain/explain';
},
showMpvOsd: (text) => calls.push(`osd:${text}`),
...overrides,
};
@@ -85,22 +81,3 @@ test('dispatchSessionAction does not advance playlist when mark watched no-ops',
assert.deepEqual(calls, ['mark-watched']);
});
test('dispatchSessionAction runs plugin actions and reports unregistered ones', async () => {
const { calls, deps } = createDeps();
await dispatchSessionAction(
{ actionId: 'runPluginAction', payload: { pluginActionId: 'ai-explain/explain' } },
deps,
);
await dispatchSessionAction(
{ actionId: 'runPluginAction', payload: { pluginActionId: 'ai-explain/missing' } },
deps,
);
assert.deepEqual(calls, [
'plugin:ai-explain/explain',
'plugin:ai-explain/missing',
'osd:Plugin action not available: ai-explain/missing',
]);
});
-9
View File
@@ -32,8 +32,6 @@ export interface SessionActionExecutorDeps {
playNextSubtitle: () => void;
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
playNextPlaylistItem: () => void;
/** Returns false when no loaded plugin registered the action. */
runPluginAction: (pluginActionId: string) => Promise<boolean>;
showMpvOsd: (text: string) => void;
}
@@ -153,13 +151,6 @@ export async function dispatchSessionAction(
}
return;
}
case 'runPluginAction': {
const pluginActionId = request.payload?.pluginActionId;
if (!pluginActionId || !(await deps.runPluginAction(pluginActionId))) {
deps.showMpvOsd(`Plugin action not available: ${pluginActionId ?? 'unknown'}`);
}
return;
}
default:
return assertUnreachableSessionAction(request.actionId);
}
@@ -323,31 +323,6 @@ test('compileSessionBindings maps the legacy Animetosho command to the TsukiHime
);
});
test('compileSessionBindings maps plugin action commands to a validated session action', () => {
const result = compileSessionBindings({
shortcuts: createShortcuts(),
keybindings: [
createKeybinding('Ctrl+E', [`${SPECIAL_COMMANDS.PLUGIN_ACTION_PREFIX}ai-explain/explain`]),
createKeybinding('Ctrl+R', [`${SPECIAL_COMMANDS.PLUGIN_ACTION_PREFIX}no-action-name`]),
],
platform: 'linux',
});
const binding = result.bindings[0];
assert.equal(result.bindings.length, 1);
assert.ok(binding?.actionType === 'session-action');
assert.equal(binding.actionId, 'runPluginAction');
assert.deepEqual(binding.payload, { pluginActionId: 'ai-explain/explain' });
assert.deepEqual(
parseSessionActionDispatchRequest({ actionId: binding.actionId, payload: binding.payload }),
{ actionId: 'runPluginAction', payload: { pluginActionId: 'ai-explain/explain' } },
);
assert.deepEqual(
result.warnings.map((warning) => `${warning.kind}:${warning.path}`),
['unsupported:keybindings[1].command'],
);
});
test('compileSessionBindings leaves retired subtitle-delay shift tokens as mpv commands', () => {
const result = compileSessionBindings({
shortcuts: createShortcuts(),
-10
View File
@@ -342,16 +342,6 @@ function resolveCommandBinding(
if (command.length !== 1) return null;
return { actionType: 'session-action', actionId: 'playNextSubtitle' };
}
if (first.startsWith(SPECIAL_COMMANDS.PLUGIN_ACTION_PREFIX)) {
const pluginActionId = first.slice(SPECIAL_COMMANDS.PLUGIN_ACTION_PREFIX.length);
const [pluginId, actionName, ...rest] = pluginActionId.split('/');
if (command.length !== 1 || !pluginId || !actionName || rest.length > 0) return null;
return {
actionType: 'session-action',
actionId: 'runPluginAction',
payload: { pluginActionId },
};
}
if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) {
if (command.length !== 1) {
return null;
-3
View File
@@ -117,8 +117,6 @@ export interface AppReadyRuntimeDeps {
loadSubtitlePosition: () => void;
resolveKeybindings: () => void;
createMpvClient: () => void;
/** Loads enabled plugins; full startup only. */
startPlugins?: () => void;
reloadConfig: () => void;
getResolvedConfig: () => AppReadyConfigLike;
getConfigWarnings: () => ConfigValidationWarning[];
@@ -312,7 +310,6 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
deps.createMpvClient();
deps.initRuntimeOptionsManager();
deps.setSecondarySubMode(config.secondarySub?.defaultMode ?? deps.defaultSecondarySubMode);
deps.startPlugins?.();
const wsConfig = config.websocket || {};
const wsEnabled = wsConfig.enabled ?? 'auto';
+32
View File
@@ -0,0 +1,32 @@
/**
* 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), [
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.2, 0), [
'seek',
13.08,
'absolute+exact',
@@ -41,7 +41,7 @@ test('next subtitle navigation treats simultaneous sanitized cues as one line bo
{ startTime: 13.02, endTime: 16, text: 'Next English' },
];
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.1), [
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.1, 0), [
'seek',
13.08,
'absolute+exact',
@@ -55,7 +55,7 @@ test('next subtitle navigation advances past the latest overlapping lyric', () =
{ startTime: 16, endTime: 19, text: 'next lyric' },
];
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 13.2), [
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 13.2, 0), [
'seek',
16.08,
'absolute+exact',
@@ -68,7 +68,7 @@ test('previous subtitle navigation leaves the current cue and seeks to the prior
{ startTime: 13, endTime: 16, text: 'current line' },
];
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', -1], cues, 14.5), [
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', -1], cues, 14.5, 0), [
'seek',
10.08,
'absolute+exact',
@@ -78,8 +78,22 @@ test('previous subtitle navigation leaves the current cue and seeks to the prior
test('subtitle navigation falls back when no sanitized destination exists', () => {
const cues = [{ startTime: 10, endTime: 13, text: 'only line' }];
assert.equal(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.2), null);
assert.equal(resolveSanitizedSubtitleSeekCommand(['seek', 5], cues, 10.2), null);
assert.equal(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.2, 0), null);
assert.equal(resolveSanitizedSubtitleSeekCommand(['seek', 5], cues, 10.2, 0), null);
});
test('subtitle cue navigation finds and seeks cues on the sub-delay retimed timeline', () => {
const cues = [
{ startTime: 10, endTime: 13, text: 'one' },
{ startTime: 13, endTime: 16, text: 'two' },
];
// At 14.5 with a 2s delay, mpv is showing "one" (12-15 on the video clock).
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 14.5, 2), [
'seek',
15.08,
'absolute+exact',
]);
});
test('sidebar cue seeks share the boundary-safe sanitized cue timestamp', () => {
+5 -2
View File
@@ -1,3 +1,4 @@
import { toSubtitleClock, toVideoClock } from './subtitle-clock';
import type { SubtitleCue } from './subtitle-cue-parser';
const CUE_START_GROUP_TOLERANCE_SECONDS = 0.05;
@@ -80,8 +81,10 @@ export function subtitleCueListSeekTime(
export function resolveSanitizedSubtitleSeekCommand(
command: readonly (string | number)[],
cues: readonly SubtitleCue[],
currentTimeSec: number,
videoTimeSec: number,
subDelay: number,
): (string | number)[] | null {
const currentTimeSec = toSubtitleClock(videoTimeSec, subDelay);
if (
command.length < 2 ||
command[0] !== 'sub-seek' ||
@@ -124,5 +127,5 @@ export function resolveSanitizedSubtitleSeekCommand(
if (!destination) {
return null;
}
return ['seek', subtitleCueSeekTime(destination.cue), 'absolute+exact'];
return ['seek', toVideoClock(subtitleCueSeekTime(destination.cue), subDelay), 'absolute+exact'];
}
+17 -1
View File
@@ -1,6 +1,22 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createNotifySendReplacer, resolveDefaultNotificationIconPath } from './notification';
import {
buildNotifySendEnv,
createNotifySendReplacer,
resolveDefaultNotificationIconPath,
} from './notification';
test('notify-send child environment drops the AppImage library-path override', () => {
const env = buildNotifySendEnv({
LD_LIBRARY_PATH: '/tmp/.mount_SubMinXXXXXX/usr/lib',
DBUS_SESSION_BUS_ADDRESS: 'unix:path=/run/user/1000/bus',
HOME: '/home/user',
});
assert.equal(env.LD_LIBRARY_PATH, undefined);
assert.equal(env.DBUS_SESSION_BUS_ADDRESS, 'unix:path=/run/user/1000/bus');
assert.equal(env.HOME, '/home/user');
});
test('default notification icon resolves packaged SubMiner asset when no per-notification icon is provided', () => {
const path = resolveDefaultNotificationIconPath({
+12 -2
View File
@@ -3,7 +3,6 @@ import electron from 'electron';
import * as fs from 'fs';
import * as path from 'path';
import { createLogger } from '../../logger';
import { buildSystemBinaryEnv } from './system-binary-env';
const { Notification, nativeImage } = electron;
const logger = createLogger('core:notification');
@@ -204,8 +203,19 @@ export function createNotifySendReplacer(
};
}
/**
* Electron AppImages export `LD_LIBRARY_PATH=<mount>/usr/lib`, whose bundled libnotify predates the
* symbols the system notify-send links against, so an inherited environment kills the child with a
* symbol lookup error before it can send anything. A system binary resolves its own libraries fine,
* so the override is dropped entirely rather than filtered.
*/
export function buildNotifySendEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
const { LD_LIBRARY_PATH: _dropped, ...rest } = env;
return rest;
}
const showLinuxReplaceableNotification = createNotifySendReplacer((args, callback) =>
execFile('notify-send', args, { timeout: 5_000, env: buildSystemBinaryEnv() }, (error, stdout) =>
execFile('notify-send', args, { timeout: 5_000, env: buildNotifySendEnv() }, (error, stdout) =>
callback(error, stdout ?? ''),
),
);
-16
View File
@@ -1,16 +0,0 @@
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
@@ -1,10 +0,0 @@
/**
* 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;
}
@@ -1,63 +0,0 @@
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
@@ -1,83 +0,0 @@
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}"`],
};
}
+31 -115
View File
@@ -328,9 +328,11 @@ import {
setMpvSubVisibilityRuntime,
setOverlayDebugVisualizationEnabledRuntime,
setVisibleOverlayVisible as setVisibleOverlayVisibleCore,
shiftSubtitleRangeToVideoClock,
showMpvOsdRuntime,
JellyfinPlaybackReporter,
tokenizeSubtitle as tokenizeSubtitleCore,
toSubtitleClock,
triggerFieldGrouping as triggerFieldGroupingCore,
upsertYomitanDictionarySettings,
updateLastCardFromClipboard as updateLastCardFromClipboardCore,
@@ -512,14 +514,6 @@ import { openSubsyncManualModal as openSubsyncManualModalRuntime } from './main/
import { openSessionHelpModal as openSessionHelpModalRuntime } from './main/runtime/session-help-open';
import { openChangelogModal as openChangelogModalRuntime } from './main/runtime/changelog-open';
import { createChangelogRuntime } from './main/runtime/changelog/changelog-runtime';
import { createPluginHost } from './core/services/plugins/plugin-host';
import { buildPluginSubtitleContext } from './core/services/plugins/subtitle-context';
import type { PluginSubtitleLine } from './core/services/plugins/types';
import {
createPluginPanelRuntime,
openPluginPanelModal,
} from './main/runtime/plugin-panel-runtime';
import { createPluginWindowRuntime } from './main/runtime/plugin-window-runtime';
import { openCharacterDictionaryManagerModal as openCharacterDictionaryManagerModalRuntime } from './main/runtime/character-dictionary-open';
import { openControllerSelectModal as openControllerSelectModalRuntime } from './main/runtime/controller-select-open';
import { openControllerDebugModal as openControllerDebugModalRuntime } from './main/runtime/controller-debug-open';
@@ -1945,7 +1939,10 @@ let tokenizeSubtitleDeferred: ((text: string) => Promise<SubtitleData>) | null =
function withCurrentSubtitleTiming(payload: SubtitleData): SubtitleData {
const canonical = resolveCanonicalPrimarySubtitle({
liveText: payload.text,
currentTimeSec: Number(appState.mpvClient?.currentTimePos),
currentTimeSec: toSubtitleClock(
Number(appState.mpvClient?.currentTimePos),
appState.mpvClient?.currentSubDelay ?? 0,
),
cues: appState.activeParsedSubtitleCues,
});
return {
@@ -1959,9 +1956,11 @@ function captureCurrentPrimarySubtitleMiningContext(): SubtitleMiningContext | n
// Mine what the overlay shows, not raw mpv `sub-text`: the raw text lists every active
// event, so a finished caption row lingering beside a fresh line would end up on the
// card. The parsed view also carries the cue's own timings for the clip range.
// Cues sit on the subtitle clock, so the lookup undoes `sub-delay` first.
const subDelay = appState.mpvClient?.currentSubDelay ?? 0;
const resolved = resolvePrimarySubtitle({
liveText: appState.mpvClient?.currentSubText ?? '',
currentTimeSec: Number(appState.mpvClient?.currentTimePos),
currentTimeSec: toSubtitleClock(Number(appState.mpvClient?.currentTimePos), subDelay),
cues: appState.activeParsedSubtitleCues,
});
// Same validity bar as the live capture path: an unusable resolved span must fall
@@ -1976,13 +1975,16 @@ function captureCurrentPrimarySubtitleMiningContext(): SubtitleMiningContext | n
) {
return captureLiveSubtitleMiningContext(appState.mpvClient);
}
return {
return shiftSubtitleRangeToVideoClock<SubtitleMiningContext>(
{
source: 'overlay',
text: resolvedText,
startTime: resolved.startTime,
endTime: resolved.endTime,
capturedAtMs: Date.now(),
};
},
subDelay,
);
}
function emitSubtitlePayload(payload: SubtitleData, options?: { resumePrefetch?: boolean }): void {
const timedPayload = withCurrentSubtitleTiming(payload);
@@ -2124,13 +2126,18 @@ function getSubtitleSidebarPlaybackState(): SubtitleSidebarPlaybackState {
subtitleSidebarCuesRef = appState.activeParsedSubtitleCues;
subtitleSidebarCuesRevision += 1;
}
const subDelay = appState.mpvClient?.currentSubDelay ?? 0;
return {
sourceKey: JSON.stringify([
appState.activeParsedSubtitleMediaPath,
appState.activeParsedSubtitleSource,
]),
cuesRevision: subtitleSidebarCuesRevision,
currentTimeSec: appState.mpvClient?.currentTimePos ?? null,
currentTimeSec:
appState.mpvClient?.currentTimePos === undefined
? null
: toSubtitleClock(appState.mpvClient.currentTimePos, subDelay),
subDelay,
currentSubtitle: {
text: appState.currentSubText,
startTime: appState.mpvClient?.currentSubStart ?? null,
@@ -2169,6 +2176,7 @@ function sendRendererMpvCommand(rawCommand: (string | number)[]): void {
rawCommand,
appState.activeParsedSubtitleCues,
appState.mpvClient?.currentTimePos ?? Number.NaN,
appState.mpvClient?.currentSubDelay ?? 0,
) ?? rawCommand;
if (isExplicitMpvSeekCommand(command)) {
explicitSeekIntentExpiresAtMs = Date.now() + EXPLICIT_SEEK_INTENT_TTL_MS;
@@ -3086,87 +3094,6 @@ function createOverlayHostedModalOpenDeps(): {
};
}
const pluginPanelRuntime = createPluginPanelRuntime({
openModal: (getSnapshot) => openPluginPanelModal(createOverlayHostedModalOpenDeps(), getSnapshot),
// The panel may live in either window; broadcastToOverlayWindows only reaches the main one.
broadcast: (channel, payload) => {
for (const window of [overlayManager.getMainWindow(), overlayManager.getModalWindow()]) {
if (window && !window.isDestroyed()) window.webContents.send(channel, payload);
}
},
onOpenFailed: () =>
overlayNotificationsRuntime.showConfiguredStatusNotification('Plugin panel unavailable.', {
variant: 'warning',
}),
});
function getPluginCurrentSubtitle(): PluginSubtitleLine | null {
const text = appState.currentSubText.trim();
if (!text) return null;
// Same view as mining: the parsed line with its cue timings on the subtitle file's timeline.
const resolved = resolvePrimarySubtitle({
liveText: appState.mpvClient?.currentSubText ?? '',
currentTimeSec: Number(appState.mpvClient?.currentTimePos),
cues: appState.activeParsedSubtitleCues,
});
const resolvedText = resolved?.text.replace(/\n{2,}/g, '\n').trim();
if (
resolved &&
resolvedText &&
Number.isFinite(resolved.startTime) &&
Number.isFinite(resolved.endTime) &&
resolved.endTime > resolved.startTime
) {
return { text: resolvedText, startTime: resolved.startTime, endTime: resolved.endTime };
}
const client = appState.mpvClient;
// The client reports a cleared sub-start as zero, so only trust timing for a live line.
const hasTiming = client !== null && client.currentSubEnd > client.currentSubStart;
return {
text,
startTime: hasTiming ? client.currentSubStart : null,
endTime: hasTiming ? client.currentSubEnd : null,
};
}
const pluginWindowRuntime = createPluginWindowRuntime({
createBrowserWindow: (options) => new BrowserWindow(options),
promoteAboveOverlay: (window) => promoteSettingsWindowAboveOverlay(window),
logError: (message) => logger.error(message),
});
const pluginHost = createPluginHost({
pluginsDir: path.join(CONFIG_DIR, 'plugins'),
pluginDataDir: path.join(USER_DATA_PATH, 'plugin-data'),
getPluginConfig: () => configService.getConfig().plugins,
createLogger,
isMpvConnected: () => appState.mpvClient?.connected === true,
sendMpvCommand: (command) => sendMpvCommandRuntime(appState.mpvClient, command),
requestMpvProperty: async (name) => {
if (!appState.mpvClient) throw new Error('mpv is not connected.');
return await appState.mpvClient.requestProperty(name);
},
ensureMpvRunning: () => ensureMpvConnectedForPlayback(),
dispatchSessionAction: (request) => dispatchSessionAction(request),
getCurrentSubtitle: () => getPluginCurrentSubtitle(),
getSubtitleContext: (options) =>
buildPluginSubtitleContext({
cues: appState.activeParsedSubtitleCues,
current: getPluginCurrentSubtitle(),
recentLines: (appState.subtitleTimingTracker?.getRecentEntries(40) ?? []).map((entry) => ({
text: entry.displayText,
startTime: entry.startTime,
endTime: entry.endTime,
})),
...options,
}),
notify: (message, variant) =>
overlayNotificationsRuntime.showConfiguredStatusNotification(message, { variant }),
openPanel: (content) => pluginPanelRuntime.open(content),
openWindow: (request) => pluginWindowRuntime.open(request),
unstable: { appState, configService, overlayManager },
});
const mediaTimingFrameExtractor = createMediaTimingFrameExtractor();
const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({
getMpvClient: () => appState.mpvClient,
@@ -3204,7 +3131,10 @@ const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({
acquireMediaWindow: (source, range) => getSharedRemoteMediaWindowCache().acquire(source, range),
getSubtitleContextLines: (range) =>
collectMediaTimingContextLines({
cues: appState.activeParsedSubtitleCues,
// Review ranges and tracker entries are on the video clock; parsed cues are not.
cues: appState.activeParsedSubtitleCues.map((cue) =>
shiftSubtitleRangeToVideoClock(cue, appState.mpvClient?.currentSubDelay ?? 0),
),
fallbackPrevious: appState.subtitleTimingTracker?.getRecentEntries(40) ?? [],
startTime: range.startTime,
endTime: range.endTime,
@@ -4403,7 +4333,6 @@ const {
void appState.discordPresenceService?.stop();
appState.discordPresenceService = null;
},
unloadPlugins: () => pluginHost.unloadAll(),
},
shouldRestoreWindowsOnActivateMainDeps: {
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
@@ -4673,9 +4602,6 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
createMpvClient: () => {
appState.mpvClient = createMpvClientRuntimeService();
},
startPlugins: () => {
void pluginHost.loadAll();
},
getResolvedConfig: () => configService.getConfig(),
getConfigWarnings: () => configService.getWarnings(),
logConfigWarning: (warning) => appLogger.logConfigWarning(warning),
@@ -5372,7 +5298,6 @@ function createMpvClientRuntimeService(): MpvIpcClient {
client.on('media-path-change', aniSkipRuntime.handleMediaPathChange);
client.on('time-pos-change', aniSkipRuntime.handleTimePosChange);
client.on('client-message', aniSkipRuntime.handleClientMessage);
pluginHost.attachMpvClient(client);
return client;
}
@@ -5954,11 +5879,6 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
},
playNextPlaylistItem: () =>
sendMpvCommandRuntime(appState.mpvClient, ['playlist-next', 'force']),
runPluginAction: async (pluginActionId) => {
const ran = await pluginHost.runAction(pluginActionId);
if (!ran) logger.warn(`Plugin action is not registered: ${pluginActionId}`);
return ran;
},
showMpvOsd: (text) => showConfiguredPlaybackFeedback(text),
});
}
@@ -6040,15 +5960,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
return false;
}
},
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;
}
onOverlayModalClosed: (modal, senderWindow) => {
if (modal === 'media-timing-review') {
void mediaTimingReviewRuntime.dispose();
}
@@ -6135,7 +6047,11 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
// live mpv sub timings at lookup time so media generation clips the mined line even
// when extraction finishes long after playback has moved on.
recordSubtitleMiningContext: (context) =>
recordSubtitleMiningContext(context ?? captureCurrentPrimarySubtitleMiningContext()),
recordSubtitleMiningContext(
context
? shiftSubtitleRangeToVideoClock(context, appState.mpvClient?.currentSubDelay ?? 0)
: captureCurrentPrimarySubtitleMiningContext(),
),
tokenizeCurrentSubtitle: async () => {
const tokenizeSubtitleForCurrent = tokenizeSubtitleDeferred;
return resolveCurrentSubtitleForRenderer({
-2
View File
@@ -24,7 +24,6 @@ export interface AppReadyRuntimeDepsFactoryInput {
loadSubtitlePosition: AppReadyRuntimeDeps['loadSubtitlePosition'];
resolveKeybindings: AppReadyRuntimeDeps['resolveKeybindings'];
createMpvClient: AppReadyRuntimeDeps['createMpvClient'];
startPlugins?: AppReadyRuntimeDeps['startPlugins'];
reloadConfig: AppReadyRuntimeDeps['reloadConfig'];
getResolvedConfig: AppReadyRuntimeDeps['getResolvedConfig'];
getConfigWarnings: AppReadyRuntimeDeps['getConfigWarnings'];
@@ -95,7 +94,6 @@ export function createAppReadyRuntimeDeps(
loadSubtitlePosition: params.loadSubtitlePosition,
resolveKeybindings: params.resolveKeybindings,
createMpvClient: params.createMpvClient,
startPlugins: params.startPlugins,
reloadConfig: params.reloadConfig,
getResolvedConfig: params.getResolvedConfig,
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 \},/,
)?.groups?.body;
const closedBlock = source.match(
/onOverlayModalClosed:\s*\(modal,\s*senderWindow(?:,\s*instanceId)?\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
/onOverlayModalClosed:\s*\(modal,\s*senderWindow\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
)?.groups?.body;
const depsBlock = source.match(/getSubtitleSidebarOpen:\s*\(\)\s*=>\s*(?<body>[^\n,]+)/)?.groups
?.body;
@@ -55,7 +55,6 @@ function makeCleanupDeps(calls: string[], overrides: Partial<CleanupDeps> = {}):
cleanupRemoteMediaWindows: record('cleanupRemoteMediaWindows'),
cleanupJellyfinSubtitleCache: record('cleanupJellyfinSubtitleCache'),
stopDiscordPresenceService: record('stopDiscordPresenceService'),
unloadPlugins: record('unloadPlugins'),
...overrides,
};
}
@@ -100,31 +99,6 @@ test('on will quit cleanup handler flushes before teardown and awaits stats befo
assert.ok(calls.indexOf('destroyImmersionTracker') < calls.indexOf('destroyAnkiIntegration'));
});
test('on will quit cleanup handler starts plugin unload first and waits for it to settle', async () => {
const calls: string[] = [];
let finishUnload!: () => void;
const cleanup = createOnWillQuitCleanupHandler(
makeCleanupDeps(calls, {
unloadPlugins: () => {
calls.push('unloadPlugins');
return new Promise<void>((resolve) => (finishUnload = resolve));
},
}),
);
let settled = false;
const pending = cleanup().then(() => {
settled = true;
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(calls[0], 'unloadPlugins');
assert.equal(settled, false);
finishUnload();
await pending;
assert.equal(settled, true);
});
test('on will quit cleanup handler cleans subtitle caches when stopping remote session fails', async () => {
const calls: string[] = [];
const cleanup = createOnWillQuitCleanupHandler(
+1 -7
View File
@@ -59,14 +59,8 @@ export function createOnWillQuitCleanupHandler(deps: {
cleanupRemoteMediaWindows: () => void;
cleanupJellyfinSubtitleCache: () => void;
stopDiscordPresenceService: () => void;
unloadPlugins: () => void | Promise<void>;
}) {
return async (): Promise<void> => {
const cleanupErrors: unknown[] = [];
// Plugins deactivate first, while the app is still intact; the host bounds how long that takes.
const unloadPlugins = (async () => deps.unloadPlugins())().catch((error: unknown) => {
cleanupErrors.push(error);
});
deps.destroyTray();
deps.stopConfigHotReload();
deps.restorePreviousSecondarySubVisibility();
@@ -74,6 +68,7 @@ export function createOnWillQuitCleanupHandler(deps: {
deps.unregisterAllGlobalShortcuts();
deps.stopSubtitleWebsocket();
deps.stopTexthookerService();
const cleanupErrors: unknown[] = [];
const stopSyncAutoScheduler = Promise.resolve(deps.stopSyncAutoScheduler()).catch(
(error: unknown) => {
cleanupErrors.push(error);
@@ -123,7 +118,6 @@ export function createOnWillQuitCleanupHandler(deps: {
deps.cleanupRemoteMediaWindows();
deps.stopDiscordPresenceService();
await stopSyncAutoScheduler;
await unloadPlugins;
if (cleanupErrors.length > 0) {
throw cleanupErrors[0];
}
@@ -52,7 +52,6 @@ function buildCleanupDeps(overrides: Partial<BuilderDeps> = {}) {
cleanupRemoteMediaWindows: noop,
cleanupJellyfinSubtitleCache: noop,
stopDiscordPresenceService: noop,
unloadPlugins: noop,
...overrides,
})();
}
@@ -65,7 +65,6 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
cleanupRemoteMediaWindows: () => void;
cleanupJellyfinSubtitleCache: () => void;
stopDiscordPresenceService: () => void;
unloadPlugins: () => void | Promise<void>;
}) {
return () => ({
destroyTray: () => deps.destroyTray(),
@@ -155,6 +154,5 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
cleanupRemoteMediaWindows: () => deps.cleanupRemoteMediaWindows(),
cleanupJellyfinSubtitleCache: () => deps.cleanupJellyfinSubtitleCache(),
stopDiscordPresenceService: () => deps.stopDiscordPresenceService(),
unloadPlugins: () => deps.unloadPlugins(),
});
}
-1
View File
@@ -6,7 +6,6 @@ export function createBuildAppReadyRuntimeMainDepsHandler(deps: AppReadyRuntimeD
loadSubtitlePosition: deps.loadSubtitlePosition,
resolveKeybindings: deps.resolveKeybindings,
createMpvClient: deps.createMpvClient,
startPlugins: deps.startPlugins,
reloadConfig: deps.reloadConfig,
getResolvedConfig: deps.getResolvedConfig,
getConfigWarnings: deps.getConfigWarnings,
@@ -1,3 +1,4 @@
import { toSubtitleClock } from '../../core/services/subtitle-clock';
import type { SubtitleCue, SubtitleData } from '../../types';
import { selectAutoplayStartupCue } from './autoplay-subtitle-primer';
import { primeVisibleOverlaySubtitleFromMpv } from './current-subtitle-snapshot';
@@ -12,6 +13,7 @@ type AutoplaySubtitlePrimingMpvClient = {
requestProperty: (name: string) => Promise<unknown>;
currentVideoPath?: string;
currentTimePos?: number;
currentSubDelay?: number;
currentSubText?: string;
currentSecondarySubText?: string;
setCurrentSecondarySubText?: (text: string) => void;
@@ -156,7 +158,7 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
const currentTimeSec = Number(client?.currentTimePos ?? deps.getLastObservedTimePos());
return resolvePrimarySubtitleText({
liveText: text,
currentTimeSec,
currentTimeSec: toSubtitleClock(currentTimeSec, client?.currentSubDelay ?? 0),
cues: deps.getActiveParsedSubtitleCues(),
});
}
@@ -270,7 +272,10 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
const currentTimeSeconds = Number(
timePosRaw ?? client?.currentTimePos ?? deps.getLastObservedTimePos() ?? 0,
);
const resolvedTimeSeconds = Number.isFinite(currentTimeSeconds) ? currentTimeSeconds : 0;
const resolvedTimeSeconds = toSubtitleClock(
Number.isFinite(currentTimeSeconds) ? currentTimeSeconds : 0,
client?.currentSubDelay ?? 0,
);
const cue = selectAutoplayStartupCue(
cues,
resolvedTimeSeconds,
+43 -8
View File
@@ -2,11 +2,6 @@ import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import {
buildWindowsShimInvocation,
isWindowsCommandShim,
} from '../../core/utils/windows-command-shim';
export interface RunCommandResult {
exitCode: number | null;
stdout: string;
@@ -147,7 +142,43 @@ export function failureMessage(result: RunCommandResult, fallback: string): stri
}
function needsWindowsShell(command: string): boolean {
return process.platform === 'win32' && isWindowsCommandShim(command);
return process.platform === 'win32' && /\.(cmd|bat)$/i.test(command);
}
/*!
* Windows command escaping adapted from cross-spawn 7.0.6.
*
* The MIT License (MIT)
*
* Copyright (c) 2018 Made With MOXY Lda <hello@moxy.studio>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
const WINDOWS_SHELL_META_CHARACTERS = /([()\][%!^"`<>&|;, *?])/g;
// Quote for both cmd.exe and the Windows argv parser. The outer caret escapes
// are consumed by cmd, leaving the quoted argument unchanged for the command.
function escapeWindowsShellArgument(value: string): string {
const quotesEscaped = value
.replace(/(?=(\\+?)?)\1"/g, '$1$1\\"')
.replace(/(?=(\\+?)?)\1$/, '$1$1');
return `"${quotesEscaped}"`.replace(WINDOWS_SHELL_META_CHARACTERS, '^$1');
}
function createDefaultRunCommand(): RunCommand {
@@ -158,8 +189,12 @@ function createDefaultRunCommand(): RunCommand {
try {
const env = options.env ?? process.env;
if (useShell) {
const invocation = buildWindowsShimInvocation(command, args, env);
child = spawn(invocation.file, invocation.args, {
const shellCommand = [
escapeWindowsShellArgument(command),
...args.map(escapeWindowsShellArgument),
].join(' ');
const commandProcessor = env.ComSpec ?? env.COMSPEC ?? process.env.ComSpec ?? 'cmd.exe';
child = spawn(commandProcessor, ['/d', '/s', '/v:off', '/c', `"${shellCommand}"`], {
env,
windowsHide: false,
windowsVerbatimArguments: true,
@@ -176,6 +176,21 @@ test('subtitle-track transitions ignore stale parsed cues until replacement cues
assert.deepEqual(recordedStarts.slice(-1), [20]);
});
test('subtitle timing history records lines on the video clock under sub-delay', () => {
const timing: Array<{ text: string; start: number; end: number }> = [];
const { handlers } = buildMainDeps({
mpvClient: { currentSubDelay: 2 },
subtitleTimingTracker: {
recordSubtitle: (text: string, start: number, end: number) =>
timing.push({ text, start, end }),
},
});
handlers.recordSubtitleTiming('食べる', 5, 8);
assert.deepEqual(timing, [{ text: '食べる', start: 7, end: 10 }]);
});
test('canonical ASS cues replace live glyph spam for display, history, and immersion', () => {
const immersion: Array<{ text: string; start: number; end: number }> = [];
const timing: Array<{ text: string; start: number; end: number }> = [];
+13 -5
View File
@@ -1,3 +1,4 @@
import { toSubtitleClock } from '../../core/services/subtitle-clock';
import { createSubtitleLineDedupGate } from '../../core/services/subtitle-line-dedup-gate';
import type { MergedToken, SubtitleCue, SubtitleData } from '../../types';
import {
@@ -23,6 +24,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
currentSubText?: string;
currentSecondarySubText?: string;
currentTimePos?: number;
currentSubDelay?: number;
requestProperty?: (name: string) => Promise<unknown>;
} | null;
immersionTracker: {
@@ -161,7 +163,10 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
resolveSubtitleText: (liveText: string) =>
resolvePrimarySubtitleText({
liveText,
currentTimeSec: Number(deps.appState.mpvClient?.currentTimePos),
currentTimeSec: toSubtitleClock(
Number(deps.appState.mpvClient?.currentTimePos),
deps.appState.mpvClient?.currentSubDelay ?? 0,
),
cues: deps.appState.activeParsedSubtitleCues,
}),
getCurrentLiveSubtitleText: () => deps.appState.mpvClient?.currentSubText ?? '',
@@ -231,6 +236,9 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
hasSubtitleTimingTracker: () => Boolean(deps.appState.subtitleTimingTracker),
recordSubtitleTiming: (text: string, start: number, end: number) => {
const secondaryText = deps.appState.mpvClient?.currentSecondarySubText || undefined;
// The tracker feeds multi-line mining, so it stores video-clock times: the subtitle
// timings plus the `sub-delay` in effect while the line was on screen.
const subDelay = deps.appState.mpvClient?.currentSubDelay ?? 0;
const canonical = resolveCanonicalSample(text, start);
if (!canonical) {
const recordableText = resolveTextForRecording(text, start);
@@ -239,8 +247,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
}
deps.appState.subtitleTimingTracker?.recordSubtitle?.(
recordableText,
start,
end,
start + subDelay,
end + subDelay,
secondaryText,
);
return;
@@ -253,8 +261,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
recordedTimingCanonicalKeys.add(key);
deps.appState.subtitleTimingTracker?.recordSubtitle?.(
cue.text,
cue.startTime,
cue.endTime,
cue.startTime + subDelay,
cue.endTime + subDelay,
secondaryText,
);
}
@@ -1,98 +0,0 @@
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
@@ -1,117 +0,0 @@
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;
},
};
}
@@ -1,91 +0,0 @@
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
@@ -1,64 +0,0 @@
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 };
},
};
}
+2 -28
View File
@@ -105,7 +105,6 @@ import type {
OverlayNotificationEventPayload,
OverlayNotificationPosition,
ChangelogSnapshot,
PluginPanelSnapshot,
MediaTimingReviewActionResult,
MediaTimingReviewOpenPayload,
MediaTimingReviewPreviewPosition,
@@ -141,20 +140,6 @@ const onOpenSessionHelpEvent = createQueuedIpcListenerWithPayload<SessionHelpOpe
}),
);
const onOpenChangelogEvent = createQueuedIpcListener(IPC_CHANNELS.event.changelogOpen);
function toPluginPanelSnapshot(payload: unknown): PluginPanelSnapshot {
const value = (typeof payload === 'object' && payload !== null ? payload : {}) as Partial<
Record<keyof PluginPanelSnapshot, unknown>
>;
return {
panelId: typeof value.panelId === 'number' ? value.panelId : -1,
title: typeof value.title === 'string' ? value.title : '',
markdown: typeof value.markdown === 'string' ? value.markdown : '',
};
}
const onOpenPluginPanelEvent = createQueuedIpcListenerWithPayload(
IPC_CHANNELS.event.pluginPanelOpen,
toPluginPanelSnapshot,
);
const onOpenCharacterDictionaryManagerEvent = createQueuedIpcListener(
IPC_CHANNELS.event.characterDictionaryManagerOpen,
);
@@ -430,17 +415,6 @@ const electronAPI: ElectronAPI = {
onOpenChangelog: onOpenChangelogEvent,
getChangelogSnapshot: (options?: { refresh?: boolean }): Promise<ChangelogSnapshot> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getChangelogSnapshot, options),
onOpenPluginPanel: onOpenPluginPanelEvent,
onPluginPanelUpdate: (callback: (snapshot: PluginPanelSnapshot) => void): void => {
ipcRenderer.on(IPC_CHANNELS.event.pluginPanelUpdate, (_event, payload: unknown) => {
callback(toPluginPanelSnapshot(payload));
});
},
onPluginPanelClose: (callback: (panelId: number) => void): void => {
ipcRenderer.on(IPC_CHANNELS.event.pluginPanelClose, (_event, panelId: unknown) => {
if (typeof panelId === 'number') callback(panelId);
});
},
onOpenControllerSelect: onOpenControllerSelectEvent,
onOpenControllerDebug: onOpenControllerDebugEvent,
onOpenJimaku: onOpenJimakuEvent,
@@ -532,8 +506,8 @@ const electronAPI: ElectronAPI = {
mediaId,
direction,
),
notifyOverlayModalClosed: (modal, instanceId) => {
ipcRenderer.send(IPC_CHANNELS.command.overlayModalClosed, modal, instanceId);
notifyOverlayModalClosed: (modal) => {
ipcRenderer.send(IPC_CHANNELS.command.overlayModalClosed, modal);
},
notifyOverlayModalOpened: (modal) => {
ipcRenderer.send(IPC_CHANNELS.command.overlayModalOpened, modal);
-1
View File
@@ -595,7 +595,6 @@ function createKeyboardHandlerHarness() {
changelogKeydownCount += 1;
return true;
},
handlePluginPanelKeydown: () => false,
openSessionHelpModal: () => {},
openControllerSelectModal: () => {
openControllerSelectCount += 1;
-6
View File
@@ -32,7 +32,6 @@ export function createKeyboardHandlers(
handleControllerDebugKeydown: (e: KeyboardEvent) => boolean;
handleSessionHelpKeydown: (e: KeyboardEvent) => boolean;
handleChangelogKeydown: (e: KeyboardEvent) => boolean;
handlePluginPanelKeydown: (e: KeyboardEvent) => boolean;
openSessionHelpModal: (opening: {
bindingKey: 'KeyH' | 'KeyK';
fallbackUsed: boolean;
@@ -1173,11 +1172,6 @@ export function createKeyboardHandlers(
return;
}
if (ctx.state.pluginPanelModalOpen) {
options.handlePluginPanelKeydown(e);
return;
}
if (ctx.state.runtimeOptionsModalOpen) {
options.handleRuntimeOptionsKeydown(e);
return;
-9
View File
@@ -1019,15 +1019,6 @@
</div>
</div>
</div>
<div id="pluginPanelModal" class="modal hidden" aria-hidden="true">
<div class="modal-content plugin-panel-content">
<div class="modal-header">
<div id="pluginPanelTitle" class="modal-title"></div>
<button id="pluginPanelClose" class="modal-close" type="button">Close</button>
</div>
<div id="pluginPanelBody" class="modal-body plugin-panel-body" tabindex="0"></div>
</div>
</div>
<div id="playlistBrowserModal" class="modal hidden" aria-hidden="true">
<div class="modal-content playlist-browser-content">
<div class="modal-header">
@@ -1,70 +0,0 @@
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
@@ -1,204 +0,0 @@
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
@@ -1,137 +0,0 @@
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,
};
}
+4 -6
View File
@@ -3,7 +3,6 @@ import type {
CompiledSessionActionBinding,
CompiledSessionBinding,
SessionActionId,
SessionActionPayload,
SessionKeyModifier,
SessionKeySpec,
} from '../../types';
@@ -190,7 +189,10 @@ function formatSessionKeySpec(key: SessionKeySpec): string {
.join(' + ');
}
function describeSessionAction(actionId: SessionActionId, payload?: SessionActionPayload): string {
function describeSessionAction(
actionId: SessionActionId,
payload?: { runtimeOptionId?: string; direction?: 1 | -1 },
): string {
switch (actionId) {
case 'toggleStatsOverlay':
return 'Toggle stats overlay';
@@ -252,8 +254,6 @@ function describeSessionAction(actionId: SessionActionId, payload?: SessionActio
return `Cycle runtime option ${payload?.runtimeOptionId ?? 'option'} ${
payload?.direction === -1 ? 'previous' : 'next'
}`;
case 'runPluginAction':
return `Plugin action ${payload?.pluginActionId ?? ''}`.trim();
}
}
@@ -297,8 +297,6 @@ function sectionForSessionBinding(binding: CompiledSessionBinding): string {
return 'Playback and navigation';
case 'cycleRuntimeOption':
return 'Runtime settings';
case 'runPluginAction':
return 'Plugins';
}
}
@@ -28,6 +28,7 @@ export async function setup() {
],
currentSubtitle: { text: '最初の台詞', startTime: 0, endTime: 1 },
currentTimeSec: 0,
subDelay: 0,
config: {
...SUBTITLE_DEFAULT_CONFIG.subtitleSidebar,
enabled: true,
@@ -101,7 +102,6 @@ export async function setup() {
handleControllerDebugKeydown: () => false,
handleSessionHelpKeydown: () => false,
handleChangelogKeydown: () => false,
handlePluginPanelKeydown: () => false,
openSessionHelpModal: () => {},
getPlaybackPaused: async () => false,
});
Loaded 100 of 123 files, more files were not shown because too many files have changed in this diff. Show more