mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-26 23:35:17 -07:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
203a61a20f
|
||
|
|
a681bbaf25
|
||
|
|
6219a81144 | ||
|
|
cc9af79d6e |
No files matched your search
@@ -1,5 +1,28 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.21.1 (2026-10-26)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Stats Session View**: Redesigned the expanded session view in the stats dashboard.
|
||||||
|
- New stat tiles show pace, lines, known-word rate, lookups, and paused time.
|
||||||
|
- A reading density chart of known and unknown words replaces the cumulative chart.
|
||||||
|
- Yomitan lookups and mined cards now appear in their own lane below the chart.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Subtitle Retiming**: Mining, the subtitle sidebar, subtitle navigation, and replay now use the timing you set with mpv's subtitle delay keys (z/x and sub-step), so mined audio and images match the retimed line.
|
||||||
|
- **Subtitle Sidebar**: mpv keys and mouse actions, like volume keys and double-click fullscreen, work again while the sidebar is open.
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
- **README**: Refreshed the anime screenshots using the current build.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Internal changes</summary>
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
- **Packaging Layout**: Packaging inputs now live under `packaging/`, and all build output lands in `dist/`, with packaged apps in `dist/release/`.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## v0.21.0 (2026-10-24)
|
## v0.21.0 (2026-10-24)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -102,10 +102,6 @@ A stats dashboard tracks watch time, vocabulary growth, mining throughput, sessi
|
|||||||
<td><b>WebSocket</b></td>
|
<td><b>WebSocket</b></td>
|
||||||
<td>Plain subtitle feed plus a dedicated annotated feed for texthooker pages and custom tools</td>
|
<td>Plain subtitle feed plus a dedicated annotated feed for texthooker pages and custom tools</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
|
||||||
<td><b>Plugins</b></td>
|
|
||||||
<td>Load your own JavaScript at startup to react to playback, run timers, add key-bound actions, and show results in an overlay panel. The bundled example explains the current line with Codex. <a href="https://docs.subminer.moe/plugins">Plugin guide</a></td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "AI Explain",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"main": "main.js"
|
|
||||||
}
|
|
||||||
@@ -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.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: docs
|
|
||||||
area: readme
|
|
||||||
|
|
||||||
- Refreshed the README's anime screenshots using the current SubMiner build.
|
|
||||||
@@ -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.
|
|
||||||
@@ -218,14 +218,6 @@
|
|||||||
"overlayPosition": "top-right" // Position for in-overlay notification cards. Values: top-left | top | top-right
|
"overlayPosition": "top-right" // Position for in-overlay notification cards. Values: top-left | top | top-right
|
||||||
}, // Overlay notification display behavior.
|
}, // Overlay notification display behavior.
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Plugins
|
|
||||||
// JavaScript plugins loaded from <config dir>/plugins/<id>/ at startup.
|
|
||||||
// Plugins run inside SubMiner with full access to your system. Only enable code you trust.
|
|
||||||
// Restart SubMiner after changing plugins.
|
|
||||||
// ==========================================
|
|
||||||
"plugins": {}, // Plugins to load at startup, keyed by directory name under <config dir>/plugins/. Each entry is { "enabled": true, "settings": { ... } }; settings are passed to the plugin as ctx.settings.
|
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Keyboard Shortcuts
|
// Keyboard Shortcuts
|
||||||
// Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
// Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
||||||
|
|||||||
@@ -221,7 +221,6 @@ const sidebar: DefaultTheme.SidebarItem[] = [
|
|||||||
{ text: 'AniList', link: '/anilist-integration' },
|
{ text: 'AniList', link: '/anilist-integration' },
|
||||||
{ text: 'AniSkip', link: '/aniskip-integration' },
|
{ text: 'AniSkip', link: '/aniskip-integration' },
|
||||||
{ text: 'Character Dictionary', link: '/character-dictionary' },
|
{ text: 'Character Dictionary', link: '/character-dictionary' },
|
||||||
{ text: 'Plugins', link: '/plugins' },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,28 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.21.1 (2026-10-26)
|
||||||
|
|
||||||
|
**Changed**
|
||||||
|
- **Stats Session View**: Redesigned the expanded session view in the stats dashboard.
|
||||||
|
- New stat tiles show pace, lines, known-word rate, lookups, and paused time.
|
||||||
|
- A reading density chart of known and unknown words replaces the cumulative chart.
|
||||||
|
- Yomitan lookups and mined cards now appear in their own lane below the chart.
|
||||||
|
|
||||||
|
**Fixed**
|
||||||
|
- **Subtitle Retiming**: Mining, the subtitle sidebar, subtitle navigation, and replay now use the timing you set with mpv's subtitle delay keys (z/x and sub-step), so mined audio and images match the retimed line.
|
||||||
|
- **Subtitle Sidebar**: mpv keys and mouse actions, like volume keys and double-click fullscreen, work again while the sidebar is open.
|
||||||
|
|
||||||
|
**Docs**
|
||||||
|
- **README**: Refreshed the anime screenshots using the current build.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Internal changes</summary>
|
||||||
|
|
||||||
|
**Internal**
|
||||||
|
- **Packaging Layout**: Packaging inputs now live under `packaging/`, and all build output lands in `dist/`, with packaged apps in `dist/release/`.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## v0.21.0 (2026-10-24)
|
## v0.21.0 (2026-10-24)
|
||||||
|
|
||||||
**Added**
|
**Added**
|
||||||
|
|||||||
@@ -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)).
|
Mining and startup status notifications use `ankiConnect.behavior.notificationType` (see [AnkiConnect](#ankiconnect)).
|
||||||
|
|
||||||
### Plugins
|
|
||||||
|
|
||||||
`plugins` enables [plugins](/plugins) from the `plugins/` folder next to this config file. Each key is a plugin folder name. Changes apply after a restart.
|
|
||||||
|
|
||||||
| Key | Default | What it does |
|
|
||||||
| ----------------------- | ------- | -------------------------------------- |
|
|
||||||
| `plugins.<id>.enabled` | `false` | Load the plugin at startup |
|
|
||||||
| `plugins.<id>.settings` | `{}` | Passed to the plugin as `ctx.settings` |
|
|
||||||
|
|
||||||
### Auto-start overlay
|
### Auto-start overlay
|
||||||
|
|
||||||
When mpv is started by SubMiner or the `subminer` launcher, the launcher passes these settings to the bundled mpv plugin. There is no separate plugin config file. `mpv.autoStartSubMiner` and `mpv.pauseUntilOverlayReady` (see [MPV launcher](#mpv-launcher)) control the background start and the initial pause.
|
When mpv is started by SubMiner or the `subminer` launcher, the launcher passes these settings to the bundled mpv plugin. There is no separate plugin config file. `mpv.autoStartSubMiner` and `mpv.pauseUntilOverlayReady` (see [MPV launcher](#mpv-launcher)) control the background start and the initial pause.
|
||||||
@@ -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`.
|
- `key` uses `KeyboardEvent.code` names (`Space`, `KeyR`, `ArrowRight`) with optional `Ctrl+`, `Alt+`, `Shift+`, `Meta+`. Mouse buttons are `MBTN_LEFT`, `MBTN_MID`, `MBTN_RIGHT`, `MBTN_BACK`, `MBTN_FORWARD`. Scroll wheel keys are `WHEEL_UP`, `WHEEL_DOWN`, `WHEEL_LEFT`, `WHEEL_RIGHT`.
|
||||||
- `command` is any mpv JSON IPC command array. Set it to `null` to disable a default.
|
- `command` is any mpv JSON IPC command array. Set it to `null` to disable a default.
|
||||||
- Commands starting with `__` run inside SubMiner: `__playlist-browser-open`, `__youtube-picker-open`, `__replay-subtitle`, `__play-next-subtitle`, `__runtime-options-open`, `__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.
|
- Unused single-key, mouse button, and scroll wheel bindings from your mpv config also work in the overlay. Your SubMiner bindings win on conflicts.
|
||||||
|
|
||||||
### Shortcuts configuration
|
### Shortcuts configuration
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ Charts for watch time, cards, words, and sessions per day or month, running tota
|
|||||||
|
|
||||||
### Sessions
|
### 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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
@@ -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`.
|
|
||||||
@@ -218,14 +218,6 @@
|
|||||||
"overlayPosition": "top-right" // Position for in-overlay notification cards. Values: top-left | top | top-right
|
"overlayPosition": "top-right" // Position for in-overlay notification cards. Values: top-left | top | top-right
|
||||||
}, // Overlay notification display behavior.
|
}, // Overlay notification display behavior.
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Plugins
|
|
||||||
// JavaScript plugins loaded from <config dir>/plugins/<id>/ at startup.
|
|
||||||
// Plugins run inside SubMiner with full access to your system. Only enable code you trust.
|
|
||||||
// Restart SubMiner after changing plugins.
|
|
||||||
// ==========================================
|
|
||||||
"plugins": {}, // Plugins to load at startup, keyed by directory name under <config dir>/plugins/. Each entry is { "enabled": true, "settings": { ... } }; settings are passed to the plugin as ctx.settings.
|
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Keyboard Shortcuts
|
// Keyboard Shortcuts
|
||||||
// Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
// Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
||||||
|
|||||||
@@ -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.
|
Mouse button names are `MBTN_LEFT`, `MBTN_MID`, `MBTN_RIGHT`, `MBTN_BACK`, and `MBTN_FORWARD`. Scroll wheel names are `WHEEL_UP`, `WHEEL_DOWN`, `WHEEL_LEFT`, and `WHEEL_RIGHT`. See [keybindings](/configuration#keybindings) and [shortcuts configuration](/configuration#shortcuts-configuration) in the config reference.
|
||||||
|
|
||||||
## Automatic mpv bindings
|
## Automatic mpv bindings
|
||||||
|
|||||||
@@ -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 generate-subs video.mkv # make Japanese subtitles from the audio
|
||||||
subminer logs -e # export a log ZIP for bug reports
|
subminer logs -e # export a log ZIP for bug reports
|
||||||
subminer app --setup # reopen first-run setup
|
subminer app --setup # reopen first-run setup
|
||||||
subminer app --plugin-action <id>/<name> # run a plugin action
|
|
||||||
subminer -u # update SubMiner
|
subminer -u # update SubMiner
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
- `subtitle-generation-reference.ts` ranks mpv's loaded text subtitle tracks, excludes signs/songs and forced references, and extracts timing hints with FFmpeg. The overlay and launcher snapshot references only for matching media, including active subtitle delays. Hints guide long-passage cuts with or without VAD; they never limit audio coverage or replace Whisper timestamps.
|
||||||
- `src/main/runtime/subtitle-selection.ts` reads and validates mpv subtitle tracks and applies primary/secondary selections. Its opt-in session shortcut opens the shared overlay modal window, with renderer focus and subtitle suppression handled by the modal registry.
|
- `src/main/runtime/subtitle-selection.ts` reads and validates mpv subtitle tracks and applies primary/secondary selections. Its opt-in session shortcut opens the shared overlay modal window, with renderer focus and subtitle suppression handled by the modal registry.
|
||||||
- `src/shared/session-key-sequences.ts` rejects sequence prefixes reserved by single-key actions. The session-binding compiler reserves configured and built-in overlay keys; `src/main/runtime/session-bindings-runtime.ts` adds active mpv bindings and publishes the effective list to both the plugin artifact and the renderer through `session-bindings:changed`. mpv no-op `ignore` bindings do not reserve prefixes.
|
- `src/shared/session-key-sequences.ts` rejects sequence prefixes reserved by single-key actions. The session-binding compiler reserves configured and built-in overlay keys; `src/main/runtime/session-bindings-runtime.ts` adds active mpv bindings and publishes the effective list to both the plugin artifact and the renderer through `session-bindings:changed`. mpv no-op `ignore` bindings do not reserve prefixes.
|
||||||
- `src/core/services/plugins/` owns user plugins: discovery from `<config dir>/plugins/<id>/plugin.json` for ids enabled in `plugins`, the per-plugin `ctx`, guarded callbacks, and `exec` (no shell, AppImage `LD_LIBRARY_PATH` dropped). `main.ts` supplies the host's deps, attaches each new mpv client so plugin listeners survive client replacement, starts plugins from the app-ready path after logging is configured, and unloads them on `before-quit`. Plugins run in the main process with no sandbox. Plugin actions are the `runPluginAction` session action (keybinding command `__plugin-action:<id>/<name>`, CLI `--plugin-action <id>/<name>`); `runAction` waits for an in-progress load so a cold-start CLI action is not dropped. Each plugin gets `<config dir>/plugin-data/<id>/` as `ctx.dataDir`. `src/main/runtime/plugin-window-runtime.ts` owns plugin windows (`ctx.ui.openWindow`): one window per plugin + HTML path, raised above the overlay like the settings windows, destroyed on unload. `src/main/runtime/plugin-panel-runtime.ts` owns the single `plugin-panel` modal: a new panel aborts the previous handle's signal, updates broadcast to both overlay windows keyed by `panelId`, and the latest content is re-sent once the renderer acknowledges the open.
|
|
||||||
- `src/renderer/` owns overlay rendering and input behavior.
|
- `src/renderer/` owns overlay rendering and input behavior.
|
||||||
- `src/config/` owns config definitions, defaults, loading, and resolution.
|
- `src/config/` owns config definitions, defaults, loading, and resolution.
|
||||||
- `src/types/` owns shared cross-runtime contracts via domain entrypoints; `src/types.ts` stays a compatibility barrel.
|
- `src/types/` owns shared cross-runtime contracts via domain entrypoints; `src/types.ts` stays a compatibility barrel.
|
||||||
|
|||||||
@@ -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/`
|
- AniList tracking + character dictionary: `src/core/services/anilist/`, `src/main/runtime/composers/anilist-*`, `src/main/character-dictionary-runtime.ts`, `src/main/character-dictionary-runtime/`
|
||||||
- TMDB live-action metadata: `src/core/services/tmdb/` (client + exact-title resolver), `src/core/services/immersion-tracker/live-action-link.ts` (links an entry to a TMDB title and merges other holders of the same title). The AniList cover-art fetcher calls the resolver as its fallback; `imm_anime.media_kind` marks the result and keeps the entry out of AniList season repair.
|
- TMDB live-action metadata: `src/core/services/tmdb/` (client + exact-title resolver), `src/core/services/immersion-tracker/live-action-link.ts` (links an entry to a TMDB title and merges other holders of the same title). The AniList cover-art fetcher calls the resolver as its fallback; `imm_anime.media_kind` marks the result and keeps the entry out of AniList season repair.
|
||||||
- Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*`
|
- Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*`
|
||||||
- User plugins: `src/core/services/plugins/` (host, `ctx`, `exec`), `src/main/runtime/plugin-panel-runtime.ts` (panel modal), `src/main/runtime/plugin-window-runtime.ts` (plugin windows), `src/renderer/modals/plugin-panel*.ts`; example in `assets/plugins/`
|
|
||||||
- Window trackers: `src/window-trackers/`
|
- Window trackers: `src/window-trackers/`
|
||||||
- Stats HTTP app: `src/core/services/stats-server.ts`, with route groups and shared route support
|
- Stats HTTP app: `src/core/services/stats-server.ts`, with route groups and shared route support
|
||||||
in `src/core/services/stats-server/`
|
in `src/core/services/stats-server/`
|
||||||
|
|||||||
@@ -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
|
per-window screenshots), mpv over its JSON IPC socket, and app commands through the
|
||||||
normal CLI handoff (`--mine-sentence`, `--toggle-subtitle-sidebar`, ...).
|
normal CLI handoff (`--mine-sentence`, `--toggle-subtitle-sidebar`, ...).
|
||||||
- Scenarios live in `e2e/*.e2e.test.ts` and share one session per file through
|
- Scenarios live in `e2e/*.e2e.test.ts` and share one session per file through
|
||||||
`startE2eSession()`. Pass `config` to override settings for a scenario file, and `files`
|
`startE2eSession()`. Pass `config` to override settings for a scenario file.
|
||||||
to write files such as plugins into the isolated config dir before launch.
|
|
||||||
- On a failed startup the temp dir is kept and its path is in the error. Set
|
- On a failed startup the temp dir is kept and its path is in the error. Set
|
||||||
`SUBMINER_E2E_KEEP=1` to keep it after any run (`app-stdio.log`, `app.log`, `mpv.log`).
|
`SUBMINER_E2E_KEEP=1` to keep it after any run (`app-stdio.log`, `app.log`, `mpv.log`).
|
||||||
|
|
||||||
|
|||||||
@@ -37,10 +37,12 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A recording AnkiConnect stand-in: an empty collection with one deck and one
|
// A recording AnkiConnect stand-in: one deck and one Lapis-shaped note type.
|
||||||
// Lapis-shaped note type. Scenarios assert on `requests`, not on stored notes.
|
// 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> {
|
export async function startFakeAnki(): Promise<FakeAnki> {
|
||||||
const requests: AnkiRequest[] = [];
|
const requests: AnkiRequest[] = [];
|
||||||
|
const addedNotes = new Map<number, Record<string, unknown>>();
|
||||||
let nextNoteId = 1000;
|
let nextNoteId = 1000;
|
||||||
|
|
||||||
const handle = (body: unknown): unknown => {
|
const handle = (body: unknown): unknown => {
|
||||||
@@ -59,10 +61,38 @@ export async function startFakeAnki(): Promise<FakeAnki> {
|
|||||||
return [FAKE_ANKI_MODEL];
|
return [FAKE_ANKI_MODEL];
|
||||||
case 'modelFieldNames':
|
case 'modelFieldNames':
|
||||||
return MODEL_FIELDS;
|
return MODEL_FIELDS;
|
||||||
case 'addNote':
|
case 'addNote': {
|
||||||
return nextNoteId++;
|
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 'findNotes':
|
||||||
case 'notesInfo':
|
|
||||||
return [];
|
return [];
|
||||||
case 'storeMediaFile':
|
case 'storeMediaFile':
|
||||||
return params.filename ?? null;
|
return params.filename ?? null;
|
||||||
|
|||||||
@@ -28,8 +28,6 @@ type JsonValue = string | number | boolean | null | JsonValue[] | JsonObject;
|
|||||||
export type E2eSessionOptions = {
|
export type E2eSessionOptions = {
|
||||||
/** Deep-merged over the harness config, for scenarios that need other settings. */
|
/** Deep-merged over the harness config, for scenarios that need other settings. */
|
||||||
config?: JsonObject;
|
config?: JsonObject;
|
||||||
/** Files written before launch, keyed by path relative to the config dir (e.g. plugins). */
|
|
||||||
files?: Record<string, string>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AppTarget = {
|
export type AppTarget = {
|
||||||
@@ -195,15 +193,6 @@ export async function startE2eSession(options: E2eSessionOptions = {}): Promise<
|
|||||||
|
|
||||||
const config = mergeConfig(buildConfig(mpvSocketPath, anki.url), options.config ?? {});
|
const config = mergeConfig(buildConfig(mpvSocketPath, anki.url), options.config ?? {});
|
||||||
fs.writeFileSync(path.join(userDataDir, 'config.jsonc'), JSON.stringify(config, null, 2));
|
fs.writeFileSync(path.join(userDataDir, 'config.jsonc'), JSON.stringify(config, null, 2));
|
||||||
for (const [relativePath, content] of Object.entries(options.files ?? {})) {
|
|
||||||
const filePath = path.resolve(userDataDir, relativePath);
|
|
||||||
const inside = path.relative(userDataDir, filePath);
|
|
||||||
if (!inside || inside.startsWith('..') || path.isAbsolute(inside)) {
|
|
||||||
throw new Error(`E2E file path must stay inside the config dir: ${relativePath}`);
|
|
||||||
}
|
|
||||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
||||||
fs.writeFileSync(filePath, content);
|
|
||||||
}
|
|
||||||
|
|
||||||
const env: EnvDelta = {
|
const env: EnvDelta = {
|
||||||
set: {
|
set: {
|
||||||
|
|||||||
@@ -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 {
|
function hasToken(tokens: RenderedToken[], surface: string, headword: string): boolean {
|
||||||
return tokens.some((token) => token.surface === surface && token.headword === headword);
|
return tokens.some((token) => token.surface === surface && token.headword === headword);
|
||||||
}
|
}
|
||||||
@@ -126,10 +134,10 @@ test(
|
|||||||
session.anki.requests.length = 0;
|
session.anki.requests.length = 0;
|
||||||
|
|
||||||
await session.runAppCommand('--mine-sentence');
|
await session.runAppCommand('--mine-sentence');
|
||||||
const update = await waitUntil(
|
const update = await waitUntil(() => findMediaUpdate(session), {
|
||||||
() => session.anki.requests.find((request) => request.action === 'updateNoteFields'),
|
description: 'media fields to be written to the mined note',
|
||||||
{ description: 'media fields to be written to the mined note', timeoutMs: 30_000 },
|
timeoutMs: 30_000,
|
||||||
);
|
});
|
||||||
|
|
||||||
const added = session.anki.requests.filter((request) => request.action === 'addNote');
|
const added = session.anki.requests.filter((request) => request.action === 'addNote');
|
||||||
assert.equal(added.length, 1);
|
assert.equal(added.length, 1);
|
||||||
@@ -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(
|
test(
|
||||||
'open subtitle sidebar lists the parsed cues and follows playback',
|
'open subtitle sidebar lists the parsed cues and follows playback',
|
||||||
{ timeout: TEST_TIMEOUT_MS },
|
{ timeout: TEST_TIMEOUT_MS },
|
||||||
@@ -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.
|
// Runs last: it leaves the lookup popup open over the overlay.
|
||||||
test(
|
test(
|
||||||
'holding Shift over a word opens the Yomitan popup with its dictionary entry',
|
'holding Shift over a word opens the Yomitan popup with its dictionary entry',
|
||||||
|
|||||||
@@ -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' },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -174,8 +174,9 @@ test('runAppCommandCaptureOutput strips ELECTRON_RUN_AS_NODE from app child env'
|
|||||||
|
|
||||||
test(
|
test(
|
||||||
'runAppCommandCaptureOutput transports Linux AppImage args through environment',
|
'runAppCommandCaptureOutput transports Linux AppImage args through environment',
|
||||||
{ skip: process.platform !== 'linux' },
|
|
||||||
async () => {
|
async () => {
|
||||||
|
// Bun's node:test shim ignores the `skip` option, so gate the platform here.
|
||||||
|
if (process.platform !== 'linux') return;
|
||||||
await withTempCase(({ dir }) => {
|
await withTempCase(({ dir }) => {
|
||||||
const appPath = writeFakeApp(
|
const appPath = writeFakeApp(
|
||||||
dir,
|
dir,
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
"name": "subminer",
|
"name": "subminer",
|
||||||
"productName": "SubMiner",
|
"productName": "SubMiner",
|
||||||
"desktopName": "SubMiner.desktop",
|
"desktopName": "SubMiner.desktop",
|
||||||
"version": "0.21.0",
|
"version": "0.21.1",
|
||||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||||
"packageManager": "bun@1.3.5",
|
"packageManager": "bun@1.3.5",
|
||||||
"main": "dist/main-entry.js",
|
"main": "dist/main-entry.js",
|
||||||
|
|||||||
@@ -1,47 +1,19 @@
|
|||||||
## Highlights
|
## Highlights
|
||||||
### Added
|
### Changed
|
||||||
- **Hachidori Dictionary Backend**: Bundled Hachidori backend as an alternative to Yomitan. Select it with `dictionaryBackend` and restart SubMiner.
|
- **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.
|
||||||
- Works with subtitle scanning, popups, frequency highlighting, character dictionaries, Anki mining, and stats dashboard mining.
|
|
||||||
- First-run setup builds Hachidori's Anki template from your existing SubMiner deck, tags, and field mappings.
|
|
||||||
- Can link an external Hachidori app, browser, or Docker host while Anki mining stays in SubMiner.
|
|
||||||
- The tray and dictionary-settings shortcut open the selected backend's settings. `--hachidori` and `--yomitan` open a specific one.
|
|
||||||
- **Jellyfin Subtitle Generation**: Japanese subtitle generation now works while playing Jellyfin episodes, with both direct-play and transcoded playback.
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- **Media Timing Review**: Opens on the first try and keeps playback paused while open, even when a dictionary popup closes.
|
- **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.
|
||||||
- **Jellyfin Terminal Playback**:
|
- **Subtitle Sidebar**: mpv keys and mouse buttons, such as volume keys and double-click fullscreen, work again while the sidebar is open.
|
||||||
- `subminer jellyfin -p` lists libraries when SubMiner is already running, and picker image previews work again.
|
|
||||||
- Real errors, like an expired session or an unreachable server, now appear instead of an empty list.
|
|
||||||
- Watch progress and played status reach Jellyfin even when cast discovery is off.
|
|
||||||
- When it starts SubMiner itself, SubMiner now shows a tray icon and quits when mpv closes.
|
|
||||||
- The rofi search step is now a compact input box.
|
|
||||||
- **macOS Casting**: Jellyfin casting finds mpv when SubMiner is opened from Finder, the Dock, or Spotlight. `mpv.executablePath` and `SUBMINER_MPV_PATH` are now honored on macOS and Linux.
|
|
||||||
- **macOS Launcher PATH**: The command-line launcher no longer shows "Not on PATH" when SubMiner is opened from Finder or the Dock.
|
|
||||||
- **Keyboard-Only Mode**: `h`, `l`, and the arrow keys type normally in modals and text fields instead of seeking mpv. Toggling the mode inside a modal no longer turns on a hidden keyboard-only mode.
|
|
||||||
- **Halfwidth Katakana**: Halfwidth katakana in subtitles now displays as fullwidth, including in mined cards and the stats dashboard. Stats sentence search matches katakana in either width.
|
|
||||||
- **Subtitle Annotations**: Lines shown while two cues overlap now get annotations right away. Prefetching no longer restarts during startup.
|
|
||||||
- **Subtitle Sidebar**: The open sidebar does far less work during playback.
|
|
||||||
- **Hyprland and Sway**: A slow or hung `hyprctl` or `swaymsg` no longer freezes the app.
|
|
||||||
- **Stats Mining**: Cards mined from the stats dashboard keep the selected history line and media.
|
|
||||||
- **Security Updates**: Updated Electron, undici, and follow-redirects to fix new security advisories.
|
|
||||||
|
|
||||||
### Docs
|
### Docs
|
||||||
- **Hachidori Setup**: Covers backend selection, Hachidori dictionary and Anki setup, settings flags and shortcuts, and linking an external host.
|
- **README**: Refreshed the anime screenshots using the current build.
|
||||||
- **Jellyfin**: The docs now say SubMiner joins the cast menu at startup only when both `startupWarmups.jellyfinRemoteSession` and `jellyfin.remoteControlAutoConnect` are on. Otherwise, enable **Jellyfin Discovery** in the tray.
|
|
||||||
- **Casting and mpv**: Added how casting finds mpv on each platform, a troubleshooting entry for casts that never start mpv, and a note on the macOS 27 Accessibility list rename.
|
|
||||||
- **Jimaku Environment Variables**: Removed the unused `SUBMINER_JIMAKU_*` variables from the docs. Set the key with `jimaku.apiKey` or `jimaku.apiKeyCommand` instead.
|
|
||||||
|
|
||||||
## What's Changed
|
## What's Changed
|
||||||
|
|
||||||
- feat(dictionary): add Hachidori backend support by @ksyasuda in #266
|
- fix(overlay): forward mpv input while subtitle sidebar is open by @ksyasuda in #300
|
||||||
- fix(jellyfin): make terminal play work with a running app and report progress by @ksyasuda in #288
|
- fix(subtitles): follow mpv sub-delay in mining, sidebar, and navigation by @ksyasuda in #302
|
||||||
- fix(overlay): stop Hyprland/Sway tracker polls from blocking the main thread by @ksyasuda in #289
|
- feat(stats): redesign expanded session view with density chart by @ksyasuda in #304
|
||||||
- fix(overlay): poll cheap playback state in the open subtitle sidebar by @ksyasuda in #290
|
|
||||||
- fix(overlay): keep keyboard mode from swallowing modal and text-field keys by @ksyasuda in #291
|
|
||||||
- refactor: remove dead code and enable noUnusedLocals by @ksyasuda in #292
|
|
||||||
- feat(subtitles): generate Japanese subtitles during Jellyfin playback by @ksyasuda in #294
|
|
||||||
- fix(subtitles): widen halfwidth katakana where subtitle text enters the app by @ksyasuda in #295
|
|
||||||
- fix(jellyfin): find mpv when SubMiner is launched from Finder by @ksyasuda in #298
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import {
|
|||||||
} from './anki-field-config';
|
} from './anki-field-config';
|
||||||
import { createLogger } from './logger';
|
import { createLogger } from './logger';
|
||||||
import { captureLiveSubtitleMiningContext } from './core/services/mining';
|
import { captureLiveSubtitleMiningContext } from './core/services/mining';
|
||||||
|
import { shiftSubtitleRangeToVideoClock } from './core/services/subtitle-clock';
|
||||||
import {
|
import {
|
||||||
createUiFeedbackState,
|
createUiFeedbackState,
|
||||||
beginUpdateProgress,
|
beginUpdateProgress,
|
||||||
@@ -1029,10 +1030,10 @@ export class AnkiIntegration {
|
|||||||
Number.isFinite(this.mpvClient.currentSubEnd) &&
|
Number.isFinite(this.mpvClient.currentSubEnd) &&
|
||||||
this.mpvClient.currentSubEnd > this.mpvClient.currentSubStart
|
this.mpvClient.currentSubEnd > this.mpvClient.currentSubStart
|
||||||
) {
|
) {
|
||||||
return {
|
return shiftSubtitleRangeToVideoClock(
|
||||||
startTime: this.mpvClient.currentSubStart,
|
{ startTime: this.mpvClient.currentSubStart, endTime: this.mpvClient.currentSubEnd },
|
||||||
endTime: this.mpvClient.currentSubEnd,
|
this.mpvClient.currentSubDelay,
|
||||||
};
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentTime = this.mpvClient.currentTimePos || 0;
|
const currentTime = this.mpvClient.currentTimePos || 0;
|
||||||
@@ -1672,7 +1673,7 @@ export class AnkiIntegration {
|
|||||||
if (this.config.fields?.miscInfo) {
|
if (this.config.fields?.miscInfo) {
|
||||||
result.miscInfoValue = this.formatMiscInfoPattern(
|
result.miscInfoValue = this.formatMiscInfoPattern(
|
||||||
audioFilename,
|
audioFilename,
|
||||||
this.mpvClient.currentSubStart,
|
this.mpvClient.currentSubStart + this.mpvClient.currentSubDelay,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1695,7 +1696,7 @@ export class AnkiIntegration {
|
|||||||
if (this.config.fields?.miscInfo && !result.miscInfoValue) {
|
if (this.config.fields?.miscInfo && !result.miscInfoValue) {
|
||||||
result.miscInfoValue = this.formatMiscInfoPattern(
|
result.miscInfoValue = this.formatMiscInfoPattern(
|
||||||
imageFilename,
|
imageFilename,
|
||||||
this.mpvClient.currentSubStart,
|
this.mpvClient.currentSubStart + this.mpvClient.currentSubDelay,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
type MediaTimingReviewRequest,
|
type MediaTimingReviewRequest,
|
||||||
type WordCardKind,
|
type WordCardKind,
|
||||||
} from '../types/anki';
|
} from '../types/anki';
|
||||||
|
import { shiftSubtitleRangeToVideoClock } from '../core/services/subtitle-clock';
|
||||||
import { createLogger } from '../logger';
|
import { createLogger } from '../logger';
|
||||||
import type { MediaInput } from '../media-input';
|
import type { MediaInput } from '../media-input';
|
||||||
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
||||||
@@ -428,10 +429,12 @@ export class CardCreationService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let startTime = mpvClient.currentSubStart;
|
let { startTime, endTime } = shiftSubtitleRangeToVideoClock(
|
||||||
let endTime = mpvClient.currentSubEnd;
|
{ 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 currentTime = mpvClient.currentTimePos || 0;
|
||||||
const fallback = this.deps.getFallbackDurationSeconds() / 2;
|
const fallback = this.deps.getFallbackDurationSeconds() / 2;
|
||||||
startTime = currentTime - fallback;
|
startTime = currentTime - fallback;
|
||||||
|
|||||||
@@ -340,14 +340,6 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
|||||||
assert.equal(shouldStartApp(sessionAction), true);
|
assert.equal(shouldStartApp(sessionAction), true);
|
||||||
assert.equal(commandNeedsOverlayRuntime(sessionAction), true);
|
assert.equal(commandNeedsOverlayRuntime(sessionAction), true);
|
||||||
|
|
||||||
const pluginAction = parseArgs(['--plugin-action', 'anime/open']);
|
|
||||||
assert.equal(pluginAction.pluginAction, 'anime/open');
|
|
||||||
assert.equal(parseArgs(['--plugin-action=anime/open']).pluginAction, 'anime/open');
|
|
||||||
assert.equal(hasExplicitCommand(pluginAction), true);
|
|
||||||
assert.equal(shouldStartApp(pluginAction), true);
|
|
||||||
// The plugin decides what it needs; a window-only action must not force the overlay up.
|
|
||||||
assert.equal(commandNeedsOverlayRuntime(pluginAction), false);
|
|
||||||
|
|
||||||
const toggleStatsOverlayRuntime = parseArgs(['--toggle-stats-overlay']);
|
const toggleStatsOverlayRuntime = parseArgs(['--toggle-stats-overlay']);
|
||||||
assert.equal(commandNeedsOverlayRuntime(toggleStatsOverlayRuntime), true);
|
assert.equal(commandNeedsOverlayRuntime(toggleStatsOverlayRuntime), true);
|
||||||
|
|
||||||
|
|||||||
@@ -48,8 +48,6 @@ export interface CliArgs {
|
|||||||
cycleRuntimeOptionId?: string;
|
cycleRuntimeOptionId?: string;
|
||||||
cycleRuntimeOptionDirection?: 1 | -1;
|
cycleRuntimeOptionDirection?: 1 | -1;
|
||||||
sessionAction?: SessionActionDispatchRequest;
|
sessionAction?: SessionActionDispatchRequest;
|
||||||
/** `<plugin id>/<action name>` to run, like a `__plugin-action:` keybinding. */
|
|
||||||
pluginAction?: string;
|
|
||||||
copySubtitleCount?: number;
|
copySubtitleCount?: number;
|
||||||
mineSentenceCount?: number;
|
mineSentenceCount?: number;
|
||||||
anilistStatus: boolean;
|
anilistStatus: boolean;
|
||||||
@@ -340,12 +338,6 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||||||
} else if (arg === '--session-action') {
|
} else if (arg === '--session-action') {
|
||||||
const parsed = parseSessionAction(readValue(argv[i + 1]));
|
const parsed = parseSessionAction(readValue(argv[i + 1]));
|
||||||
if (parsed) args.sessionAction = parsed;
|
if (parsed) args.sessionAction = parsed;
|
||||||
} else if (arg.startsWith('--plugin-action=')) {
|
|
||||||
const value = arg.slice('--plugin-action='.length).trim();
|
|
||||||
if (value) args.pluginAction = value;
|
|
||||||
} else if (arg === '--plugin-action') {
|
|
||||||
const value = readValue(argv[i + 1])?.trim();
|
|
||||||
if (value) args.pluginAction = value;
|
|
||||||
} else if (arg.startsWith('--copy-subtitle-count=')) {
|
} else if (arg.startsWith('--copy-subtitle-count=')) {
|
||||||
const value = Number(arg.split('=', 2)[1]);
|
const value = Number(arg.split('=', 2)[1]);
|
||||||
if (Number.isInteger(value) && value > 0) args.copySubtitleCount = value;
|
if (Number.isInteger(value) && value > 0) args.copySubtitleCount = value;
|
||||||
@@ -609,7 +601,6 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
|||||||
args.playbackFeedback !== undefined ||
|
args.playbackFeedback !== undefined ||
|
||||||
args.cycleRuntimeOptionId !== undefined ||
|
args.cycleRuntimeOptionId !== undefined ||
|
||||||
args.sessionAction !== undefined ||
|
args.sessionAction !== undefined ||
|
||||||
args.pluginAction !== undefined ||
|
|
||||||
args.copySubtitleCount !== undefined ||
|
args.copySubtitleCount !== undefined ||
|
||||||
args.mineSentenceCount !== undefined ||
|
args.mineSentenceCount !== undefined ||
|
||||||
args.anilistStatus ||
|
args.anilistStatus ||
|
||||||
@@ -691,7 +682,6 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
|||||||
args.playbackFeedback === undefined &&
|
args.playbackFeedback === undefined &&
|
||||||
args.cycleRuntimeOptionId === undefined &&
|
args.cycleRuntimeOptionId === undefined &&
|
||||||
args.sessionAction === undefined &&
|
args.sessionAction === undefined &&
|
||||||
args.pluginAction === undefined &&
|
|
||||||
args.copySubtitleCount === undefined &&
|
args.copySubtitleCount === undefined &&
|
||||||
args.mineSentenceCount === undefined &&
|
args.mineSentenceCount === undefined &&
|
||||||
!args.anilistStatus &&
|
!args.anilistStatus &&
|
||||||
@@ -762,7 +752,6 @@ export function shouldStartApp(args: CliArgs): boolean {
|
|||||||
args.playbackFeedback !== undefined ||
|
args.playbackFeedback !== undefined ||
|
||||||
args.cycleRuntimeOptionId !== undefined ||
|
args.cycleRuntimeOptionId !== undefined ||
|
||||||
args.sessionAction !== undefined ||
|
args.sessionAction !== undefined ||
|
||||||
args.pluginAction !== undefined ||
|
|
||||||
args.copySubtitleCount !== undefined ||
|
args.copySubtitleCount !== undefined ||
|
||||||
args.mineSentenceCount !== undefined ||
|
args.mineSentenceCount !== undefined ||
|
||||||
args.dictionary ||
|
args.dictionary ||
|
||||||
@@ -830,7 +819,6 @@ export function shouldRunDictionarySettingsOnlyStartup(args: CliArgs): boolean {
|
|||||||
args.playbackFeedback === undefined &&
|
args.playbackFeedback === undefined &&
|
||||||
args.cycleRuntimeOptionId === undefined &&
|
args.cycleRuntimeOptionId === undefined &&
|
||||||
args.sessionAction === undefined &&
|
args.sessionAction === undefined &&
|
||||||
args.pluginAction === undefined &&
|
|
||||||
args.copySubtitleCount === undefined &&
|
args.copySubtitleCount === undefined &&
|
||||||
args.mineSentenceCount === undefined &&
|
args.mineSentenceCount === undefined &&
|
||||||
!args.anilistStatus &&
|
!args.anilistStatus &&
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ ${B}Mining${R}
|
|||||||
--open-session-help Open session help modal
|
--open-session-help Open session help modal
|
||||||
--open-controller-select Open controller select modal
|
--open-controller-select Open controller select modal
|
||||||
--open-controller-debug Open controller debug modal
|
--open-controller-debug Open controller debug modal
|
||||||
--plugin-action ${D}<id>/<name>${R} Run a plugin action
|
|
||||||
|
|
||||||
${B}AniList${R}
|
${B}AniList${R}
|
||||||
--anilist-setup Open AniList authentication flow
|
--anilist-setup Open AniList authentication flow
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ const {
|
|||||||
startupWarmups,
|
startupWarmups,
|
||||||
updates,
|
updates,
|
||||||
notifications,
|
notifications,
|
||||||
plugins,
|
|
||||||
auto_start_overlay,
|
auto_start_overlay,
|
||||||
} = CORE_DEFAULT_CONFIG;
|
} = CORE_DEFAULT_CONFIG;
|
||||||
const {
|
const {
|
||||||
@@ -75,7 +74,6 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
|
|||||||
startupWarmups,
|
startupWarmups,
|
||||||
updates,
|
updates,
|
||||||
notifications,
|
notifications,
|
||||||
plugins,
|
|
||||||
subtitleStyle,
|
subtitleStyle,
|
||||||
subtitleSidebar,
|
subtitleSidebar,
|
||||||
auto_start_overlay,
|
auto_start_overlay,
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
|||||||
| 'startupWarmups'
|
| 'startupWarmups'
|
||||||
| 'updates'
|
| 'updates'
|
||||||
| 'notifications'
|
| 'notifications'
|
||||||
| 'plugins'
|
|
||||||
| 'auto_start_overlay'
|
| 'auto_start_overlay'
|
||||||
> = {
|
> = {
|
||||||
dictionaryBackend: 'yomitan',
|
dictionaryBackend: 'yomitan',
|
||||||
@@ -146,6 +145,5 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
|||||||
notifications: {
|
notifications: {
|
||||||
overlayPosition: 'top-right',
|
overlayPosition: 'top-right',
|
||||||
},
|
},
|
||||||
plugins: {},
|
|
||||||
auto_start_overlay: true,
|
auto_start_overlay: true,
|
||||||
};
|
};
|
||||||
@@ -546,13 +546,6 @@ export function buildCoreConfigOptionRegistry(
|
|||||||
defaultValue: defaultConfig.notifications.overlayPosition,
|
defaultValue: defaultConfig.notifications.overlayPosition,
|
||||||
description: 'Position for in-overlay notification cards.',
|
description: 'Position for in-overlay notification cards.',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'plugins',
|
|
||||||
kind: 'object',
|
|
||||||
defaultValue: defaultConfig.plugins,
|
|
||||||
description:
|
|
||||||
'Plugins to load at startup, keyed by directory name under <config dir>/plugins/. Each entry is { "enabled": true, "settings": { ... } }; settings are passed to the plugin as ctx.settings.',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'shortcuts.multiCopyTimeoutMs',
|
path: 'shortcuts.multiCopyTimeoutMs',
|
||||||
kind: 'number',
|
kind: 'number',
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ export const SPECIAL_COMMANDS = {
|
|||||||
ANIMETOSHO_OPEN: '__animetosho-open',
|
ANIMETOSHO_OPEN: '__animetosho-open',
|
||||||
TSUKIHIME_OPEN: '__tsukihime-open',
|
TSUKIHIME_OPEN: '__tsukihime-open',
|
||||||
RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
|
RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
|
||||||
PLUGIN_ACTION_PREFIX: '__plugin-action:',
|
|
||||||
REPLAY_SUBTITLE: '__replay-subtitle',
|
REPLAY_SUBTITLE: '__replay-subtitle',
|
||||||
PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
|
PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
|
||||||
YOUTUBE_PICKER_OPEN: '__youtube-picker-open',
|
YOUTUBE_PICKER_OPEN: '__youtube-picker-open',
|
||||||
|
|||||||
@@ -92,15 +92,6 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
|||||||
notes: ['Hot-reload: position changes apply to the next overlay notification.'],
|
notes: ['Hot-reload: position changes apply to the next overlay notification.'],
|
||||||
key: 'notifications',
|
key: 'notifications',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: 'Plugins',
|
|
||||||
description: [
|
|
||||||
'JavaScript plugins loaded from <config dir>/plugins/<id>/ at startup.',
|
|
||||||
'Plugins run inside SubMiner with full access to your system. Only enable code you trust.',
|
|
||||||
],
|
|
||||||
notes: ['Restart SubMiner after changing plugins.'],
|
|
||||||
key: 'plugins',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: 'Keyboard Shortcuts',
|
title: 'Keyboard Shortcuts',
|
||||||
description: ['Overlay keyboard shortcuts. Set a shortcut to null to disable.'],
|
description: ['Overlay keyboard shortcuts. Set a shortcut to null to disable.'],
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { createResolveContext } from './resolve/context';
|
|||||||
import { applyCoreDomainConfig } from './resolve/core-domains';
|
import { applyCoreDomainConfig } from './resolve/core-domains';
|
||||||
import { applyImmersionTrackingConfig } from './resolve/immersion-tracking';
|
import { applyImmersionTrackingConfig } from './resolve/immersion-tracking';
|
||||||
import { applyIntegrationConfig } from './resolve/integrations';
|
import { applyIntegrationConfig } from './resolve/integrations';
|
||||||
import { applyPluginsConfig } from './resolve/plugins';
|
|
||||||
import { applyStatsConfig } from './resolve/stats';
|
import { applyStatsConfig } from './resolve/stats';
|
||||||
import { applySubtitleDomainConfig } from './resolve/subtitle-domains';
|
import { applySubtitleDomainConfig } from './resolve/subtitle-domains';
|
||||||
import { applyTopLevelConfig } from './resolve/top-level';
|
import { applyTopLevelConfig } from './resolve/top-level';
|
||||||
@@ -16,7 +15,6 @@ const APPLY_RESOLVE_STEPS = [
|
|||||||
applyIntegrationConfig,
|
applyIntegrationConfig,
|
||||||
applyImmersionTrackingConfig,
|
applyImmersionTrackingConfig,
|
||||||
applyStatsConfig,
|
applyStatsConfig,
|
||||||
applyPluginsConfig,
|
|
||||||
applyAnkiConnectResolution,
|
applyAnkiConnectResolution,
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
@@ -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'],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -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 } : {},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -81,7 +81,6 @@ const JSON_OBJECT_FIELDS = new Set([
|
|||||||
'keybindings',
|
'keybindings',
|
||||||
'controller.bindings',
|
'controller.bindings',
|
||||||
'controller.profiles',
|
'controller.profiles',
|
||||||
'plugins',
|
|
||||||
'ankiConnect.knownWords.decks',
|
'ankiConnect.knownWords.decks',
|
||||||
'subtitleStyle.css',
|
'subtitleStyle.css',
|
||||||
'subtitleStyle.secondary.css',
|
'subtitleStyle.secondary.css',
|
||||||
@@ -442,9 +441,6 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
|||||||
if (path.startsWith('notifications.')) {
|
if (path.startsWith('notifications.')) {
|
||||||
return { category: 'behavior', section: 'Notifications' };
|
return { category: 'behavior', section: 'Notifications' };
|
||||||
}
|
}
|
||||||
if (path === 'plugins') {
|
|
||||||
return { category: 'advanced', section: 'Plugins' };
|
|
||||||
}
|
|
||||||
if (path === 'mpv.aniskipButtonKey') {
|
if (path === 'mpv.aniskipButtonKey') {
|
||||||
return { category: 'input', section: 'Overlay Shortcuts' };
|
return { category: 'input', section: 'Overlay Shortcuts' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ function makeDeps(overrides: Partial<AppReadyRuntimeDeps> = {}) {
|
|||||||
loadSubtitlePosition: () => calls.push('loadSubtitlePosition'),
|
loadSubtitlePosition: () => calls.push('loadSubtitlePosition'),
|
||||||
resolveKeybindings: () => calls.push('resolveKeybindings'),
|
resolveKeybindings: () => calls.push('resolveKeybindings'),
|
||||||
createMpvClient: () => calls.push('createMpvClient'),
|
createMpvClient: () => calls.push('createMpvClient'),
|
||||||
startPlugins: () => calls.push('startPlugins'),
|
|
||||||
reloadConfig: () => calls.push('reloadConfig'),
|
reloadConfig: () => calls.push('reloadConfig'),
|
||||||
getResolvedConfig: () => ({
|
getResolvedConfig: () => ({
|
||||||
websocket: { enabled: 'auto' },
|
websocket: { enabled: 'auto' },
|
||||||
@@ -163,7 +162,6 @@ test('runAppReadyRuntime skips heavy startup when shouldSkipHeavyStartup returns
|
|||||||
assert.equal(calls.includes('loadSubtitlePosition'), false);
|
assert.equal(calls.includes('loadSubtitlePosition'), false);
|
||||||
assert.equal(calls.includes('resolveKeybindings'), false);
|
assert.equal(calls.includes('resolveKeybindings'), false);
|
||||||
assert.equal(calls.includes('createMpvClient'), false);
|
assert.equal(calls.includes('createMpvClient'), false);
|
||||||
assert.equal(calls.includes('startPlugins'), false);
|
|
||||||
assert.equal(calls.includes('initRuntimeOptionsManager'), false);
|
assert.equal(calls.includes('initRuntimeOptionsManager'), false);
|
||||||
assert.equal(calls.includes('createImmersionTracker'), false);
|
assert.equal(calls.includes('createImmersionTracker'), false);
|
||||||
assert.equal(calls.includes('startJellyfinRemoteSession'), false);
|
assert.equal(calls.includes('startJellyfinRemoteSession'), false);
|
||||||
@@ -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('loadSubtitlePosition') < calls.indexOf('startBackgroundWarmups'));
|
||||||
assert.ok(calls.indexOf('createMpvClient') < calls.indexOf('startBackgroundWarmups'));
|
assert.ok(calls.indexOf('createMpvClient') < calls.indexOf('startBackgroundWarmups'));
|
||||||
assert.ok(calls.includes('setLogLevel:info:config'));
|
|
||||||
assert.ok(calls.indexOf('setLogLevel:info:config') < calls.indexOf('startPlugins'));
|
|
||||||
assert.ok(calls.indexOf('startPlugins') < calls.indexOf('startBackgroundWarmups'));
|
|
||||||
assert.ok(calls.indexOf('initializeOverlayRuntime') < calls.indexOf('startBackgroundWarmups'));
|
assert.ok(calls.indexOf('initializeOverlayRuntime') < calls.indexOf('startBackgroundWarmups'));
|
||||||
assert.ok(calls.indexOf('startBackgroundWarmups') < calls.indexOf('handleInitialArgs'));
|
assert.ok(calls.indexOf('startBackgroundWarmups') < calls.indexOf('handleInitialArgs'));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 () => {
|
test('handleCliCommand dispatches mark-watched session action', async () => {
|
||||||
let request: unknown = null;
|
let request: unknown = null;
|
||||||
const { deps } = createDeps({
|
const { deps } = createDeps({
|
||||||
|
|||||||
@@ -422,12 +422,6 @@ export function handleCliCommand(
|
|||||||
`sessionAction:${args.sessionAction.actionId}`,
|
`sessionAction:${args.sessionAction.actionId}`,
|
||||||
'Session action failed',
|
'Session action failed',
|
||||||
);
|
);
|
||||||
} else if (args.pluginAction) {
|
|
||||||
dispatchCliSessionAction(
|
|
||||||
{ actionId: 'runPluginAction', payload: { pluginActionId: args.pluginAction } },
|
|
||||||
`pluginAction:${args.pluginAction}`,
|
|
||||||
'Plugin action failed',
|
|
||||||
);
|
|
||||||
} else if (args.toggle || args.toggleVisibleOverlay) {
|
} else if (args.toggle || args.toggleVisibleOverlay) {
|
||||||
deps.toggleVisibleOverlay();
|
deps.toggleVisibleOverlay();
|
||||||
} else if (args.togglePrimarySubtitleBar) {
|
} else if (args.togglePrimarySubtitleBar) {
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export {
|
|||||||
subtitleCueListSeekTime,
|
subtitleCueListSeekTime,
|
||||||
subtitleCueSeekTime,
|
subtitleCueSeekTime,
|
||||||
} from './subtitle-cue-navigation';
|
} from './subtitle-cue-navigation';
|
||||||
|
export { shiftSubtitleRangeToVideoClock, toSubtitleClock, toVideoClock } from './subtitle-clock';
|
||||||
export { createFrequencyDictionaryLookup } from './frequency-dictionary';
|
export { createFrequencyDictionaryLookup } from './frequency-dictionary';
|
||||||
export { createJlptVocabularyLookup } from './jlpt-vocab';
|
export { createJlptVocabularyLookup } from './jlpt-vocab';
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ function createSubtitleSidebarSnapshotFixture(): SubtitleSidebarSnapshot {
|
|||||||
cuesRevision: 1,
|
cuesRevision: 1,
|
||||||
cues: [],
|
cues: [],
|
||||||
currentTimeSec: null,
|
currentTimeSec: null,
|
||||||
|
subDelay: 0,
|
||||||
currentSubtitle: { text: '', startTime: null, endTime: null },
|
currentSubtitle: { text: '', startTime: null, endTime: null },
|
||||||
config: {
|
config: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@@ -575,8 +576,8 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
|
|||||||
const openedModals: unknown[] = [];
|
const openedModals: unknown[] = [];
|
||||||
registerIpcHandlers(
|
registerIpcHandlers(
|
||||||
{
|
{
|
||||||
onOverlayModalClosed: (modal, _senderWindow, instanceId) => {
|
onOverlayModalClosed: (modal) => {
|
||||||
closedModals.push(instanceId === undefined ? modal : [modal, instanceId]);
|
closedModals.push(modal);
|
||||||
},
|
},
|
||||||
onOverlayModalOpened: (modal) => {
|
onOverlayModalOpened: (modal) => {
|
||||||
openedModals.push(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)!({}, 'not-a-modal');
|
||||||
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'subsync');
|
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'subsync');
|
||||||
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'kiku');
|
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'kiku');
|
||||||
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'plugin-panel', 4);
|
assert.deepEqual(closedModals, ['subsync', 'kiku']);
|
||||||
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'plugin-panel', 'bad');
|
|
||||||
assert.deepEqual(closedModals, ['subsync', 'kiku', ['plugin-panel', 4], 'plugin-panel']);
|
|
||||||
|
|
||||||
handlers.on.get(IPC_CHANNELS.command.overlayModalOpened)!({}, 'bad');
|
handlers.on.get(IPC_CHANNELS.command.overlayModalOpened)!({}, 'bad');
|
||||||
handlers.on.get(IPC_CHANNELS.command.overlayModalOpened)!({}, 'subsync');
|
handlers.on.get(IPC_CHANNELS.command.overlayModalOpened)!({}, 'subsync');
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ export interface IpcServiceDeps {
|
|||||||
onOverlayModalClosed: (
|
onOverlayModalClosed: (
|
||||||
modal: OverlayHostedModal,
|
modal: OverlayHostedModal,
|
||||||
senderWindow: ElectronBrowserWindow | null,
|
senderWindow: ElectronBrowserWindow | null,
|
||||||
instanceId?: number,
|
|
||||||
) => void;
|
) => void;
|
||||||
onOverlayModalOpened?: (
|
onOverlayModalOpened?: (
|
||||||
modal: OverlayHostedModal,
|
modal: OverlayHostedModal,
|
||||||
@@ -318,7 +317,6 @@ export interface IpcDepsRuntimeOptions {
|
|||||||
onOverlayModalClosed: (
|
onOverlayModalClosed: (
|
||||||
modal: OverlayHostedModal,
|
modal: OverlayHostedModal,
|
||||||
senderWindow: ElectronBrowserWindow | null,
|
senderWindow: ElectronBrowserWindow | null,
|
||||||
instanceId?: number,
|
|
||||||
) => void;
|
) => void;
|
||||||
onOverlayModalOpened?: (
|
onOverlayModalOpened?: (
|
||||||
modal: OverlayHostedModal,
|
modal: OverlayHostedModal,
|
||||||
@@ -529,20 +527,13 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
ipc.on(
|
ipc.on(IPC_CHANNELS.command.overlayModalClosed, (event: unknown, modal: unknown) => {
|
||||||
IPC_CHANNELS.command.overlayModalClosed,
|
|
||||||
(event: unknown, modal: unknown, instanceId: unknown) => {
|
|
||||||
const parsedModal = parseOverlayHostedModal(modal);
|
const parsedModal = parseOverlayHostedModal(modal);
|
||||||
if (!parsedModal) return;
|
if (!parsedModal) return;
|
||||||
const senderWindow =
|
const senderWindow =
|
||||||
electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null;
|
electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null;
|
||||||
deps.onOverlayModalClosed(
|
deps.onOverlayModalClosed(parsedModal, senderWindow);
|
||||||
parsedModal,
|
});
|
||||||
senderWindow,
|
|
||||||
Number.isInteger(instanceId) ? (instanceId as number) : undefined,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
ipc.on(IPC_CHANNELS.command.overlayModalOpened, (event: unknown, modal: unknown) => {
|
ipc.on(IPC_CHANNELS.command.overlayModalOpened, (event: unknown, modal: unknown) => {
|
||||||
const parsedModal = parseOverlayHostedModal(modal);
|
const parsedModal = parseOverlayHostedModal(modal);
|
||||||
if (!parsedModal) return;
|
if (!parsedModal) return;
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ test('mineSentenceCard handles missing integration and disconnected mpv', async
|
|||||||
currentSubText: 'line',
|
currentSubText: 'line',
|
||||||
currentSubStart: 1,
|
currentSubStart: 1,
|
||||||
currentSubEnd: 2,
|
currentSubEnd: 2,
|
||||||
|
currentSubDelay: 0,
|
||||||
},
|
},
|
||||||
showMpvOsd: (text) => osd.push(text),
|
showMpvOsd: (text) => osd.push(text),
|
||||||
}),
|
}),
|
||||||
@@ -109,6 +110,7 @@ test('mineSentenceCard creates sentence card from mpv subtitle state', async ()
|
|||||||
currentSubText: 'subtitle line',
|
currentSubText: 'subtitle line',
|
||||||
currentSubStart: 10,
|
currentSubStart: 10,
|
||||||
currentSubEnd: 12,
|
currentSubEnd: 12,
|
||||||
|
currentSubDelay: 0,
|
||||||
currentSecondarySubText: 'secondary line',
|
currentSecondarySubText: 'secondary line',
|
||||||
},
|
},
|
||||||
showMpvOsd: () => {},
|
showMpvOsd: () => {},
|
||||||
@@ -148,6 +150,7 @@ test('mineSentenceCard prefers a canonical primary subtitle snapshot', async ()
|
|||||||
currentSubText: '今今今手手手',
|
currentSubText: '今今今手手手',
|
||||||
currentSubStart: 11.4,
|
currentSubStart: 11.4,
|
||||||
currentSubEnd: 11.8,
|
currentSubEnd: 11.8,
|
||||||
|
currentSubDelay: 0,
|
||||||
currentSecondarySubText: 'English subtitle',
|
currentSecondarySubText: 'English subtitle',
|
||||||
},
|
},
|
||||||
primarySubtitle: {
|
primarySubtitle: {
|
||||||
@@ -187,6 +190,7 @@ test('mineSentenceCard uses normalized secondary subtitle state instead of raw m
|
|||||||
currentSubText: '日本語字幕',
|
currentSubText: '日本語字幕',
|
||||||
currentSubStart: 10,
|
currentSubStart: 10,
|
||||||
currentSubEnd: 12,
|
currentSubEnd: 12,
|
||||||
|
currentSubDelay: 0,
|
||||||
currentSecondarySubText: 'Your\nmosaic',
|
currentSecondarySubText: 'Your\nmosaic',
|
||||||
requestProperty: async () => {
|
requestProperty: async () => {
|
||||||
requestedRawSecondaryText = true;
|
requestedRawSecondaryText = true;
|
||||||
@@ -218,6 +222,7 @@ test('mineSentenceCard omits normalized secondary text that matches the primary
|
|||||||
currentSubText: '日本語字幕',
|
currentSubText: '日本語字幕',
|
||||||
currentSubStart: 10,
|
currentSubStart: 10,
|
||||||
currentSubEnd: 12,
|
currentSubEnd: 12,
|
||||||
|
currentSubDelay: 0,
|
||||||
currentSecondarySubText: '日本語字幕',
|
currentSecondarySubText: '日本語字幕',
|
||||||
},
|
},
|
||||||
showMpvOsd: () => {},
|
showMpvOsd: () => {},
|
||||||
@@ -396,7 +401,7 @@ test('handleMineSentenceDigit joins per-entry secondary subtitles when available
|
|||||||
|
|
||||||
test('captureLiveSubtitleMiningContext snapshots the current line and timings', () => {
|
test('captureLiveSubtitleMiningContext snapshots the current line and timings', () => {
|
||||||
const context = captureLiveSubtitleMiningContext(
|
const context = captureLiveSubtitleMiningContext(
|
||||||
{ currentSubText: ' 食べる ', currentSubStart: 12.5, currentSubEnd: 15.25 },
|
{ currentSubText: ' 食べる ', currentSubStart: 12.5, currentSubEnd: 15.25, currentSubDelay: 0 },
|
||||||
() => 1234,
|
() => 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', () => {
|
test('captureLiveSubtitleMiningContext rejects missing client, empty text and bad timings', () => {
|
||||||
assert.equal(captureLiveSubtitleMiningContext(null), null);
|
assert.equal(captureLiveSubtitleMiningContext(null), null);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
@@ -416,6 +431,7 @@ test('captureLiveSubtitleMiningContext rejects missing client, empty text and ba
|
|||||||
currentSubText: ' ',
|
currentSubText: ' ',
|
||||||
currentSubStart: 1,
|
currentSubStart: 1,
|
||||||
currentSubEnd: 2,
|
currentSubEnd: 2,
|
||||||
|
currentSubDelay: 0,
|
||||||
}),
|
}),
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@@ -424,6 +440,7 @@ test('captureLiveSubtitleMiningContext rejects missing client, empty text and ba
|
|||||||
currentSubText: 'line',
|
currentSubText: 'line',
|
||||||
currentSubStart: 5,
|
currentSubStart: 5,
|
||||||
currentSubEnd: 5,
|
currentSubEnd: 5,
|
||||||
|
currentSubDelay: 0,
|
||||||
}),
|
}),
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@@ -432,6 +449,7 @@ test('captureLiveSubtitleMiningContext rejects missing client, empty text and ba
|
|||||||
currentSubText: 'line',
|
currentSubText: 'line',
|
||||||
currentSubStart: NaN,
|
currentSubStart: NaN,
|
||||||
currentSubEnd: 2,
|
currentSubEnd: 2,
|
||||||
|
currentSubDelay: 0,
|
||||||
}),
|
}),
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { SubtitleTimingBlock } from '../../subtitle-timing-tracker';
|
import type { SubtitleTimingBlock } from '../../subtitle-timing-tracker';
|
||||||
import type { SubtitleMiningContext } from '../../types/subtitle';
|
import type { SubtitleMiningContext } from '../../types/subtitle';
|
||||||
|
import { shiftSubtitleRangeToVideoClock } from './subtitle-clock';
|
||||||
|
|
||||||
interface SubtitleTimingTrackerLike {
|
interface SubtitleTimingTrackerLike {
|
||||||
getRecentBlocks: (count: number) => string[];
|
getRecentBlocks: (count: number) => string[];
|
||||||
@@ -25,6 +26,7 @@ interface MpvClientLike {
|
|||||||
currentSubText: string;
|
currentSubText: string;
|
||||||
currentSubStart: number;
|
currentSubStart: number;
|
||||||
currentSubEnd: number;
|
currentSubEnd: number;
|
||||||
|
currentSubDelay: number;
|
||||||
currentSecondarySubText?: string;
|
currentSecondarySubText?: string;
|
||||||
requestProperty?: (name: string) => Promise<unknown>;
|
requestProperty?: (name: string) => Promise<unknown>;
|
||||||
}
|
}
|
||||||
@@ -62,7 +64,10 @@ export function handleMultiCopyDigit(
|
|||||||
* line is on screen by then; callers capture here at mining time instead.
|
* line is on screen by then; callers capture here at mining time instead.
|
||||||
*/
|
*/
|
||||||
export function captureLiveSubtitleMiningContext(
|
export function captureLiveSubtitleMiningContext(
|
||||||
client: Pick<MpvClientLike, 'currentSubText' | 'currentSubStart' | 'currentSubEnd'> | null,
|
client: Pick<
|
||||||
|
MpvClientLike,
|
||||||
|
'currentSubText' | 'currentSubStart' | 'currentSubEnd' | 'currentSubDelay'
|
||||||
|
> | null,
|
||||||
now: () => number = Date.now,
|
now: () => number = Date.now,
|
||||||
): SubtitleMiningContext | null {
|
): SubtitleMiningContext | null {
|
||||||
if (!client) {
|
if (!client) {
|
||||||
@@ -73,7 +78,10 @@ export function captureLiveSubtitleMiningContext(
|
|||||||
if (!text || !Number.isFinite(startTime) || !Number.isFinite(endTime) || endTime <= startTime) {
|
if (!text || !Number.isFinite(startTime) || !Number.isFinite(endTime) || endTime <= startTime) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return { source: 'overlay', text, startTime, endTime, capturedAtMs: now() };
|
return shiftSubtitleRangeToVideoClock<SubtitleMiningContext>(
|
||||||
|
{ source: 'overlay', text, startTime, endTime, capturedAtMs: now() },
|
||||||
|
client.currentSubDelay,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function copyCurrentSubtitle(deps: {
|
export function copyCurrentSubtitle(deps: {
|
||||||
@@ -167,6 +175,7 @@ export async function markLastCardAsAudioCard(deps: {
|
|||||||
export async function mineSentenceCard(deps: {
|
export async function mineSentenceCard(deps: {
|
||||||
ankiIntegration: AnkiIntegrationLike | null;
|
ankiIntegration: AnkiIntegrationLike | null;
|
||||||
mpvClient: MpvClientLike | null;
|
mpvClient: MpvClientLike | null;
|
||||||
|
/** Already on the video clock, like every `SubtitleMiningContext`. */
|
||||||
primarySubtitle?: Pick<SubtitleMiningContext, 'text' | 'startTime' | 'endTime'>;
|
primarySubtitle?: Pick<SubtitleMiningContext, 'text' | 'startTime' | 'endTime'>;
|
||||||
showMpvOsd: (text: string) => void;
|
showMpvOsd: (text: string) => void;
|
||||||
}): Promise<boolean> {
|
}): Promise<boolean> {
|
||||||
@@ -185,10 +194,16 @@ export async function mineSentenceCard(deps: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const secondarySubText = getCurrentSecondarySubTextForSentenceCard(mpvClient, primaryText);
|
const secondarySubText = getCurrentSecondarySubTextForSentenceCard(mpvClient, primaryText);
|
||||||
|
const range =
|
||||||
|
deps.primarySubtitle ??
|
||||||
|
shiftSubtitleRangeToVideoClock(
|
||||||
|
{ startTime: mpvClient.currentSubStart, endTime: mpvClient.currentSubEnd },
|
||||||
|
mpvClient.currentSubDelay,
|
||||||
|
);
|
||||||
return await anki.createSentenceCard(
|
return await anki.createSentenceCard(
|
||||||
primaryText,
|
primaryText,
|
||||||
deps.primarySubtitle?.startTime ?? mpvClient.currentSubStart,
|
range.startTime,
|
||||||
deps.primarySubtitle?.endTime ?? mpvClient.currentSubEnd,
|
range.endTime,
|
||||||
secondarySubText,
|
secondarySubText,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ const MPV_SUBTITLE_PROPERTY_OBSERVATIONS: string[] = [
|
|||||||
'path',
|
'path',
|
||||||
'sub-start',
|
'sub-start',
|
||||||
'sub-end',
|
'sub-end',
|
||||||
|
'sub-delay',
|
||||||
'time-pos',
|
'time-pos',
|
||||||
'secondary-sub-text',
|
'secondary-sub-text',
|
||||||
'aid',
|
'aid',
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
|
|||||||
getCurrentSubStart: () => 0,
|
getCurrentSubStart: () => 0,
|
||||||
setCurrentSubEnd: () => {},
|
setCurrentSubEnd: () => {},
|
||||||
getCurrentSubEnd: () => 0,
|
getCurrentSubEnd: () => 0,
|
||||||
|
setCurrentSubDelay: () => {},
|
||||||
|
getCurrentSubDelay: () => 0,
|
||||||
emitMediaPathChange: (payload) => {
|
emitMediaPathChange: (payload) => {
|
||||||
state.mediaPath = payload.path;
|
state.mediaPath = payload.path;
|
||||||
},
|
},
|
||||||
@@ -131,6 +133,22 @@ test('dispatchMpvProtocolMessage emits subtitle text on property change', async
|
|||||||
assert.deepEqual(state.events, [{ text: '字幕', isOverlayVisible: false }]);
|
assert.deepEqual(state.events, [{ text: '字幕', isOverlayVisible: false }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('dispatchMpvProtocolMessage tracks sub-delay retiming', async () => {
|
||||||
|
const delays: number[] = [];
|
||||||
|
const { deps } = createDeps({ setCurrentSubDelay: (value) => delays.push(value) });
|
||||||
|
|
||||||
|
await dispatchMpvProtocolMessage(
|
||||||
|
{ event: 'property-change', name: 'sub-delay', data: 1.5 },
|
||||||
|
deps,
|
||||||
|
);
|
||||||
|
await dispatchMpvProtocolMessage(
|
||||||
|
{ event: 'property-change', name: 'sub-delay', data: null },
|
||||||
|
deps,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(delays, [1.5, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
test('dispatchMpvProtocolMessage widens halfwidth katakana in subtitle text', async () => {
|
test('dispatchMpvProtocolMessage widens halfwidth katakana in subtitle text', async () => {
|
||||||
const { deps, state } = createDeps();
|
const { deps, state } = createDeps();
|
||||||
|
|
||||||
@@ -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 () => {
|
test('dispatchMpvProtocolMessage updates current time before emitting time-pos change', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
let currentTimePos = 0;
|
let currentTimePos = 0;
|
||||||
@@ -445,19 +486,3 @@ test('dispatchMpvProtocolMessage emits client-message string args', async () =>
|
|||||||
|
|
||||||
assert.deepEqual(received, [{ args: ['subminer-skip-intro', 'extra'] }]);
|
assert.deepEqual(received, [{ args: ['subminer-skip-intro', 'extra'] }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('dispatchMpvProtocolMessage emits end-file with reason, entry id, and error', async () => {
|
|
||||||
const received: unknown[] = [];
|
|
||||||
const { deps } = createDeps({ emitEndFile: (payload) => received.push(payload) });
|
|
||||||
|
|
||||||
await dispatchMpvProtocolMessage(
|
|
||||||
{ event: 'end-file', reason: 'error', playlist_entry_id: 3, file_error: 'loading failed' },
|
|
||||||
deps,
|
|
||||||
);
|
|
||||||
await dispatchMpvProtocolMessage({ event: 'end-file', reason: 'something-new' }, deps);
|
|
||||||
|
|
||||||
assert.deepEqual(received, [
|
|
||||||
{ reason: 'error', playlistEntryId: 3, error: 'loading failed' },
|
|
||||||
{ reason: 'unknown', playlistEntryId: null, error: null },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { MpvSubtitleRenderMetrics } from '../../types';
|
import { MpvSubtitleRenderMetrics } from '../../types';
|
||||||
import { sanitizeMediaTitle } from '../../shared/media-identity';
|
import { sanitizeMediaTitle } from '../../shared/media-identity';
|
||||||
import { widenHalfwidthKana } from '../utils/halfwidth-kana';
|
import { widenHalfwidthKana } from '../utils/halfwidth-kana';
|
||||||
|
import { toVideoClock } from './subtitle-clock';
|
||||||
|
|
||||||
export type MpvMessage = {
|
export type MpvMessage = {
|
||||||
event?: string;
|
event?: string;
|
||||||
@@ -9,30 +10,8 @@ export type MpvMessage = {
|
|||||||
args?: unknown;
|
args?: unknown;
|
||||||
request_id?: number;
|
request_id?: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
reason?: unknown;
|
|
||||||
playlist_entry_id?: unknown;
|
|
||||||
file_error?: unknown;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const MPV_END_FILE_REASONS = ['eof', 'stop', 'quit', 'error', 'redirect'] as const;
|
|
||||||
export type MpvEndFileReason = (typeof MPV_END_FILE_REASONS)[number] | 'unknown';
|
|
||||||
|
|
||||||
/** mpv's `end-file` event: why the current file stopped, and mpv's error text when it failed. */
|
|
||||||
export interface MpvEndFileEvent {
|
|
||||||
reason: MpvEndFileReason;
|
|
||||||
playlistEntryId: number | null;
|
|
||||||
error: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseEndFileEvent(msg: MpvMessage): MpvEndFileEvent {
|
|
||||||
const reason = MPV_END_FILE_REASONS.find((known) => known === msg.reason) ?? 'unknown';
|
|
||||||
return {
|
|
||||||
reason,
|
|
||||||
playlistEntryId: typeof msg.playlist_entry_id === 'number' ? msg.playlist_entry_id : null,
|
|
||||||
error: typeof msg.file_error === 'string' && msg.file_error ? msg.file_error : null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MPV_REQUEST_ID_SUBTEXT = 101;
|
export const MPV_REQUEST_ID_SUBTEXT = 101;
|
||||||
export const MPV_REQUEST_ID_PATH = 102;
|
export const MPV_REQUEST_ID_PATH = 102;
|
||||||
export const MPV_REQUEST_ID_SECONDARY_SUBTEXT = 103;
|
export const MPV_REQUEST_ID_SECONDARY_SUBTEXT = 103;
|
||||||
@@ -86,6 +65,8 @@ export interface MpvProtocolHandleMessageDeps {
|
|||||||
getCurrentSubStart: () => number;
|
getCurrentSubStart: () => number;
|
||||||
setCurrentSubEnd: (value: number) => void;
|
setCurrentSubEnd: (value: number) => void;
|
||||||
getCurrentSubEnd: () => number;
|
getCurrentSubEnd: () => number;
|
||||||
|
setCurrentSubDelay: (value: number) => void;
|
||||||
|
getCurrentSubDelay: () => number;
|
||||||
emitMediaPathChange: (payload: { path: string }) => void;
|
emitMediaPathChange: (payload: { path: string }) => void;
|
||||||
emitMediaTitleChange: (payload: { title: string | null }) => void;
|
emitMediaTitleChange: (payload: { title: string | null }) => void;
|
||||||
emitTimePosChange: (payload: { time: number }) => void;
|
emitTimePosChange: (payload: { time: number }) => void;
|
||||||
@@ -122,7 +103,6 @@ export interface MpvProtocolHandleMessageDeps {
|
|||||||
shouldQuitOnMpvShutdown: () => boolean;
|
shouldQuitOnMpvShutdown: () => boolean;
|
||||||
requestAppQuit: () => void;
|
requestAppQuit: () => void;
|
||||||
emitClientMessage?: (payload: { args: string[] }) => void;
|
emitClientMessage?: (payload: { args: string[] }) => void;
|
||||||
emitEndFile?: (payload: MpvEndFileEvent) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type SubtitleTrackCandidate = {
|
type SubtitleTrackCandidate = {
|
||||||
@@ -286,7 +266,8 @@ export async function dispatchMpvProtocolMessage(
|
|||||||
const subEnd = (msg.data as number) || 0;
|
const subEnd = (msg.data as number) || 0;
|
||||||
deps.setCurrentSubEnd(subEnd);
|
deps.setCurrentSubEnd(subEnd);
|
||||||
if (deps.getPendingPauseAtSubEnd() && subEnd > 0) {
|
if (deps.getPendingPauseAtSubEnd() && subEnd > 0) {
|
||||||
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.setPendingPauseAtSubEnd(false);
|
||||||
deps.sendCommand({ command: ['set_property', 'pause', false] });
|
deps.sendCommand({ command: ['set_property', 'pause', false] });
|
||||||
}
|
}
|
||||||
@@ -295,6 +276,16 @@ export async function dispatchMpvProtocolMessage(
|
|||||||
start: deps.getCurrentSubStart(),
|
start: deps.getCurrentSubStart(),
|
||||||
end: deps.getCurrentSubEnd(),
|
end: deps.getCurrentSubEnd(),
|
||||||
});
|
});
|
||||||
|
} else if (msg.name === 'sub-delay') {
|
||||||
|
const parsed = typeof msg.data === 'number' ? msg.data : Number(msg.data);
|
||||||
|
const delay = Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
// A scheduled replay pause sits at the line's end on the video clock; retiming
|
||||||
|
// mid-replay moves that end, so the pause moves with it.
|
||||||
|
const pauseAtTime = deps.getPauseAtTime();
|
||||||
|
if (pauseAtTime !== null) {
|
||||||
|
deps.setPauseAtTime(pauseAtTime + delay - deps.getCurrentSubDelay());
|
||||||
|
}
|
||||||
|
deps.setCurrentSubDelay(delay);
|
||||||
} else if (msg.name === 'secondary-sub-text') {
|
} else if (msg.name === 'secondary-sub-text') {
|
||||||
const nextSubText = (msg.data as string) || '';
|
const nextSubText = (msg.data as string) || '';
|
||||||
deps.setCurrentSecondarySubText(nextSubText);
|
deps.setCurrentSecondarySubText(nextSubText);
|
||||||
@@ -442,8 +433,6 @@ export async function dispatchMpvProtocolMessage(
|
|||||||
if (args.length > 0) {
|
if (args.length > 0) {
|
||||||
deps.emitClientMessage?.({ args });
|
deps.emitClientMessage?.({ args });
|
||||||
}
|
}
|
||||||
} else if (msg.event === 'end-file') {
|
|
||||||
deps.emitEndFile?.(parseEndFileEvent(msg));
|
|
||||||
} else if (msg.event === 'shutdown') {
|
} else if (msg.event === 'shutdown') {
|
||||||
deps.restorePreviousSecondarySubVisibility();
|
deps.restorePreviousSecondarySubVisibility();
|
||||||
if (deps.shouldQuitOnMpvShutdown()) {
|
if (deps.shouldQuitOnMpvShutdown()) {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
dispatchMpvProtocolMessage,
|
dispatchMpvProtocolMessage,
|
||||||
MPV_REQUEST_ID_TRACK_LIST_AUDIO,
|
MPV_REQUEST_ID_TRACK_LIST_AUDIO,
|
||||||
MPV_REQUEST_ID_TRACK_LIST_SECONDARY,
|
MPV_REQUEST_ID_TRACK_LIST_SECONDARY,
|
||||||
type MpvEndFileEvent,
|
|
||||||
MpvMessage,
|
MpvMessage,
|
||||||
MpvProtocolHandleMessageDeps,
|
MpvProtocolHandleMessageDeps,
|
||||||
splitMpvMessagesFromBuffer,
|
splitMpvMessagesFromBuffer,
|
||||||
@@ -139,7 +138,6 @@ export interface MpvIpcClientEventMap {
|
|||||||
'subtitle-metrics-change': { patch: Partial<MpvSubtitleRenderMetrics> };
|
'subtitle-metrics-change': { patch: Partial<MpvSubtitleRenderMetrics> };
|
||||||
'secondary-subtitle-visibility': { visible: boolean };
|
'secondary-subtitle-visibility': { visible: boolean };
|
||||||
'client-message': { args: string[] };
|
'client-message': { args: string[] };
|
||||||
'end-file': MpvEndFileEvent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type MpvIpcClientEventName = keyof MpvIpcClientEventMap;
|
type MpvIpcClientEventName = keyof MpvIpcClientEventMap;
|
||||||
@@ -162,6 +160,8 @@ export class MpvIpcClient implements MpvClient {
|
|||||||
public currentTimePos = 0;
|
public currentTimePos = 0;
|
||||||
public currentSubStart = 0;
|
public currentSubStart = 0;
|
||||||
public currentSubEnd = 0;
|
public currentSubEnd = 0;
|
||||||
|
/** mpv `sub-delay`: seconds each primary line plays after its `sub-start`/`sub-end`. */
|
||||||
|
public currentSubDelay = 0;
|
||||||
public currentSubText = '';
|
public currentSubText = '';
|
||||||
public currentSecondarySubText = '';
|
public currentSecondarySubText = '';
|
||||||
public currentAudioStreamIndex: number | null = null;
|
public currentAudioStreamIndex: number | null = null;
|
||||||
@@ -461,6 +461,10 @@ export class MpvIpcClient implements MpvClient {
|
|||||||
this.currentSubEnd = value;
|
this.currentSubEnd = value;
|
||||||
},
|
},
|
||||||
getCurrentSubEnd: () => this.currentSubEnd,
|
getCurrentSubEnd: () => this.currentSubEnd,
|
||||||
|
setCurrentSubDelay: (value: number) => {
|
||||||
|
this.currentSubDelay = value;
|
||||||
|
},
|
||||||
|
getCurrentSubDelay: () => this.currentSubDelay,
|
||||||
emitMediaPathChange: (payload) => {
|
emitMediaPathChange: (payload) => {
|
||||||
this.emit('media-path-change', payload);
|
this.emit('media-path-change', payload);
|
||||||
},
|
},
|
||||||
@@ -521,9 +525,6 @@ export class MpvIpcClient implements MpvClient {
|
|||||||
emitClientMessage: (payload) => {
|
emitClientMessage: (payload) => {
|
||||||
this.emit('client-message', payload);
|
this.emit('client-message', payload);
|
||||||
},
|
},
|
||||||
emitEndFile: (payload) => {
|
|
||||||
this.emit('end-file', payload);
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -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 ?? '');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -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: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
@@ -55,10 +55,6 @@ function createDeps(overrides: Partial<SessionActionExecutorDeps> = {}) {
|
|||||||
playNextSubtitle: () => calls.push('play-next'),
|
playNextSubtitle: () => calls.push('play-next'),
|
||||||
cycleRuntimeOption: () => ({ ok: true }),
|
cycleRuntimeOption: () => ({ ok: true }),
|
||||||
playNextPlaylistItem: () => calls.push('playlist-next'),
|
playNextPlaylistItem: () => calls.push('playlist-next'),
|
||||||
runPluginAction: async (pluginActionId) => {
|
|
||||||
calls.push(`plugin:${pluginActionId}`);
|
|
||||||
return pluginActionId === 'ai-explain/explain';
|
|
||||||
},
|
|
||||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
@@ -85,22 +81,3 @@ test('dispatchSessionAction does not advance playlist when mark watched no-ops',
|
|||||||
|
|
||||||
assert.deepEqual(calls, ['mark-watched']);
|
assert.deepEqual(calls, ['mark-watched']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('dispatchSessionAction runs plugin actions and reports unregistered ones', async () => {
|
|
||||||
const { calls, deps } = createDeps();
|
|
||||||
|
|
||||||
await dispatchSessionAction(
|
|
||||||
{ actionId: 'runPluginAction', payload: { pluginActionId: 'ai-explain/explain' } },
|
|
||||||
deps,
|
|
||||||
);
|
|
||||||
await dispatchSessionAction(
|
|
||||||
{ actionId: 'runPluginAction', payload: { pluginActionId: 'ai-explain/missing' } },
|
|
||||||
deps,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(calls, [
|
|
||||||
'plugin:ai-explain/explain',
|
|
||||||
'plugin:ai-explain/missing',
|
|
||||||
'osd:Plugin action not available: ai-explain/missing',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
@@ -32,8 +32,6 @@ export interface SessionActionExecutorDeps {
|
|||||||
playNextSubtitle: () => void;
|
playNextSubtitle: () => void;
|
||||||
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
|
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
|
||||||
playNextPlaylistItem: () => void;
|
playNextPlaylistItem: () => void;
|
||||||
/** Returns false when no loaded plugin registered the action. */
|
|
||||||
runPluginAction: (pluginActionId: string) => Promise<boolean>;
|
|
||||||
showMpvOsd: (text: string) => void;
|
showMpvOsd: (text: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,13 +151,6 @@ export async function dispatchSessionAction(
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case 'runPluginAction': {
|
|
||||||
const pluginActionId = request.payload?.pluginActionId;
|
|
||||||
if (!pluginActionId || !(await deps.runPluginAction(pluginActionId))) {
|
|
||||||
deps.showMpvOsd(`Plugin action not available: ${pluginActionId ?? 'unknown'}`);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
return assertUnreachableSessionAction(request.actionId);
|
return assertUnreachableSessionAction(request.actionId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -323,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', () => {
|
test('compileSessionBindings leaves retired subtitle-delay shift tokens as mpv commands', () => {
|
||||||
const result = compileSessionBindings({
|
const result = compileSessionBindings({
|
||||||
shortcuts: createShortcuts(),
|
shortcuts: createShortcuts(),
|
||||||
|
|||||||
@@ -342,16 +342,6 @@ function resolveCommandBinding(
|
|||||||
if (command.length !== 1) return null;
|
if (command.length !== 1) return null;
|
||||||
return { actionType: 'session-action', actionId: 'playNextSubtitle' };
|
return { actionType: 'session-action', actionId: 'playNextSubtitle' };
|
||||||
}
|
}
|
||||||
if (first.startsWith(SPECIAL_COMMANDS.PLUGIN_ACTION_PREFIX)) {
|
|
||||||
const pluginActionId = first.slice(SPECIAL_COMMANDS.PLUGIN_ACTION_PREFIX.length);
|
|
||||||
const [pluginId, actionName, ...rest] = pluginActionId.split('/');
|
|
||||||
if (command.length !== 1 || !pluginId || !actionName || rest.length > 0) return null;
|
|
||||||
return {
|
|
||||||
actionType: 'session-action',
|
|
||||||
actionId: 'runPluginAction',
|
|
||||||
payload: { pluginActionId },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) {
|
if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) {
|
||||||
if (command.length !== 1) {
|
if (command.length !== 1) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -117,8 +117,6 @@ export interface AppReadyRuntimeDeps {
|
|||||||
loadSubtitlePosition: () => void;
|
loadSubtitlePosition: () => void;
|
||||||
resolveKeybindings: () => void;
|
resolveKeybindings: () => void;
|
||||||
createMpvClient: () => void;
|
createMpvClient: () => void;
|
||||||
/** Loads enabled plugins; full startup only. */
|
|
||||||
startPlugins?: () => void;
|
|
||||||
reloadConfig: () => void;
|
reloadConfig: () => void;
|
||||||
getResolvedConfig: () => AppReadyConfigLike;
|
getResolvedConfig: () => AppReadyConfigLike;
|
||||||
getConfigWarnings: () => ConfigValidationWarning[];
|
getConfigWarnings: () => ConfigValidationWarning[];
|
||||||
@@ -312,7 +310,6 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
|||||||
deps.createMpvClient();
|
deps.createMpvClient();
|
||||||
deps.initRuntimeOptionsManager();
|
deps.initRuntimeOptionsManager();
|
||||||
deps.setSecondarySubMode(config.secondarySub?.defaultMode ?? deps.defaultSecondarySubMode);
|
deps.setSecondarySubMode(config.secondarySub?.defaultMode ?? deps.defaultSecondarySubMode);
|
||||||
deps.startPlugins?.();
|
|
||||||
|
|
||||||
const wsConfig = config.websocket || {};
|
const wsConfig = config.websocket || {};
|
||||||
const wsEnabled = wsConfig.enabled ?? 'auto';
|
const wsEnabled = wsConfig.enabled ?? 'auto';
|
||||||
|
|||||||
@@ -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',
|
'seek',
|
||||||
13.08,
|
13.08,
|
||||||
'absolute+exact',
|
'absolute+exact',
|
||||||
@@ -41,7 +41,7 @@ test('next subtitle navigation treats simultaneous sanitized cues as one line bo
|
|||||||
{ startTime: 13.02, endTime: 16, text: 'Next English' },
|
{ startTime: 13.02, endTime: 16, text: 'Next English' },
|
||||||
];
|
];
|
||||||
|
|
||||||
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.1), [
|
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.1, 0), [
|
||||||
'seek',
|
'seek',
|
||||||
13.08,
|
13.08,
|
||||||
'absolute+exact',
|
'absolute+exact',
|
||||||
@@ -55,7 +55,7 @@ test('next subtitle navigation advances past the latest overlapping lyric', () =
|
|||||||
{ startTime: 16, endTime: 19, text: 'next lyric' },
|
{ startTime: 16, endTime: 19, text: 'next lyric' },
|
||||||
];
|
];
|
||||||
|
|
||||||
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 13.2), [
|
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 13.2, 0), [
|
||||||
'seek',
|
'seek',
|
||||||
16.08,
|
16.08,
|
||||||
'absolute+exact',
|
'absolute+exact',
|
||||||
@@ -68,7 +68,7 @@ test('previous subtitle navigation leaves the current cue and seeks to the prior
|
|||||||
{ startTime: 13, endTime: 16, text: 'current line' },
|
{ startTime: 13, endTime: 16, text: 'current line' },
|
||||||
];
|
];
|
||||||
|
|
||||||
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', -1], cues, 14.5), [
|
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', -1], cues, 14.5, 0), [
|
||||||
'seek',
|
'seek',
|
||||||
10.08,
|
10.08,
|
||||||
'absolute+exact',
|
'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', () => {
|
test('subtitle navigation falls back when no sanitized destination exists', () => {
|
||||||
const cues = [{ startTime: 10, endTime: 13, text: 'only line' }];
|
const cues = [{ startTime: 10, endTime: 13, text: 'only line' }];
|
||||||
|
|
||||||
assert.equal(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.2), null);
|
assert.equal(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.2, 0), null);
|
||||||
assert.equal(resolveSanitizedSubtitleSeekCommand(['seek', 5], cues, 10.2), 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', () => {
|
test('sidebar cue seeks share the boundary-safe sanitized cue timestamp', () => {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { toSubtitleClock, toVideoClock } from './subtitle-clock';
|
||||||
import type { SubtitleCue } from './subtitle-cue-parser';
|
import type { SubtitleCue } from './subtitle-cue-parser';
|
||||||
|
|
||||||
const CUE_START_GROUP_TOLERANCE_SECONDS = 0.05;
|
const CUE_START_GROUP_TOLERANCE_SECONDS = 0.05;
|
||||||
@@ -80,8 +81,10 @@ export function subtitleCueListSeekTime(
|
|||||||
export function resolveSanitizedSubtitleSeekCommand(
|
export function resolveSanitizedSubtitleSeekCommand(
|
||||||
command: readonly (string | number)[],
|
command: readonly (string | number)[],
|
||||||
cues: readonly SubtitleCue[],
|
cues: readonly SubtitleCue[],
|
||||||
currentTimeSec: number,
|
videoTimeSec: number,
|
||||||
|
subDelay: number,
|
||||||
): (string | number)[] | null {
|
): (string | number)[] | null {
|
||||||
|
const currentTimeSec = toSubtitleClock(videoTimeSec, subDelay);
|
||||||
if (
|
if (
|
||||||
command.length < 2 ||
|
command.length < 2 ||
|
||||||
command[0] !== 'sub-seek' ||
|
command[0] !== 'sub-seek' ||
|
||||||
@@ -124,5 +127,5 @@ export function resolveSanitizedSubtitleSeekCommand(
|
|||||||
if (!destination) {
|
if (!destination) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return ['seek', subtitleCueSeekTime(destination.cue), 'absolute+exact'];
|
return ['seek', toVideoClock(subtitleCueSeekTime(destination.cue), subDelay), 'absolute+exact'];
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,22 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
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', () => {
|
test('default notification icon resolves packaged SubMiner asset when no per-notification icon is provided', () => {
|
||||||
const path = resolveDefaultNotificationIconPath({
|
const path = resolveDefaultNotificationIconPath({
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import electron from 'electron';
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { createLogger } from '../../logger';
|
import { createLogger } from '../../logger';
|
||||||
import { buildSystemBinaryEnv } from './system-binary-env';
|
|
||||||
|
|
||||||
const { Notification, nativeImage } = electron;
|
const { Notification, nativeImage } = electron;
|
||||||
const logger = createLogger('core:notification');
|
const logger = createLogger('core:notification');
|
||||||
@@ -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) =>
|
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 ?? ''),
|
callback(error, stdout ?? ''),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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');
|
|
||||||
});
|
|
||||||
@@ -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\\^"^""',
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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
@@ -328,9 +328,11 @@ import {
|
|||||||
setMpvSubVisibilityRuntime,
|
setMpvSubVisibilityRuntime,
|
||||||
setOverlayDebugVisualizationEnabledRuntime,
|
setOverlayDebugVisualizationEnabledRuntime,
|
||||||
setVisibleOverlayVisible as setVisibleOverlayVisibleCore,
|
setVisibleOverlayVisible as setVisibleOverlayVisibleCore,
|
||||||
|
shiftSubtitleRangeToVideoClock,
|
||||||
showMpvOsdRuntime,
|
showMpvOsdRuntime,
|
||||||
JellyfinPlaybackReporter,
|
JellyfinPlaybackReporter,
|
||||||
tokenizeSubtitle as tokenizeSubtitleCore,
|
tokenizeSubtitle as tokenizeSubtitleCore,
|
||||||
|
toSubtitleClock,
|
||||||
triggerFieldGrouping as triggerFieldGroupingCore,
|
triggerFieldGrouping as triggerFieldGroupingCore,
|
||||||
upsertYomitanDictionarySettings,
|
upsertYomitanDictionarySettings,
|
||||||
updateLastCardFromClipboard as updateLastCardFromClipboardCore,
|
updateLastCardFromClipboard as updateLastCardFromClipboardCore,
|
||||||
@@ -512,14 +514,6 @@ import { openSubsyncManualModal as openSubsyncManualModalRuntime } from './main/
|
|||||||
import { openSessionHelpModal as openSessionHelpModalRuntime } from './main/runtime/session-help-open';
|
import { openSessionHelpModal as openSessionHelpModalRuntime } from './main/runtime/session-help-open';
|
||||||
import { openChangelogModal as openChangelogModalRuntime } from './main/runtime/changelog-open';
|
import { openChangelogModal as openChangelogModalRuntime } from './main/runtime/changelog-open';
|
||||||
import { createChangelogRuntime } from './main/runtime/changelog/changelog-runtime';
|
import { createChangelogRuntime } from './main/runtime/changelog/changelog-runtime';
|
||||||
import { createPluginHost } from './core/services/plugins/plugin-host';
|
|
||||||
import { buildPluginSubtitleContext } from './core/services/plugins/subtitle-context';
|
|
||||||
import type { PluginSubtitleLine } from './core/services/plugins/types';
|
|
||||||
import {
|
|
||||||
createPluginPanelRuntime,
|
|
||||||
openPluginPanelModal,
|
|
||||||
} from './main/runtime/plugin-panel-runtime';
|
|
||||||
import { createPluginWindowRuntime } from './main/runtime/plugin-window-runtime';
|
|
||||||
import { openCharacterDictionaryManagerModal as openCharacterDictionaryManagerModalRuntime } from './main/runtime/character-dictionary-open';
|
import { openCharacterDictionaryManagerModal as openCharacterDictionaryManagerModalRuntime } from './main/runtime/character-dictionary-open';
|
||||||
import { openControllerSelectModal as openControllerSelectModalRuntime } from './main/runtime/controller-select-open';
|
import { openControllerSelectModal as openControllerSelectModalRuntime } from './main/runtime/controller-select-open';
|
||||||
import { openControllerDebugModal as openControllerDebugModalRuntime } from './main/runtime/controller-debug-open';
|
import { openControllerDebugModal as openControllerDebugModalRuntime } from './main/runtime/controller-debug-open';
|
||||||
@@ -1945,7 +1939,10 @@ let tokenizeSubtitleDeferred: ((text: string) => Promise<SubtitleData>) | null =
|
|||||||
function withCurrentSubtitleTiming(payload: SubtitleData): SubtitleData {
|
function withCurrentSubtitleTiming(payload: SubtitleData): SubtitleData {
|
||||||
const canonical = resolveCanonicalPrimarySubtitle({
|
const canonical = resolveCanonicalPrimarySubtitle({
|
||||||
liveText: payload.text,
|
liveText: payload.text,
|
||||||
currentTimeSec: Number(appState.mpvClient?.currentTimePos),
|
currentTimeSec: toSubtitleClock(
|
||||||
|
Number(appState.mpvClient?.currentTimePos),
|
||||||
|
appState.mpvClient?.currentSubDelay ?? 0,
|
||||||
|
),
|
||||||
cues: appState.activeParsedSubtitleCues,
|
cues: appState.activeParsedSubtitleCues,
|
||||||
});
|
});
|
||||||
return {
|
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
|
// Mine what the overlay shows, not raw mpv `sub-text`: the raw text lists every active
|
||||||
// event, so a finished caption row lingering beside a fresh line would end up on the
|
// event, so a finished caption row lingering beside a fresh line would end up on the
|
||||||
// card. The parsed view also carries the cue's own timings for the clip range.
|
// card. The parsed view also carries the cue's own timings for the clip range.
|
||||||
|
// Cues sit on the subtitle clock, so the lookup undoes `sub-delay` first.
|
||||||
|
const subDelay = appState.mpvClient?.currentSubDelay ?? 0;
|
||||||
const resolved = resolvePrimarySubtitle({
|
const resolved = resolvePrimarySubtitle({
|
||||||
liveText: appState.mpvClient?.currentSubText ?? '',
|
liveText: appState.mpvClient?.currentSubText ?? '',
|
||||||
currentTimeSec: Number(appState.mpvClient?.currentTimePos),
|
currentTimeSec: toSubtitleClock(Number(appState.mpvClient?.currentTimePos), subDelay),
|
||||||
cues: appState.activeParsedSubtitleCues,
|
cues: appState.activeParsedSubtitleCues,
|
||||||
});
|
});
|
||||||
// Same validity bar as the live capture path: an unusable resolved span must fall
|
// Same validity bar as the live capture path: an unusable resolved span must fall
|
||||||
@@ -1976,13 +1975,16 @@ function captureCurrentPrimarySubtitleMiningContext(): SubtitleMiningContext | n
|
|||||||
) {
|
) {
|
||||||
return captureLiveSubtitleMiningContext(appState.mpvClient);
|
return captureLiveSubtitleMiningContext(appState.mpvClient);
|
||||||
}
|
}
|
||||||
return {
|
return shiftSubtitleRangeToVideoClock<SubtitleMiningContext>(
|
||||||
|
{
|
||||||
source: 'overlay',
|
source: 'overlay',
|
||||||
text: resolvedText,
|
text: resolvedText,
|
||||||
startTime: resolved.startTime,
|
startTime: resolved.startTime,
|
||||||
endTime: resolved.endTime,
|
endTime: resolved.endTime,
|
||||||
capturedAtMs: Date.now(),
|
capturedAtMs: Date.now(),
|
||||||
};
|
},
|
||||||
|
subDelay,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
function emitSubtitlePayload(payload: SubtitleData, options?: { resumePrefetch?: boolean }): void {
|
function emitSubtitlePayload(payload: SubtitleData, options?: { resumePrefetch?: boolean }): void {
|
||||||
const timedPayload = withCurrentSubtitleTiming(payload);
|
const timedPayload = withCurrentSubtitleTiming(payload);
|
||||||
@@ -2124,13 +2126,18 @@ function getSubtitleSidebarPlaybackState(): SubtitleSidebarPlaybackState {
|
|||||||
subtitleSidebarCuesRef = appState.activeParsedSubtitleCues;
|
subtitleSidebarCuesRef = appState.activeParsedSubtitleCues;
|
||||||
subtitleSidebarCuesRevision += 1;
|
subtitleSidebarCuesRevision += 1;
|
||||||
}
|
}
|
||||||
|
const subDelay = appState.mpvClient?.currentSubDelay ?? 0;
|
||||||
return {
|
return {
|
||||||
sourceKey: JSON.stringify([
|
sourceKey: JSON.stringify([
|
||||||
appState.activeParsedSubtitleMediaPath,
|
appState.activeParsedSubtitleMediaPath,
|
||||||
appState.activeParsedSubtitleSource,
|
appState.activeParsedSubtitleSource,
|
||||||
]),
|
]),
|
||||||
cuesRevision: subtitleSidebarCuesRevision,
|
cuesRevision: subtitleSidebarCuesRevision,
|
||||||
currentTimeSec: appState.mpvClient?.currentTimePos ?? null,
|
currentTimeSec:
|
||||||
|
appState.mpvClient?.currentTimePos === undefined
|
||||||
|
? null
|
||||||
|
: toSubtitleClock(appState.mpvClient.currentTimePos, subDelay),
|
||||||
|
subDelay,
|
||||||
currentSubtitle: {
|
currentSubtitle: {
|
||||||
text: appState.currentSubText,
|
text: appState.currentSubText,
|
||||||
startTime: appState.mpvClient?.currentSubStart ?? null,
|
startTime: appState.mpvClient?.currentSubStart ?? null,
|
||||||
@@ -2169,6 +2176,7 @@ function sendRendererMpvCommand(rawCommand: (string | number)[]): void {
|
|||||||
rawCommand,
|
rawCommand,
|
||||||
appState.activeParsedSubtitleCues,
|
appState.activeParsedSubtitleCues,
|
||||||
appState.mpvClient?.currentTimePos ?? Number.NaN,
|
appState.mpvClient?.currentTimePos ?? Number.NaN,
|
||||||
|
appState.mpvClient?.currentSubDelay ?? 0,
|
||||||
) ?? rawCommand;
|
) ?? rawCommand;
|
||||||
if (isExplicitMpvSeekCommand(command)) {
|
if (isExplicitMpvSeekCommand(command)) {
|
||||||
explicitSeekIntentExpiresAtMs = Date.now() + EXPLICIT_SEEK_INTENT_TTL_MS;
|
explicitSeekIntentExpiresAtMs = Date.now() + EXPLICIT_SEEK_INTENT_TTL_MS;
|
||||||
@@ -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 mediaTimingFrameExtractor = createMediaTimingFrameExtractor();
|
||||||
const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({
|
const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({
|
||||||
getMpvClient: () => appState.mpvClient,
|
getMpvClient: () => appState.mpvClient,
|
||||||
@@ -3204,7 +3131,10 @@ const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({
|
|||||||
acquireMediaWindow: (source, range) => getSharedRemoteMediaWindowCache().acquire(source, range),
|
acquireMediaWindow: (source, range) => getSharedRemoteMediaWindowCache().acquire(source, range),
|
||||||
getSubtitleContextLines: (range) =>
|
getSubtitleContextLines: (range) =>
|
||||||
collectMediaTimingContextLines({
|
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) ?? [],
|
fallbackPrevious: appState.subtitleTimingTracker?.getRecentEntries(40) ?? [],
|
||||||
startTime: range.startTime,
|
startTime: range.startTime,
|
||||||
endTime: range.endTime,
|
endTime: range.endTime,
|
||||||
@@ -4403,7 +4333,6 @@ const {
|
|||||||
void appState.discordPresenceService?.stop();
|
void appState.discordPresenceService?.stop();
|
||||||
appState.discordPresenceService = null;
|
appState.discordPresenceService = null;
|
||||||
},
|
},
|
||||||
unloadPlugins: () => pluginHost.unloadAll(),
|
|
||||||
},
|
},
|
||||||
shouldRestoreWindowsOnActivateMainDeps: {
|
shouldRestoreWindowsOnActivateMainDeps: {
|
||||||
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
|
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
|
||||||
@@ -4673,9 +4602,6 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
|
|||||||
createMpvClient: () => {
|
createMpvClient: () => {
|
||||||
appState.mpvClient = createMpvClientRuntimeService();
|
appState.mpvClient = createMpvClientRuntimeService();
|
||||||
},
|
},
|
||||||
startPlugins: () => {
|
|
||||||
void pluginHost.loadAll();
|
|
||||||
},
|
|
||||||
getResolvedConfig: () => configService.getConfig(),
|
getResolvedConfig: () => configService.getConfig(),
|
||||||
getConfigWarnings: () => configService.getWarnings(),
|
getConfigWarnings: () => configService.getWarnings(),
|
||||||
logConfigWarning: (warning) => appLogger.logConfigWarning(warning),
|
logConfigWarning: (warning) => appLogger.logConfigWarning(warning),
|
||||||
@@ -5372,7 +5298,6 @@ function createMpvClientRuntimeService(): MpvIpcClient {
|
|||||||
client.on('media-path-change', aniSkipRuntime.handleMediaPathChange);
|
client.on('media-path-change', aniSkipRuntime.handleMediaPathChange);
|
||||||
client.on('time-pos-change', aniSkipRuntime.handleTimePosChange);
|
client.on('time-pos-change', aniSkipRuntime.handleTimePosChange);
|
||||||
client.on('client-message', aniSkipRuntime.handleClientMessage);
|
client.on('client-message', aniSkipRuntime.handleClientMessage);
|
||||||
pluginHost.attachMpvClient(client);
|
|
||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5954,11 +5879,6 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
|
|||||||
},
|
},
|
||||||
playNextPlaylistItem: () =>
|
playNextPlaylistItem: () =>
|
||||||
sendMpvCommandRuntime(appState.mpvClient, ['playlist-next', 'force']),
|
sendMpvCommandRuntime(appState.mpvClient, ['playlist-next', 'force']),
|
||||||
runPluginAction: async (pluginActionId) => {
|
|
||||||
const ran = await pluginHost.runAction(pluginActionId);
|
|
||||||
if (!ran) logger.warn(`Plugin action is not registered: ${pluginActionId}`);
|
|
||||||
return ran;
|
|
||||||
},
|
|
||||||
showMpvOsd: (text) => showConfiguredPlaybackFeedback(text),
|
showMpvOsd: (text) => showConfiguredPlaybackFeedback(text),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -6040,15 +5960,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onOverlayModalClosed: (modal, senderWindow, instanceId) => {
|
onOverlayModalClosed: (modal, senderWindow) => {
|
||||||
// A late close for a replaced plugin panel must not hide the panel now showing.
|
|
||||||
if (
|
|
||||||
modal === 'plugin-panel' &&
|
|
||||||
instanceId !== undefined &&
|
|
||||||
!pluginPanelRuntime.handleModalClosed(instanceId)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (modal === 'media-timing-review') {
|
if (modal === 'media-timing-review') {
|
||||||
void mediaTimingReviewRuntime.dispose();
|
void mediaTimingReviewRuntime.dispose();
|
||||||
}
|
}
|
||||||
@@ -6135,7 +6047,11 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
// live mpv sub timings at lookup time so media generation clips the mined line even
|
// live mpv sub timings at lookup time so media generation clips the mined line even
|
||||||
// when extraction finishes long after playback has moved on.
|
// when extraction finishes long after playback has moved on.
|
||||||
recordSubtitleMiningContext: (context) =>
|
recordSubtitleMiningContext: (context) =>
|
||||||
recordSubtitleMiningContext(context ?? captureCurrentPrimarySubtitleMiningContext()),
|
recordSubtitleMiningContext(
|
||||||
|
context
|
||||||
|
? shiftSubtitleRangeToVideoClock(context, appState.mpvClient?.currentSubDelay ?? 0)
|
||||||
|
: captureCurrentPrimarySubtitleMiningContext(),
|
||||||
|
),
|
||||||
tokenizeCurrentSubtitle: async () => {
|
tokenizeCurrentSubtitle: async () => {
|
||||||
const tokenizeSubtitleForCurrent = tokenizeSubtitleDeferred;
|
const tokenizeSubtitleForCurrent = tokenizeSubtitleDeferred;
|
||||||
return resolveCurrentSubtitleForRenderer({
|
return resolveCurrentSubtitleForRenderer({
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ export interface AppReadyRuntimeDepsFactoryInput {
|
|||||||
loadSubtitlePosition: AppReadyRuntimeDeps['loadSubtitlePosition'];
|
loadSubtitlePosition: AppReadyRuntimeDeps['loadSubtitlePosition'];
|
||||||
resolveKeybindings: AppReadyRuntimeDeps['resolveKeybindings'];
|
resolveKeybindings: AppReadyRuntimeDeps['resolveKeybindings'];
|
||||||
createMpvClient: AppReadyRuntimeDeps['createMpvClient'];
|
createMpvClient: AppReadyRuntimeDeps['createMpvClient'];
|
||||||
startPlugins?: AppReadyRuntimeDeps['startPlugins'];
|
|
||||||
reloadConfig: AppReadyRuntimeDeps['reloadConfig'];
|
reloadConfig: AppReadyRuntimeDeps['reloadConfig'];
|
||||||
getResolvedConfig: AppReadyRuntimeDeps['getResolvedConfig'];
|
getResolvedConfig: AppReadyRuntimeDeps['getResolvedConfig'];
|
||||||
getConfigWarnings: AppReadyRuntimeDeps['getConfigWarnings'];
|
getConfigWarnings: AppReadyRuntimeDeps['getConfigWarnings'];
|
||||||
@@ -95,7 +94,6 @@ export function createAppReadyRuntimeDeps(
|
|||||||
loadSubtitlePosition: params.loadSubtitlePosition,
|
loadSubtitlePosition: params.loadSubtitlePosition,
|
||||||
resolveKeybindings: params.resolveKeybindings,
|
resolveKeybindings: params.resolveKeybindings,
|
||||||
createMpvClient: params.createMpvClient,
|
createMpvClient: params.createMpvClient,
|
||||||
startPlugins: params.startPlugins,
|
|
||||||
reloadConfig: params.reloadConfig,
|
reloadConfig: params.reloadConfig,
|
||||||
getResolvedConfig: params.getResolvedConfig,
|
getResolvedConfig: params.getResolvedConfig,
|
||||||
getConfigWarnings: params.getConfigWarnings,
|
getConfigWarnings: params.getConfigWarnings,
|
||||||
|
|||||||
@@ -303,7 +303,7 @@ test('subtitle sidebar open state is restored for replacement visible overlay wi
|
|||||||
/onOverlayModalOpened:\s*\(modal,\s*senderWindow\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
|
/onOverlayModalOpened:\s*\(modal,\s*senderWindow\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
|
||||||
)?.groups?.body;
|
)?.groups?.body;
|
||||||
const closedBlock = source.match(
|
const closedBlock = source.match(
|
||||||
/onOverlayModalClosed:\s*\(modal,\s*senderWindow(?:,\s*instanceId)?\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
|
/onOverlayModalClosed:\s*\(modal,\s*senderWindow\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
|
||||||
)?.groups?.body;
|
)?.groups?.body;
|
||||||
const depsBlock = source.match(/getSubtitleSidebarOpen:\s*\(\)\s*=>\s*(?<body>[^\n,]+)/)?.groups
|
const depsBlock = source.match(/getSubtitleSidebarOpen:\s*\(\)\s*=>\s*(?<body>[^\n,]+)/)?.groups
|
||||||
?.body;
|
?.body;
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ function makeCleanupDeps(calls: string[], overrides: Partial<CleanupDeps> = {}):
|
|||||||
cleanupRemoteMediaWindows: record('cleanupRemoteMediaWindows'),
|
cleanupRemoteMediaWindows: record('cleanupRemoteMediaWindows'),
|
||||||
cleanupJellyfinSubtitleCache: record('cleanupJellyfinSubtitleCache'),
|
cleanupJellyfinSubtitleCache: record('cleanupJellyfinSubtitleCache'),
|
||||||
stopDiscordPresenceService: record('stopDiscordPresenceService'),
|
stopDiscordPresenceService: record('stopDiscordPresenceService'),
|
||||||
unloadPlugins: record('unloadPlugins'),
|
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -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'));
|
assert.ok(calls.indexOf('destroyImmersionTracker') < calls.indexOf('destroyAnkiIntegration'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('on will quit cleanup handler starts plugin unload first and waits for it to settle', async () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
let finishUnload!: () => void;
|
|
||||||
const cleanup = createOnWillQuitCleanupHandler(
|
|
||||||
makeCleanupDeps(calls, {
|
|
||||||
unloadPlugins: () => {
|
|
||||||
calls.push('unloadPlugins');
|
|
||||||
return new Promise<void>((resolve) => (finishUnload = resolve));
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
let settled = false;
|
|
||||||
const pending = cleanup().then(() => {
|
|
||||||
settled = true;
|
|
||||||
});
|
|
||||||
await new Promise((resolve) => setImmediate(resolve));
|
|
||||||
assert.equal(calls[0], 'unloadPlugins');
|
|
||||||
assert.equal(settled, false);
|
|
||||||
|
|
||||||
finishUnload();
|
|
||||||
await pending;
|
|
||||||
assert.equal(settled, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('on will quit cleanup handler cleans subtitle caches when stopping remote session fails', async () => {
|
test('on will quit cleanup handler cleans subtitle caches when stopping remote session fails', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const cleanup = createOnWillQuitCleanupHandler(
|
const cleanup = createOnWillQuitCleanupHandler(
|
||||||
|
|||||||
@@ -59,14 +59,8 @@ export function createOnWillQuitCleanupHandler(deps: {
|
|||||||
cleanupRemoteMediaWindows: () => void;
|
cleanupRemoteMediaWindows: () => void;
|
||||||
cleanupJellyfinSubtitleCache: () => void;
|
cleanupJellyfinSubtitleCache: () => void;
|
||||||
stopDiscordPresenceService: () => void;
|
stopDiscordPresenceService: () => void;
|
||||||
unloadPlugins: () => void | Promise<void>;
|
|
||||||
}) {
|
}) {
|
||||||
return async (): Promise<void> => {
|
return async (): Promise<void> => {
|
||||||
const cleanupErrors: unknown[] = [];
|
|
||||||
// Plugins deactivate first, while the app is still intact; the host bounds how long that takes.
|
|
||||||
const unloadPlugins = (async () => deps.unloadPlugins())().catch((error: unknown) => {
|
|
||||||
cleanupErrors.push(error);
|
|
||||||
});
|
|
||||||
deps.destroyTray();
|
deps.destroyTray();
|
||||||
deps.stopConfigHotReload();
|
deps.stopConfigHotReload();
|
||||||
deps.restorePreviousSecondarySubVisibility();
|
deps.restorePreviousSecondarySubVisibility();
|
||||||
@@ -74,6 +68,7 @@ export function createOnWillQuitCleanupHandler(deps: {
|
|||||||
deps.unregisterAllGlobalShortcuts();
|
deps.unregisterAllGlobalShortcuts();
|
||||||
deps.stopSubtitleWebsocket();
|
deps.stopSubtitleWebsocket();
|
||||||
deps.stopTexthookerService();
|
deps.stopTexthookerService();
|
||||||
|
const cleanupErrors: unknown[] = [];
|
||||||
const stopSyncAutoScheduler = Promise.resolve(deps.stopSyncAutoScheduler()).catch(
|
const stopSyncAutoScheduler = Promise.resolve(deps.stopSyncAutoScheduler()).catch(
|
||||||
(error: unknown) => {
|
(error: unknown) => {
|
||||||
cleanupErrors.push(error);
|
cleanupErrors.push(error);
|
||||||
@@ -123,7 +118,6 @@ export function createOnWillQuitCleanupHandler(deps: {
|
|||||||
deps.cleanupRemoteMediaWindows();
|
deps.cleanupRemoteMediaWindows();
|
||||||
deps.stopDiscordPresenceService();
|
deps.stopDiscordPresenceService();
|
||||||
await stopSyncAutoScheduler;
|
await stopSyncAutoScheduler;
|
||||||
await unloadPlugins;
|
|
||||||
if (cleanupErrors.length > 0) {
|
if (cleanupErrors.length > 0) {
|
||||||
throw cleanupErrors[0];
|
throw cleanupErrors[0];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ function buildCleanupDeps(overrides: Partial<BuilderDeps> = {}) {
|
|||||||
cleanupRemoteMediaWindows: noop,
|
cleanupRemoteMediaWindows: noop,
|
||||||
cleanupJellyfinSubtitleCache: noop,
|
cleanupJellyfinSubtitleCache: noop,
|
||||||
stopDiscordPresenceService: noop,
|
stopDiscordPresenceService: noop,
|
||||||
unloadPlugins: noop,
|
|
||||||
...overrides,
|
...overrides,
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
|||||||
cleanupRemoteMediaWindows: () => void;
|
cleanupRemoteMediaWindows: () => void;
|
||||||
cleanupJellyfinSubtitleCache: () => void;
|
cleanupJellyfinSubtitleCache: () => void;
|
||||||
stopDiscordPresenceService: () => void;
|
stopDiscordPresenceService: () => void;
|
||||||
unloadPlugins: () => void | Promise<void>;
|
|
||||||
}) {
|
}) {
|
||||||
return () => ({
|
return () => ({
|
||||||
destroyTray: () => deps.destroyTray(),
|
destroyTray: () => deps.destroyTray(),
|
||||||
@@ -155,6 +154,5 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
|||||||
cleanupRemoteMediaWindows: () => deps.cleanupRemoteMediaWindows(),
|
cleanupRemoteMediaWindows: () => deps.cleanupRemoteMediaWindows(),
|
||||||
cleanupJellyfinSubtitleCache: () => deps.cleanupJellyfinSubtitleCache(),
|
cleanupJellyfinSubtitleCache: () => deps.cleanupJellyfinSubtitleCache(),
|
||||||
stopDiscordPresenceService: () => deps.stopDiscordPresenceService(),
|
stopDiscordPresenceService: () => deps.stopDiscordPresenceService(),
|
||||||
unloadPlugins: () => deps.unloadPlugins(),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -6,7 +6,6 @@ export function createBuildAppReadyRuntimeMainDepsHandler(deps: AppReadyRuntimeD
|
|||||||
loadSubtitlePosition: deps.loadSubtitlePosition,
|
loadSubtitlePosition: deps.loadSubtitlePosition,
|
||||||
resolveKeybindings: deps.resolveKeybindings,
|
resolveKeybindings: deps.resolveKeybindings,
|
||||||
createMpvClient: deps.createMpvClient,
|
createMpvClient: deps.createMpvClient,
|
||||||
startPlugins: deps.startPlugins,
|
|
||||||
reloadConfig: deps.reloadConfig,
|
reloadConfig: deps.reloadConfig,
|
||||||
getResolvedConfig: deps.getResolvedConfig,
|
getResolvedConfig: deps.getResolvedConfig,
|
||||||
getConfigWarnings: deps.getConfigWarnings,
|
getConfigWarnings: deps.getConfigWarnings,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { toSubtitleClock } from '../../core/services/subtitle-clock';
|
||||||
import type { SubtitleCue, SubtitleData } from '../../types';
|
import type { SubtitleCue, SubtitleData } from '../../types';
|
||||||
import { selectAutoplayStartupCue } from './autoplay-subtitle-primer';
|
import { selectAutoplayStartupCue } from './autoplay-subtitle-primer';
|
||||||
import { primeVisibleOverlaySubtitleFromMpv } from './current-subtitle-snapshot';
|
import { primeVisibleOverlaySubtitleFromMpv } from './current-subtitle-snapshot';
|
||||||
@@ -12,6 +13,7 @@ type AutoplaySubtitlePrimingMpvClient = {
|
|||||||
requestProperty: (name: string) => Promise<unknown>;
|
requestProperty: (name: string) => Promise<unknown>;
|
||||||
currentVideoPath?: string;
|
currentVideoPath?: string;
|
||||||
currentTimePos?: number;
|
currentTimePos?: number;
|
||||||
|
currentSubDelay?: number;
|
||||||
currentSubText?: string;
|
currentSubText?: string;
|
||||||
currentSecondarySubText?: string;
|
currentSecondarySubText?: string;
|
||||||
setCurrentSecondarySubText?: (text: string) => void;
|
setCurrentSecondarySubText?: (text: string) => void;
|
||||||
@@ -156,7 +158,7 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
|
|||||||
const currentTimeSec = Number(client?.currentTimePos ?? deps.getLastObservedTimePos());
|
const currentTimeSec = Number(client?.currentTimePos ?? deps.getLastObservedTimePos());
|
||||||
return resolvePrimarySubtitleText({
|
return resolvePrimarySubtitleText({
|
||||||
liveText: text,
|
liveText: text,
|
||||||
currentTimeSec,
|
currentTimeSec: toSubtitleClock(currentTimeSec, client?.currentSubDelay ?? 0),
|
||||||
cues: deps.getActiveParsedSubtitleCues(),
|
cues: deps.getActiveParsedSubtitleCues(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -270,7 +272,10 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
|
|||||||
const currentTimeSeconds = Number(
|
const currentTimeSeconds = Number(
|
||||||
timePosRaw ?? client?.currentTimePos ?? deps.getLastObservedTimePos() ?? 0,
|
timePosRaw ?? client?.currentTimePos ?? deps.getLastObservedTimePos() ?? 0,
|
||||||
);
|
);
|
||||||
const resolvedTimeSeconds = Number.isFinite(currentTimeSeconds) ? currentTimeSeconds : 0;
|
const resolvedTimeSeconds = toSubtitleClock(
|
||||||
|
Number.isFinite(currentTimeSeconds) ? currentTimeSeconds : 0,
|
||||||
|
client?.currentSubDelay ?? 0,
|
||||||
|
);
|
||||||
const cue = selectAutoplayStartupCue(
|
const cue = selectAutoplayStartupCue(
|
||||||
cues,
|
cues,
|
||||||
resolvedTimeSeconds,
|
resolvedTimeSeconds,
|
||||||
|
|||||||
@@ -2,11 +2,6 @@ import { spawn } from 'node:child_process';
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
import {
|
|
||||||
buildWindowsShimInvocation,
|
|
||||||
isWindowsCommandShim,
|
|
||||||
} from '../../core/utils/windows-command-shim';
|
|
||||||
|
|
||||||
export interface RunCommandResult {
|
export interface RunCommandResult {
|
||||||
exitCode: number | null;
|
exitCode: number | null;
|
||||||
stdout: string;
|
stdout: string;
|
||||||
@@ -147,7 +142,43 @@ export function failureMessage(result: RunCommandResult, fallback: string): stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
function needsWindowsShell(command: string): boolean {
|
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 {
|
function createDefaultRunCommand(): RunCommand {
|
||||||
@@ -158,8 +189,12 @@ function createDefaultRunCommand(): RunCommand {
|
|||||||
try {
|
try {
|
||||||
const env = options.env ?? process.env;
|
const env = options.env ?? process.env;
|
||||||
if (useShell) {
|
if (useShell) {
|
||||||
const invocation = buildWindowsShimInvocation(command, args, env);
|
const shellCommand = [
|
||||||
child = spawn(invocation.file, invocation.args, {
|
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,
|
env,
|
||||||
windowsHide: false,
|
windowsHide: false,
|
||||||
windowsVerbatimArguments: true,
|
windowsVerbatimArguments: true,
|
||||||
|
|||||||
@@ -176,6 +176,21 @@ test('subtitle-track transitions ignore stale parsed cues until replacement cues
|
|||||||
assert.deepEqual(recordedStarts.slice(-1), [20]);
|
assert.deepEqual(recordedStarts.slice(-1), [20]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('subtitle timing history records lines on the video clock under sub-delay', () => {
|
||||||
|
const timing: Array<{ text: string; start: number; end: number }> = [];
|
||||||
|
const { handlers } = buildMainDeps({
|
||||||
|
mpvClient: { currentSubDelay: 2 },
|
||||||
|
subtitleTimingTracker: {
|
||||||
|
recordSubtitle: (text: string, start: number, end: number) =>
|
||||||
|
timing.push({ text, start, end }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
handlers.recordSubtitleTiming('食べる', 5, 8);
|
||||||
|
|
||||||
|
assert.deepEqual(timing, [{ text: '食べる', start: 7, end: 10 }]);
|
||||||
|
});
|
||||||
|
|
||||||
test('canonical ASS cues replace live glyph spam for display, history, and immersion', () => {
|
test('canonical ASS cues replace live glyph spam for display, history, and immersion', () => {
|
||||||
const immersion: Array<{ text: string; start: number; end: number }> = [];
|
const immersion: Array<{ text: string; start: number; end: number }> = [];
|
||||||
const timing: Array<{ text: string; start: number; end: number }> = [];
|
const timing: Array<{ text: string; start: number; end: number }> = [];
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { toSubtitleClock } from '../../core/services/subtitle-clock';
|
||||||
import { createSubtitleLineDedupGate } from '../../core/services/subtitle-line-dedup-gate';
|
import { createSubtitleLineDedupGate } from '../../core/services/subtitle-line-dedup-gate';
|
||||||
import type { MergedToken, SubtitleCue, SubtitleData } from '../../types';
|
import type { MergedToken, SubtitleCue, SubtitleData } from '../../types';
|
||||||
import {
|
import {
|
||||||
@@ -23,6 +24,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
|||||||
currentSubText?: string;
|
currentSubText?: string;
|
||||||
currentSecondarySubText?: string;
|
currentSecondarySubText?: string;
|
||||||
currentTimePos?: number;
|
currentTimePos?: number;
|
||||||
|
currentSubDelay?: number;
|
||||||
requestProperty?: (name: string) => Promise<unknown>;
|
requestProperty?: (name: string) => Promise<unknown>;
|
||||||
} | null;
|
} | null;
|
||||||
immersionTracker: {
|
immersionTracker: {
|
||||||
@@ -161,7 +163,10 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
|||||||
resolveSubtitleText: (liveText: string) =>
|
resolveSubtitleText: (liveText: string) =>
|
||||||
resolvePrimarySubtitleText({
|
resolvePrimarySubtitleText({
|
||||||
liveText,
|
liveText,
|
||||||
currentTimeSec: Number(deps.appState.mpvClient?.currentTimePos),
|
currentTimeSec: toSubtitleClock(
|
||||||
|
Number(deps.appState.mpvClient?.currentTimePos),
|
||||||
|
deps.appState.mpvClient?.currentSubDelay ?? 0,
|
||||||
|
),
|
||||||
cues: deps.appState.activeParsedSubtitleCues,
|
cues: deps.appState.activeParsedSubtitleCues,
|
||||||
}),
|
}),
|
||||||
getCurrentLiveSubtitleText: () => deps.appState.mpvClient?.currentSubText ?? '',
|
getCurrentLiveSubtitleText: () => deps.appState.mpvClient?.currentSubText ?? '',
|
||||||
@@ -231,6 +236,9 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
|||||||
hasSubtitleTimingTracker: () => Boolean(deps.appState.subtitleTimingTracker),
|
hasSubtitleTimingTracker: () => Boolean(deps.appState.subtitleTimingTracker),
|
||||||
recordSubtitleTiming: (text: string, start: number, end: number) => {
|
recordSubtitleTiming: (text: string, start: number, end: number) => {
|
||||||
const secondaryText = deps.appState.mpvClient?.currentSecondarySubText || undefined;
|
const secondaryText = deps.appState.mpvClient?.currentSecondarySubText || undefined;
|
||||||
|
// The tracker feeds multi-line mining, so it stores video-clock times: the subtitle
|
||||||
|
// timings plus the `sub-delay` in effect while the line was on screen.
|
||||||
|
const subDelay = deps.appState.mpvClient?.currentSubDelay ?? 0;
|
||||||
const canonical = resolveCanonicalSample(text, start);
|
const canonical = resolveCanonicalSample(text, start);
|
||||||
if (!canonical) {
|
if (!canonical) {
|
||||||
const recordableText = resolveTextForRecording(text, start);
|
const recordableText = resolveTextForRecording(text, start);
|
||||||
@@ -239,8 +247,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
|||||||
}
|
}
|
||||||
deps.appState.subtitleTimingTracker?.recordSubtitle?.(
|
deps.appState.subtitleTimingTracker?.recordSubtitle?.(
|
||||||
recordableText,
|
recordableText,
|
||||||
start,
|
start + subDelay,
|
||||||
end,
|
end + subDelay,
|
||||||
secondaryText,
|
secondaryText,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -253,8 +261,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
|||||||
recordedTimingCanonicalKeys.add(key);
|
recordedTimingCanonicalKeys.add(key);
|
||||||
deps.appState.subtitleTimingTracker?.recordSubtitle?.(
|
deps.appState.subtitleTimingTracker?.recordSubtitle?.(
|
||||||
cue.text,
|
cue.text,
|
||||||
cue.startTime,
|
cue.startTime + subDelay,
|
||||||
cue.endTime,
|
cue.endTime + subDelay,
|
||||||
secondaryText,
|
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);
|
|
||||||
});
|
|
||||||
@@ -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']);
|
|
||||||
});
|
|
||||||
@@ -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
@@ -105,7 +105,6 @@ import type {
|
|||||||
OverlayNotificationEventPayload,
|
OverlayNotificationEventPayload,
|
||||||
OverlayNotificationPosition,
|
OverlayNotificationPosition,
|
||||||
ChangelogSnapshot,
|
ChangelogSnapshot,
|
||||||
PluginPanelSnapshot,
|
|
||||||
MediaTimingReviewActionResult,
|
MediaTimingReviewActionResult,
|
||||||
MediaTimingReviewOpenPayload,
|
MediaTimingReviewOpenPayload,
|
||||||
MediaTimingReviewPreviewPosition,
|
MediaTimingReviewPreviewPosition,
|
||||||
@@ -141,20 +140,6 @@ const onOpenSessionHelpEvent = createQueuedIpcListenerWithPayload<SessionHelpOpe
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const onOpenChangelogEvent = createQueuedIpcListener(IPC_CHANNELS.event.changelogOpen);
|
const onOpenChangelogEvent = createQueuedIpcListener(IPC_CHANNELS.event.changelogOpen);
|
||||||
function toPluginPanelSnapshot(payload: unknown): PluginPanelSnapshot {
|
|
||||||
const value = (typeof payload === 'object' && payload !== null ? payload : {}) as Partial<
|
|
||||||
Record<keyof PluginPanelSnapshot, unknown>
|
|
||||||
>;
|
|
||||||
return {
|
|
||||||
panelId: typeof value.panelId === 'number' ? value.panelId : -1,
|
|
||||||
title: typeof value.title === 'string' ? value.title : '',
|
|
||||||
markdown: typeof value.markdown === 'string' ? value.markdown : '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const onOpenPluginPanelEvent = createQueuedIpcListenerWithPayload(
|
|
||||||
IPC_CHANNELS.event.pluginPanelOpen,
|
|
||||||
toPluginPanelSnapshot,
|
|
||||||
);
|
|
||||||
const onOpenCharacterDictionaryManagerEvent = createQueuedIpcListener(
|
const onOpenCharacterDictionaryManagerEvent = createQueuedIpcListener(
|
||||||
IPC_CHANNELS.event.characterDictionaryManagerOpen,
|
IPC_CHANNELS.event.characterDictionaryManagerOpen,
|
||||||
);
|
);
|
||||||
@@ -430,17 +415,6 @@ const electronAPI: ElectronAPI = {
|
|||||||
onOpenChangelog: onOpenChangelogEvent,
|
onOpenChangelog: onOpenChangelogEvent,
|
||||||
getChangelogSnapshot: (options?: { refresh?: boolean }): Promise<ChangelogSnapshot> =>
|
getChangelogSnapshot: (options?: { refresh?: boolean }): Promise<ChangelogSnapshot> =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.request.getChangelogSnapshot, options),
|
ipcRenderer.invoke(IPC_CHANNELS.request.getChangelogSnapshot, options),
|
||||||
onOpenPluginPanel: onOpenPluginPanelEvent,
|
|
||||||
onPluginPanelUpdate: (callback: (snapshot: PluginPanelSnapshot) => void): void => {
|
|
||||||
ipcRenderer.on(IPC_CHANNELS.event.pluginPanelUpdate, (_event, payload: unknown) => {
|
|
||||||
callback(toPluginPanelSnapshot(payload));
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onPluginPanelClose: (callback: (panelId: number) => void): void => {
|
|
||||||
ipcRenderer.on(IPC_CHANNELS.event.pluginPanelClose, (_event, panelId: unknown) => {
|
|
||||||
if (typeof panelId === 'number') callback(panelId);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onOpenControllerSelect: onOpenControllerSelectEvent,
|
onOpenControllerSelect: onOpenControllerSelectEvent,
|
||||||
onOpenControllerDebug: onOpenControllerDebugEvent,
|
onOpenControllerDebug: onOpenControllerDebugEvent,
|
||||||
onOpenJimaku: onOpenJimakuEvent,
|
onOpenJimaku: onOpenJimakuEvent,
|
||||||
@@ -532,8 +506,8 @@ const electronAPI: ElectronAPI = {
|
|||||||
mediaId,
|
mediaId,
|
||||||
direction,
|
direction,
|
||||||
),
|
),
|
||||||
notifyOverlayModalClosed: (modal, instanceId) => {
|
notifyOverlayModalClosed: (modal) => {
|
||||||
ipcRenderer.send(IPC_CHANNELS.command.overlayModalClosed, modal, instanceId);
|
ipcRenderer.send(IPC_CHANNELS.command.overlayModalClosed, modal);
|
||||||
},
|
},
|
||||||
notifyOverlayModalOpened: (modal) => {
|
notifyOverlayModalOpened: (modal) => {
|
||||||
ipcRenderer.send(IPC_CHANNELS.command.overlayModalOpened, modal);
|
ipcRenderer.send(IPC_CHANNELS.command.overlayModalOpened, modal);
|
||||||
|
|||||||
@@ -595,7 +595,6 @@ function createKeyboardHandlerHarness() {
|
|||||||
changelogKeydownCount += 1;
|
changelogKeydownCount += 1;
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
handlePluginPanelKeydown: () => false,
|
|
||||||
openSessionHelpModal: () => {},
|
openSessionHelpModal: () => {},
|
||||||
openControllerSelectModal: () => {
|
openControllerSelectModal: () => {
|
||||||
openControllerSelectCount += 1;
|
openControllerSelectCount += 1;
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ export function createKeyboardHandlers(
|
|||||||
handleControllerDebugKeydown: (e: KeyboardEvent) => boolean;
|
handleControllerDebugKeydown: (e: KeyboardEvent) => boolean;
|
||||||
handleSessionHelpKeydown: (e: KeyboardEvent) => boolean;
|
handleSessionHelpKeydown: (e: KeyboardEvent) => boolean;
|
||||||
handleChangelogKeydown: (e: KeyboardEvent) => boolean;
|
handleChangelogKeydown: (e: KeyboardEvent) => boolean;
|
||||||
handlePluginPanelKeydown: (e: KeyboardEvent) => boolean;
|
|
||||||
openSessionHelpModal: (opening: {
|
openSessionHelpModal: (opening: {
|
||||||
bindingKey: 'KeyH' | 'KeyK';
|
bindingKey: 'KeyH' | 'KeyK';
|
||||||
fallbackUsed: boolean;
|
fallbackUsed: boolean;
|
||||||
@@ -1173,11 +1172,6 @@ export function createKeyboardHandlers(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ctx.state.pluginPanelModalOpen) {
|
|
||||||
options.handlePluginPanelKeydown(e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ctx.state.runtimeOptionsModalOpen) {
|
if (ctx.state.runtimeOptionsModalOpen) {
|
||||||
options.handleRuntimeOptionsKeydown(e);
|
options.handleRuntimeOptionsKeydown(e);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1019,15 +1019,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="pluginPanelModal" class="modal hidden" aria-hidden="true">
|
|
||||||
<div class="modal-content plugin-panel-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<div id="pluginPanelTitle" class="modal-title"></div>
|
|
||||||
<button id="pluginPanelClose" class="modal-close" type="button">Close</button>
|
|
||||||
</div>
|
|
||||||
<div id="pluginPanelBody" class="modal-body plugin-panel-body" tabindex="0"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="playlistBrowserModal" class="modal hidden" aria-hidden="true">
|
<div id="playlistBrowserModal" class="modal hidden" aria-hidden="true">
|
||||||
<div class="modal-content playlist-browser-content">
|
<div class="modal-content playlist-browser-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
|
|||||||
@@ -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' },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@ import type {
|
|||||||
CompiledSessionActionBinding,
|
CompiledSessionActionBinding,
|
||||||
CompiledSessionBinding,
|
CompiledSessionBinding,
|
||||||
SessionActionId,
|
SessionActionId,
|
||||||
SessionActionPayload,
|
|
||||||
SessionKeyModifier,
|
SessionKeyModifier,
|
||||||
SessionKeySpec,
|
SessionKeySpec,
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
@@ -190,7 +189,10 @@ function formatSessionKeySpec(key: SessionKeySpec): string {
|
|||||||
.join(' + ');
|
.join(' + ');
|
||||||
}
|
}
|
||||||
|
|
||||||
function describeSessionAction(actionId: SessionActionId, payload?: SessionActionPayload): string {
|
function describeSessionAction(
|
||||||
|
actionId: SessionActionId,
|
||||||
|
payload?: { runtimeOptionId?: string; direction?: 1 | -1 },
|
||||||
|
): string {
|
||||||
switch (actionId) {
|
switch (actionId) {
|
||||||
case 'toggleStatsOverlay':
|
case 'toggleStatsOverlay':
|
||||||
return 'Toggle stats overlay';
|
return 'Toggle stats overlay';
|
||||||
@@ -252,8 +254,6 @@ function describeSessionAction(actionId: SessionActionId, payload?: SessionActio
|
|||||||
return `Cycle runtime option ${payload?.runtimeOptionId ?? 'option'} ${
|
return `Cycle runtime option ${payload?.runtimeOptionId ?? 'option'} ${
|
||||||
payload?.direction === -1 ? 'previous' : 'next'
|
payload?.direction === -1 ? 'previous' : 'next'
|
||||||
}`;
|
}`;
|
||||||
case 'runPluginAction':
|
|
||||||
return `Plugin action ${payload?.pluginActionId ?? ''}`.trim();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,8 +297,6 @@ function sectionForSessionBinding(binding: CompiledSessionBinding): string {
|
|||||||
return 'Playback and navigation';
|
return 'Playback and navigation';
|
||||||
case 'cycleRuntimeOption':
|
case 'cycleRuntimeOption':
|
||||||
return 'Runtime settings';
|
return 'Runtime settings';
|
||||||
case 'runPluginAction':
|
|
||||||
return 'Plugins';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export async function setup() {
|
|||||||
],
|
],
|
||||||
currentSubtitle: { text: '最初の台詞', startTime: 0, endTime: 1 },
|
currentSubtitle: { text: '最初の台詞', startTime: 0, endTime: 1 },
|
||||||
currentTimeSec: 0,
|
currentTimeSec: 0,
|
||||||
|
subDelay: 0,
|
||||||
config: {
|
config: {
|
||||||
...SUBTITLE_DEFAULT_CONFIG.subtitleSidebar,
|
...SUBTITLE_DEFAULT_CONFIG.subtitleSidebar,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -101,7 +102,6 @@ export async function setup() {
|
|||||||
handleControllerDebugKeydown: () => false,
|
handleControllerDebugKeydown: () => false,
|
||||||
handleSessionHelpKeydown: () => false,
|
handleSessionHelpKeydown: () => false,
|
||||||
handleChangelogKeydown: () => false,
|
handleChangelogKeydown: () => false,
|
||||||
handlePluginPanelKeydown: () => false,
|
|
||||||
openSessionHelpModal: () => {},
|
openSessionHelpModal: () => {},
|
||||||
getPlaybackPaused: async () => false,
|
getPlaybackPaused: async () => false,
|
||||||
});
|
});
|
||||||
|
|||||||
Loaded 100 of 123 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user