diff --git a/README.md b/README.md
index 41d04cc9..c406c88c 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
# SubMiner
-Integrates Yomitan with mpv - look up words, mine to Anki, and track your immersion without leaving the player.
+Integrates Yomitan and mpv - on-screen lookups, mine to Anki, and track immersion without leaving the player
[Installation](#quick-start) · [Requirements](#requirements) · [Usage](https://docs.subminer.moe/usage) · [Documentation](https://docs.subminer.moe)
@@ -23,7 +23,7 @@ Integrates Yomitan with mpv - look up words, mine to Anki, and track your immers
### Dictionary Lookups
-Yomitan runs inside the overlay. Trigger a lookup on any word for full dictionary popups — definitions, pitch accent, frequency data — without ever leaving mpv.
+Hover over any word and trigger a lookup to get the full Yomitan popup - definitions, pitch accent, and frequency data - without ever leaving mpv.
@@ -43,7 +43,7 @@ Create an Anki card with the sentence, audio clip, screenshot, and machine trans
### Reading Annotations
-Real-time subtitle annotations with frequency highlighting, JLPT tags, N+1 targeting, and a character name dictionary. Known words fade back; new words stand out. Grammar-only tokens render as plain text so you focus on what matters.
+Real-time subtitle annotations with frequency highlighting, JLPT tags, N+1 targeting, and a character name dictionary. Grammar-only tokens and particles render as plain text so you focus on what matters.
@@ -53,7 +53,7 @@ Real-time subtitle annotations with frequency highlighting, JLPT tags, N+1 targe
### Immersion Dashboard
-Local stats dashboard — watch time, anime library, vocabulary growth, mining throughput, session history, and trends. All stored locally, no third-party tracking.
+Local stats dashboard tracking watch time, vocabulary growth, mining throughput, session history, and trends. All stored locally, no third-party tracking.
@@ -96,7 +96,7 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
WebSocket
-
Annotated subtitle feed for external clients (texthooker pages, custom tools)
+
Plain subtitle feed plus a dedicated annotated feed for texthooker pages and custom tools
@@ -110,16 +110,17 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
## Requirements
-Only **mpv** is required. Everything else is optional but enhances the experience.
+Only **mpv** and Anki+AnkiConnect is required. Everything else is optional but enhances the experience
-| Dependency | Status | What it does |
-| -------------------- | ----------- | ------------------------------------------------- |
-| mpv | Required | The video player SubMiner overlays on |
-| ffmpeg | Recommended | Audio clips & screenshots for Anki cards |
-| MeCab + mecab-ipadic | Recommended | More precise N+1, JLPT, and frequency annotations |
-| yt-dlp | Optional | YouTube playback |
-| fzf / rofi | Optional | Video picker in the launcher |
-| alass / ffsubsync | Optional | Subtitle sync |
+| Dependency | Status | What it does |
+| -------------------- | ----------- | ---------------------------------------- |
+| mpv | Required | The video player SubMiner overlays on |
+| Anki + AnkiConnect | Required | Card creation from the Yomitan popup |
+| ffmpeg | Recommended | Audio clips & screenshots for Anki cards |
+| MeCab + mecab-ipadic | Recommended | More precise annotations and filtering |
+| yt-dlp | Optional | YouTube playback |
+| fzf / rofi | Optional | Video picker in the launcher |
+| alass / ffsubsync | Optional | Subtitle sync |
Platform-specific install commands
@@ -196,25 +197,24 @@ See the [build-from-source guide](https://docs.subminer.moe/installation#from-so
Run SubMiner and the first-run setup wizard will guide you through importing Yomitan dictionaries and optionally installing the `subminer` command-line launcher.
```bash
-# Linux (AUR)
+# Linux
subminer app --setup
# macOS — open SubMiner.app, or:
subminer app --setup
```
-On **Windows**, just run `SubMiner.exe` — setup opens automatically on first launch.
+On **Windows**, just run `SubMiner.exe` and the setup will open automatically on first launch.
-### 3. Play
+### 3. Mine
```bash
-subminer video.mkv # play video with overlay
-subminer stats # open immersion dashboard
-subminer settings # open settings window
-subminer --settings # open settings window via flag
+subminer video.mkv # launch mpv with SubMiner
+subminer /path/to/dir # pick a file with fzf
+subminer -R /path/to/dir # pick a file with rofi (Linux only)
```
-On **Windows**, use the **SubMiner mpv** shortcut created during setup — double-click it or drag a video file onto it.
+On **Windows**, use the **SubMiner mpv** shortcut created during setup. Double-click it or drag a video file onto it.
## Documentation
diff --git a/changes/background-launcher-reuse.md b/changes/background-launcher-reuse.md
index 14938080..8956e279 100644
--- a/changes/background-launcher-reuse.md
+++ b/changes/background-launcher-reuse.md
@@ -1,4 +1,4 @@
type: fixed
area: launcher
-- Reused an already-running background SubMiner app for launcher-opened videos, preserving warmups and keeping the tray app alive after playback closes.
+- Reused an already-running background SubMiner app for launcher-opened videos, closed launcher-owned tray apps after playback ends, and reapplied preferred subtitles for warm launches.
diff --git a/changes/hide-setup-runtime-plugin.md b/changes/hide-setup-runtime-plugin.md
new file mode 100644
index 00000000..2274043d
--- /dev/null
+++ b/changes/hide-setup-runtime-plugin.md
@@ -0,0 +1,4 @@
+type: changed
+area: setup
+
+- Setup: Removed the bundled mpv runtime plugin readiness card; legacy mpv plugin removal still appears when needed.
diff --git a/changes/plain-websocket-annotations.md b/changes/plain-websocket-annotations.md
new file mode 100644
index 00000000..c883df74
--- /dev/null
+++ b/changes/plain-websocket-annotations.md
@@ -0,0 +1,4 @@
+type: fixed
+area: websocket
+
+- WebSocket: Kept the regular subtitle websocket plain-text only; annotation spans and token metadata now stay on the annotation websocket.
diff --git a/changes/setup-subminer-settings-button.md b/changes/setup-subminer-settings-button.md
new file mode 100644
index 00000000..8d0b9195
--- /dev/null
+++ b/changes/setup-subminer-settings-button.md
@@ -0,0 +1,4 @@
+type: added
+area: setup
+
+- Setup: Added an Open SubMiner Settings button to first-run setup and moved Finish setup to the right-side action slot.
diff --git a/config.example.jsonc b/config.example.jsonc
index 5d6059ad..393e2f29 100644
--- a/config.example.jsonc
+++ b/config.example.jsonc
@@ -438,7 +438,7 @@
"autoOpen": false, // Automatically open the subtitle sidebar once during overlay startup. Values: true | false
"layout": "overlay", // Render the subtitle sidebar as a floating overlay or reserve space inside mpv. Values: overlay | embedded
"toggleKey": "Backslash", // KeyboardEvent.code used to toggle the subtitle sidebar open and closed.
- "pauseVideoOnHover": false, // Pause mpv while hovering the subtitle sidebar, then resume on leave. Values: true | false
+ "pauseVideoOnHover": true, // Pause mpv while hovering the subtitle sidebar, then resume on leave. Values: true | false
"autoScroll": true, // Auto-scroll the active subtitle cue into view while playback advances. Values: true | false
"css": {
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
diff --git a/docs-site/configuration.md b/docs-site/configuration.md
index a192951d..aba9ea72 100644
--- a/docs-site/configuration.md
+++ b/docs-site/configuration.md
@@ -275,7 +275,7 @@ Defaults warm local tokenizer/dictionary work (`true` for `mecab`, `yomitanExten
### WebSocket Server
-The overlay includes a built-in WebSocket server that broadcasts subtitle text to connected clients (such as texthooker-ui) for external processing.
+The overlay includes a built-in WebSocket server that broadcasts plain subtitle text to connected clients for external processing.
For endpoint details, payload examples, and client patterns, see [WebSocket / Texthooker API & Integration](/websocket-texthooker-api).
@@ -443,7 +443,7 @@ Configure the parsed-subtitle sidebar modal.
"autoOpen": false,
"layout": "overlay",
"toggleKey": "Backslash",
- "pauseVideoOnHover": false,
+ "pauseVideoOnHover": true,
"autoScroll": true,
"fontFamily": "\"M PLUS 1\", \"Noto Sans CJK JP\", sans-serif",
"fontSize": 16
@@ -457,7 +457,7 @@ Configure the parsed-subtitle sidebar modal.
| `autoOpen` | boolean | Open sidebar automatically on overlay startup (`false` by default) |
| `layout` | string | `"overlay"` floats over mpv; `"embedded"` reserves right-side player space to mimic browser-like layout |
| `toggleKey` | string | `KeyboardEvent.code` used to open/close the sidebar (default: `"Backslash"`) |
-| `pauseVideoOnHover` | boolean | Pause playback while hovering the sidebar cue list |
+| `pauseVideoOnHover` | boolean | Pause playback while hovering the sidebar cue list (`true` by default) |
| `autoScroll` | boolean | Keep the active cue in view while playback advances |
| `maxWidth` | number | Maximum sidebar width in CSS pixels (default: `420`) |
| `opacity` | number | Sidebar opacity between `0` and `1` (default: `0.95`) |
diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc
index 5d6059ad..393e2f29 100644
--- a/docs-site/public/config.example.jsonc
+++ b/docs-site/public/config.example.jsonc
@@ -438,7 +438,7 @@
"autoOpen": false, // Automatically open the subtitle sidebar once during overlay startup. Values: true | false
"layout": "overlay", // Render the subtitle sidebar as a floating overlay or reserve space inside mpv. Values: overlay | embedded
"toggleKey": "Backslash", // KeyboardEvent.code used to toggle the subtitle sidebar open and closed.
- "pauseVideoOnHover": false, // Pause mpv while hovering the subtitle sidebar, then resume on leave. Values: true | false
+ "pauseVideoOnHover": true, // Pause mpv while hovering the subtitle sidebar, then resume on leave. Values: true | false
"autoScroll": true, // Auto-scroll the active subtitle cue into view while playback advances. Values: true | false
"css": {
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
diff --git a/docs-site/subtitle-sidebar.md b/docs-site/subtitle-sidebar.md
index 44df6feb..d0ea946e 100644
--- a/docs-site/subtitle-sidebar.md
+++ b/docs-site/subtitle-sidebar.md
@@ -33,7 +33,7 @@ Enable and configure the sidebar under `subtitleSidebar` in your config file:
"autoOpen": false,
"layout": "overlay",
"toggleKey": "Backslash",
- "pauseVideoOnHover": false,
+ "pauseVideoOnHover": true,
"autoScroll": true,
"fontFamily": "\"M PLUS 1\", \"Noto Sans CJK JP\", sans-serif",
"fontSize": 16
@@ -47,7 +47,7 @@ Enable and configure the sidebar under `subtitleSidebar` in your config file:
| `autoOpen` | boolean | `false` | Open the sidebar automatically on overlay startup |
| `layout` | string | `"overlay"` | `"overlay"` floats over mpv; `"embedded"` reserves right-side player space |
| `toggleKey` | string | `"Backslash"` | `KeyboardEvent.code` for the toggle shortcut |
-| `pauseVideoOnHover` | boolean | `false` | Pause playback while hovering the cue list |
+| `pauseVideoOnHover` | boolean | `true` | Pause playback while hovering the cue list |
| `autoScroll` | boolean | `true` | Keep the active cue in view during playback |
| `maxWidth` | number | `420` | Maximum sidebar width in CSS pixels |
| `opacity` | number | `0.95` | Sidebar opacity between `0` and `1` |
diff --git a/docs-site/websocket-texthooker-api.md b/docs-site/websocket-texthooker-api.md
index 44fb9066..ae9b5b3b 100644
--- a/docs-site/websocket-texthooker-api.md
+++ b/docs-site/websocket-texthooker-api.md
@@ -52,7 +52,7 @@ If you use the [mpv plugin](/mpv-plugin), it can also start a texthooker-only he
### 1. Subtitle WebSocket
-Use the basic subtitle websocket when you only need the current subtitle line and a ready-to-render HTML sentence string.
+Use the basic subtitle websocket when you only need the current subtitle line as plain text.
- **Default URL:** `ws://127.0.0.1:6677`
- **Transport:** local WebSocket server bound to `127.0.0.1`
@@ -64,6 +64,36 @@ When a client connects, SubMiner immediately sends the latest subtitle payload i
#### Message shape
+```json
+{
+ "version": 1,
+ "text": "無事",
+ "sentence": "無事",
+ "tokens": []
+}
+```
+
+#### Field reference
+
+| Field | Type | Notes |
+| --- | --- | --- |
+| `version` | number | Current websocket payload version. Today this is `1`. |
+| `text` | string | Raw subtitle text. |
+| `sentence` | string | Plain subtitle text with line breaks represented as ` `. No annotation spans or attributes. |
+| `tokens` | array | Always empty on the basic subtitle websocket. |
+
+### 2. Annotation WebSocket
+
+Use the annotation websocket for custom clients that want the same structured token payload the bundled texthooker UI consumes.
+
+- **Default URL:** `ws://127.0.0.1:6678`
+- **Payload shape:** JSON payload with `text`, rendered `sentence` HTML, and token metadata
+- **Primary difference:** this stream is intended to stay on even when the basic websocket auto-disables because `mpv_websocket` is installed
+
+In practice, if you are building a new client, prefer `annotationWebsocket` unless you specifically need compatibility with an existing `websocket` consumer.
+
+#### Message shape
+
```json
{
"version": 1,
@@ -91,16 +121,7 @@ When a client connects, SubMiner immediately sends the latest subtitle payload i
}
```
-#### Field reference
-
-| Field | Type | Notes |
-| --- | --- | --- |
-| `version` | number | Current websocket payload version. Today this is `1`. |
-| `text` | string | Raw subtitle text. |
-| `sentence` | string | HTML string with `` wrappers and `data-*` attributes for client rendering. |
-| `tokens` | array | Token metadata; empty when the subtitle is not tokenized yet. |
-
-Each token may include:
+Each annotation token may include:
| Token field | Type | Notes |
| --- | --- | --- |
@@ -119,16 +140,6 @@ Each token may include:
| `frequencyRankLabel` | string or `null` | Preformatted rank label for UIs |
| `jlptLevelLabel` | string or `null` | Preformatted JLPT label for UIs |
-### 2. Annotation WebSocket
-
-Use the annotation websocket for custom clients that want the same structured token payload the bundled texthooker UI consumes.
-
-- **Default URL:** `ws://127.0.0.1:6678`
-- **Payload shape:** same JSON contract as the basic subtitle websocket
-- **Primary difference:** this stream is intended to stay on even when the basic websocket auto-disables because `mpv_websocket` is installed
-
-In practice, if you are building a new client, prefer `annotationWebsocket` unless you specifically need compatibility with an existing `websocket` consumer.
-
### 3. HTML markup conventions
The `sentence` field is pre-rendered HTML generated by SubMiner. Depending on token state, it can include classes such as:
diff --git a/launcher/commands/playback-command.test.ts b/launcher/commands/playback-command.test.ts
index 23365e37..ec5a93f6 100644
--- a/launcher/commands/playback-command.test.ts
+++ b/launcher/commands/playback-command.test.ts
@@ -206,3 +206,65 @@ test('plugin auto-start playback leaves app lifetime to managed-playback owner',
state.overlayManagedByLauncher = false;
}
});
+
+test('plugin auto-start playback attaches a warm background app through the launcher', async () => {
+ const context = createContext();
+ context.args = {
+ ...context.args,
+ target: '/tmp/movie.mkv',
+ targetKind: 'file',
+ };
+ context.pluginRuntimeConfig = {
+ socketPath: '/tmp/subminer.sock',
+ binaryPath: '',
+ backend: 'auto',
+ autoStart: true,
+ autoStartVisibleOverlay: true,
+ autoStartPauseUntilReady: true,
+ texthookerEnabled: true,
+ aniskipEnabled: true,
+ aniskipButtonKey: 'TAB',
+ };
+ const calls: string[] = [];
+ const receivedStartMpvOptions: Record[] = [];
+
+ await runPlaybackCommandWithDeps(context, {
+ ensurePlaybackSetupReady: async () => {},
+ chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
+ checkDependencies: () => {},
+ registerCleanup: () => {},
+ startMpv: async (
+ _target,
+ _targetKind,
+ _args,
+ _socketPath,
+ _appPath,
+ _preloadedSubtitles,
+ options,
+ ) => {
+ calls.push('startMpv');
+ if (options) {
+ receivedStartMpvOptions.push(options as Record);
+ }
+ },
+ waitForUnixSocketReady: async () => true,
+ startOverlay: async (_appPath, _args, _socketPath, extraAppArgs = []) => {
+ calls.push(`startOverlay:${extraAppArgs.join(' ')}`);
+ },
+ launchAppCommandDetached: () => {},
+ log: () => {},
+ cleanupPlaybackSession: async () => {},
+ getMpvProc: () => null,
+ isAppControlServerAvailable: async () => true,
+ } as Parameters[1] & {
+ isAppControlServerAvailable: () => Promise;
+ });
+
+ assert.deepEqual(calls, ['startMpv', 'startOverlay:--show-visible-overlay --texthooker']);
+ assert.equal(receivedStartMpvOptions[0]?.startPaused, false);
+ assert.equal(
+ (receivedStartMpvOptions[0]?.runtimePluginConfig as { autoStart?: boolean } | undefined)
+ ?.autoStart,
+ false,
+ );
+});
diff --git a/launcher/commands/playback-command.ts b/launcher/commands/playback-command.ts
index e45ed7d9..e2a82550 100644
--- a/launcher/commands/playback-command.ts
+++ b/launcher/commands/playback-command.ts
@@ -8,6 +8,7 @@ import {
cleanupPlaybackSession,
launchAppCommandDetached,
resolveLauncherRuntimePluginPath,
+ isRunningAppControlServerAvailable,
startMpv,
startOverlay,
state,
@@ -146,6 +147,7 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
waitForUnixSocketReady,
startOverlay,
launchAppCommandDetached,
+ isAppControlServerAvailable: isRunningAppControlServerAvailable,
log,
cleanupPlaybackSession,
getMpvProc: () => state.mpvProc,
@@ -164,6 +166,7 @@ type PlaybackCommandDeps = {
waitForUnixSocketReady: typeof waitForUnixSocketReady;
startOverlay: typeof startOverlay;
launchAppCommandDetached: typeof launchAppCommandDetached;
+ isAppControlServerAvailable?: (logLevel: Args['logLevel']) => Promise;
log: typeof log;
cleanupPlaybackSession: typeof cleanupPlaybackSession;
getMpvProc: () => typeof state.mpvProc;
@@ -213,8 +216,19 @@ export async function runPlaybackCommandWithDeps(
deps.log('info', args.logLevel, 'YouTube subtitle flow: app-owned picker after mpv bootstrap');
}
+ const pluginAutoStartEnabled = pluginRuntimeConfig.autoStart;
+ const shouldLauncherAttachRunningApp =
+ pluginAutoStartEnabled &&
+ !args.startOverlay &&
+ !args.autoStartOverlay &&
+ !isAppOwnedYoutubeFlow &&
+ ((await deps.isAppControlServerAvailable?.(args.logLevel)) ?? false);
+ const effectivePluginRuntimeConfig = shouldLauncherAttachRunningApp
+ ? { ...pluginRuntimeConfig, autoStart: false }
+ : pluginRuntimeConfig;
+
const shouldPauseUntilOverlayReady =
- pluginRuntimeConfig.autoStart &&
+ effectivePluginRuntimeConfig.autoStart &&
pluginRuntimeConfig.autoStartVisibleOverlay &&
pluginRuntimeConfig.autoStartPauseUntilReady;
@@ -238,16 +252,19 @@ export async function runPlaybackCommandWithDeps(
disableYoutubeSubtitleAutoLoad: isAppOwnedYoutubeFlow,
runtimePluginPath: resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
runtimePluginConfig: {
- ...pluginRuntimeConfig,
+ ...effectivePluginRuntimeConfig,
backend: args.backend,
- texthookerEnabled: args.useTexthooker && pluginRuntimeConfig.texthookerEnabled,
+ texthookerEnabled: args.useTexthooker && effectivePluginRuntimeConfig.texthookerEnabled,
},
},
);
const ready = await deps.waitForUnixSocketReady(mpvSocketPath, 10000);
- const pluginAutoStartEnabled = pluginRuntimeConfig.autoStart;
- const shouldStartOverlay = args.startOverlay || args.autoStartOverlay || isAppOwnedYoutubeFlow;
+ const shouldStartOverlay =
+ args.startOverlay ||
+ args.autoStartOverlay ||
+ isAppOwnedYoutubeFlow ||
+ shouldLauncherAttachRunningApp;
if (shouldStartOverlay) {
if (ready) {
deps.log('info', args.logLevel, 'MPV IPC socket ready, starting SubMiner overlay');
@@ -258,14 +275,17 @@ export async function runPlaybackCommandWithDeps(
'MPV IPC socket not ready after timeout, starting SubMiner overlay anyway',
);
}
- await deps.startOverlay(
- appPath,
- args,
- mpvSocketPath,
- isAppOwnedYoutubeFlow
- ? ['--youtube-play', selectedTarget.target, '--youtube-mode', youtubeMode]
- : [],
- );
+ const extraAppArgs = isAppOwnedYoutubeFlow
+ ? ['--youtube-play', selectedTarget.target, '--youtube-mode', youtubeMode]
+ : shouldLauncherAttachRunningApp
+ ? [
+ pluginRuntimeConfig.autoStartVisibleOverlay
+ ? '--show-visible-overlay'
+ : '--hide-visible-overlay',
+ ...(pluginRuntimeConfig.texthookerEnabled ? ['--texthooker'] : []),
+ ]
+ : [];
+ await deps.startOverlay(appPath, args, mpvSocketPath, extraAppArgs);
} else if (pluginAutoStartEnabled) {
if (ready) {
deps.log('info', args.logLevel, 'MPV IPC socket ready, relying on mpv plugin auto-start');
diff --git a/launcher/mpv.test.ts b/launcher/mpv.test.ts
index 2e1a621b..0b2beda7 100644
--- a/launcher/mpv.test.ts
+++ b/launcher/mpv.test.ts
@@ -655,6 +655,48 @@ test('startOverlay captures app stdout and stderr into app log', async () => {
}
});
+test('startOverlay starts launcher-owned playback in background managed mode', async () => {
+ const { dir, socketPath } = createTempSocketPath();
+ const appPath = path.join(dir, 'fake-subminer.sh');
+ const appInvocationsPath = path.join(dir, 'app-invocations.log');
+ fs.writeFileSync(
+ appPath,
+ [
+ '#!/bin/sh',
+ `printf '%s\\n' "$@" >> ${JSON.stringify(appInvocationsPath)}`,
+ 'if [ "$1" = "--app-ping" ]; then exit 1; fi',
+ 'exit 0',
+ '',
+ ].join('\n'),
+ );
+ fs.chmodSync(appPath, 0o755);
+ fs.writeFileSync(socketPath, '');
+ const originalCreateConnection = net.createConnection;
+ try {
+ net.createConnection = (() => {
+ const socket = new EventEmitter() as net.Socket;
+ socket.destroy = (() => socket) as net.Socket['destroy'];
+ socket.setTimeout = (() => socket) as net.Socket['setTimeout'];
+ setTimeout(() => socket.emit('connect'), 10);
+ return socket;
+ }) as typeof net.createConnection;
+
+ await startOverlay(appPath, makeArgs(), socketPath);
+
+ const invocationText = fs.readFileSync(appInvocationsPath, 'utf8');
+ assert.match(invocationText, /--background/);
+ assert.match(invocationText, /--managed-playback/);
+ assert.equal(state.overlayManagedByLauncher, true);
+ assert.equal(state.appPath, appPath);
+ } finally {
+ net.createConnection = originalCreateConnection;
+ state.overlayProc = null;
+ state.overlayManagedByLauncher = false;
+ state.appPath = '';
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+});
+
test('startOverlay borrows an already-running background app instead of owning its lifecycle', async () => {
const { dir, socketPath } = createTempSocketPath();
const appPath = path.join(dir, 'fake-subminer.sh');
@@ -686,6 +728,7 @@ test('startOverlay borrows an already-running background app instead of owning i
const invocationText = fs.readFileSync(appInvocationsPath, 'utf8');
assert.match(invocationText, /--app-ping/);
assert.match(invocationText, /--start/);
+ assert.doesNotMatch(invocationText, /--background/);
assert.equal(state.overlayManagedByLauncher, false);
assert.equal(state.appPath, '');
} finally {
@@ -697,6 +740,89 @@ test('startOverlay borrows an already-running background app instead of owning i
}
});
+test('startOverlay attaches through the running app control socket without spawning another app command', async () => {
+ if (process.platform === 'win32') return;
+
+ const { dir, socketPath } = createTempSocketPath();
+ const controlSocketPath = path.join(dir, 'control.sock');
+ const appPath = path.join(dir, 'fake-subminer.sh');
+ const appInvocationsPath = path.join(dir, 'app-invocations.log');
+ const receivedControlArgv: string[][] = [];
+ const originalControlSocket = process.env.SUBMINER_APP_CONTROL_SOCKET;
+
+ fs.writeFileSync(
+ appPath,
+ [
+ '#!/bin/sh',
+ `printf '%s\\n' "$@" >> ${JSON.stringify(appInvocationsPath)}`,
+ 'if [ "$1" = "--app-ping" ]; then exit 0; fi',
+ 'exit 0',
+ '',
+ ].join('\n'),
+ );
+ fs.chmodSync(appPath, 0o755);
+
+ const mpvServer = net.createServer((socket) => socket.end());
+ const controlServer = net.createServer((socket) => {
+ let buffer = '';
+ socket.on('data', (chunk) => {
+ buffer += chunk.toString('utf8');
+ const line = buffer.split(/\r?\n/, 1)[0];
+ if (!line) return;
+ const payload = JSON.parse(line) as { argv?: unknown };
+ if (Array.isArray(payload.argv)) {
+ receivedControlArgv.push(
+ payload.argv.filter((value): value is string => typeof value === 'string'),
+ );
+ }
+ socket.end(JSON.stringify({ ok: true }) + '\n');
+ });
+ });
+
+ try {
+ process.env.SUBMINER_APP_CONTROL_SOCKET = controlSocketPath;
+ await new Promise((resolve, reject) => {
+ mpvServer.once('error', reject);
+ mpvServer.listen(socketPath, resolve);
+ });
+ await new Promise((resolve, reject) => {
+ controlServer.once('error', reject);
+ controlServer.listen(controlSocketPath, resolve);
+ });
+
+ await startOverlay(appPath, makeArgs(), socketPath);
+
+ const invocationText = fs.existsSync(appInvocationsPath)
+ ? fs.readFileSync(appInvocationsPath, 'utf8')
+ : '';
+ assert.equal(invocationText, '');
+ assert.equal(receivedControlArgv.length, 1);
+ assert.deepEqual(receivedControlArgv[0]?.slice(0, 7), [
+ '--start',
+ '--managed-playback',
+ '--backend',
+ 'x11',
+ '--socket',
+ socketPath,
+ '--log-level',
+ ]);
+ assert.equal(state.overlayManagedByLauncher, false);
+ assert.equal(state.appPath, '');
+ } finally {
+ if (originalControlSocket === undefined) {
+ delete process.env.SUBMINER_APP_CONTROL_SOCKET;
+ } else {
+ process.env.SUBMINER_APP_CONTROL_SOCKET = originalControlSocket;
+ }
+ await new Promise((resolve) => mpvServer.close(() => resolve()));
+ await new Promise((resolve) => controlServer.close(() => resolve()));
+ state.overlayProc = null;
+ state.overlayManagedByLauncher = false;
+ state.appPath = '';
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+});
+
test('startOverlay keeps lifecycle ownership for its already-managed app', async () => {
const { dir, socketPath } = createTempSocketPath();
const appPath = path.join(dir, 'fake-subminer.sh');
diff --git a/launcher/mpv.ts b/launcher/mpv.ts
index f8298bfc..ba95ea9f 100644
--- a/launcher/mpv.ts
+++ b/launcher/mpv.ts
@@ -4,6 +4,11 @@ import os from 'node:os';
import net from 'node:net';
import { spawn, spawnSync } from 'node:child_process';
import { buildMpvLaunchModeArgs } from '../src/shared/mpv-launch-mode.js';
+import {
+ isAppControlServerAvailable as checkAppControlServerAvailable,
+ sendAppControlCommand,
+} from '../src/shared/app-control-client.js';
+import { getDefaultConfigDir } from '../src/shared/setup-state.js';
import {
detectInstalledMpvPlugin,
type InstalledMpvPluginDetection,
@@ -1004,19 +1009,70 @@ export async function startOverlay(
): Promise {
const backend = detectBackend(args.backend);
log('info', args.logLevel, `Starting SubMiner overlay (backend: ${backend})...`);
- const appAlreadyRunning = isAppAlreadyRunning(appPath, args.logLevel);
+ const alreadyManagedByLauncher = state.overlayManagedByLauncher && state.appPath === appPath;
- const overlayArgs = ['--start', '--backend', backend, '--socket', socketPath, ...extraAppArgs];
+ const overlayArgs = [
+ '--start',
+ '--managed-playback',
+ '--backend',
+ backend,
+ '--socket',
+ socketPath,
+ ...extraAppArgs,
+ ];
if (args.logLevel !== 'info') overlayArgs.push('--log-level', args.logLevel);
if (args.useTexthooker) overlayArgs.push('--texthooker');
- const target = resolveAppSpawnTarget(appPath, overlayArgs);
+ const controlResult = await sendAppControlCommand(overlayArgs, {
+ configDir: getLauncherConfigDir(),
+ });
+ if (controlResult.ok) {
+ log('debug', args.logLevel, 'Attached to running SubMiner app via control socket');
+ if (alreadyManagedByLauncher) {
+ markOverlayManagedByLauncher(appPath);
+ } else {
+ clearOverlayManagedByLauncher();
+ state.overlayProc = null;
+ }
+
+ const socketReady = await waitForUnixSocketReady(
+ socketPath,
+ OVERLAY_START_SOCKET_READY_TIMEOUT_MS,
+ );
+ if (!socketReady) {
+ log(
+ 'debug',
+ args.logLevel,
+ 'Overlay start continuing before mpv socket readiness was confirmed',
+ );
+ }
+ return;
+ }
+ if (controlResult.unavailable !== true) {
+ log(
+ 'warn',
+ args.logLevel,
+ `Running SubMiner app control command failed: ${controlResult.error ?? 'unknown error'}`,
+ );
+ if (!alreadyManagedByLauncher) {
+ clearOverlayManagedByLauncher();
+ state.overlayProc = null;
+ }
+ return;
+ }
+
+ const appAlreadyRunning = isAppAlreadyRunning(appPath, args.logLevel);
+ const borrowingExistingApp = appAlreadyRunning && !alreadyManagedByLauncher;
+ const spawnOverlayArgs = [...overlayArgs];
+ if (!borrowingExistingApp) spawnOverlayArgs.unshift('--background');
+
+ const target = resolveAppSpawnTarget(appPath, spawnOverlayArgs);
state.overlayProc = spawn(target.command, target.args, {
stdio: ['ignore', 'pipe', 'pipe'],
env: buildAppEnv(process.env, target.env),
});
attachAppProcessLogging(state.overlayProc);
- if (appAlreadyRunning && !(state.overlayManagedByLauncher && state.appPath === appPath)) {
+ if (borrowingExistingApp) {
log(
'debug',
args.logLevel,
@@ -1045,6 +1101,23 @@ export async function startOverlay(
}
}
+function getLauncherConfigDir(): string {
+ return getDefaultConfigDir({
+ xdgConfigHome: process.env.XDG_CONFIG_HOME,
+ homeDir: os.homedir(),
+ });
+}
+
+export async function isRunningAppControlServerAvailable(logLevel: LogLevel): Promise {
+ const available = await checkAppControlServerAvailable({
+ configDir: getLauncherConfigDir(),
+ });
+ if (available) {
+ log('debug', logLevel, 'Running SubMiner app control socket detected');
+ }
+ return available;
+}
+
export function markOverlayManagedByLauncher(appPath?: string): void {
if (appPath) {
state.appPath = appPath;
diff --git a/launcher/smoke.e2e.test.ts b/launcher/smoke.e2e.test.ts
index 6aee89c6..e9b3201d 100644
--- a/launcher/smoke.e2e.test.ts
+++ b/launcher/smoke.e2e.test.ts
@@ -238,6 +238,84 @@ async function waitForJsonLines(
}
}
+async function waitForFile(filePath: string, timeoutMs = 1500): Promise {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ if (fs.existsSync(filePath)) return;
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ }
+}
+
+async function startFakeControlServer(
+ smokeCase: SmokeCase,
+): Promise<{ socketPath: string; logPath: string; stop: () => Promise }> {
+ const socketPath = path.join(smokeCase.socketDir, 'app-control.sock');
+ const logPath = path.join(smokeCase.artifactsDir, 'fake-control.log');
+ const readyPath = path.join(smokeCase.artifactsDir, 'fake-control.ready');
+ const scriptPath = path.join(smokeCase.artifactsDir, 'fake-control-server.js');
+
+ fs.writeFileSync(
+ scriptPath,
+ `const fs = require('node:fs');
+const net = require('node:net');
+const path = require('node:path');
+
+const socketPath = ${JSON.stringify(socketPath)};
+const logPath = ${JSON.stringify(logPath)};
+const readyPath = ${JSON.stringify(readyPath)};
+try { fs.rmSync(socketPath, { force: true }); } catch {}
+fs.mkdirSync(path.dirname(socketPath), { recursive: true });
+
+const server = net.createServer((socket) => {
+ let buffer = '';
+ socket.on('data', (chunk) => {
+ buffer += chunk.toString('utf8');
+ const line = buffer.split(/\\r?\\n/, 1)[0];
+ if (!line) return;
+ fs.appendFileSync(logPath, line + '\\n');
+ socket.end(JSON.stringify({ ok: true }) + '\\n');
+ });
+});
+
+server.listen(socketPath, () => {
+ fs.writeFileSync(readyPath, 'ready');
+});
+
+const shutdown = () => {
+ server.close(() => {
+ try { fs.rmSync(socketPath, { force: true }); } catch {}
+ process.exit(0);
+ });
+};
+process.on('SIGTERM', shutdown);
+process.on('SIGINT', shutdown);
+setInterval(() => {}, 1000);
+`,
+ );
+
+ const proc = spawn(process.execPath, [scriptPath], { stdio: 'ignore' });
+ await waitForFile(readyPath);
+
+ return {
+ socketPath,
+ logPath,
+ stop: async () => {
+ if (proc.exitCode !== null || proc.signalCode !== null) return;
+ proc.kill('SIGTERM');
+ await new Promise((resolve) => {
+ const timer = setTimeout(() => {
+ proc.kill('SIGKILL');
+ resolve();
+ }, 1000);
+ proc.once('close', () => {
+ clearTimeout(timer);
+ resolve();
+ });
+ });
+ },
+ };
+}
+
test('launcher smoke fixture seeds completed setup state', () => {
const smokeCase = createSmokeCase('setup-state');
try {
@@ -295,7 +373,7 @@ test('launcher mpv status returns ready when socket is connectable', async () =>
});
test(
- 'launcher start-overlay run forwards socket/backend and keeps background app alive after mpv exits',
+ 'launcher start-overlay run forwards socket/backend and stops owned background app after mpv exits',
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS },
async () => {
await withSmokeCase('overlay-start-stop', async (smokeCase) => {
@@ -330,7 +408,9 @@ test(
const appStartArgs = appStartEntries[0]?.argv;
assert.equal(Array.isArray(appStartArgs), true);
+ assert.equal((appStartArgs as string[]).includes('--background'), true);
assert.equal((appStartArgs as string[]).includes('--start'), true);
+ assert.equal((appStartArgs as string[]).includes('--managed-playback'), true);
assert.equal((appStartArgs as string[]).includes('--backend'), true);
assert.equal((appStartArgs as string[]).includes('x11'), true);
assert.equal((appStartArgs as string[]).includes('--socket'), true);
@@ -351,44 +431,53 @@ test(
);
test(
- 'launcher start-overlay borrows a running background app and does not stop it after mpv exits',
+ 'launcher start-overlay attaches to a running background app without spawning another app command',
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS },
async () => {
await withSmokeCase('overlay-borrow-background', async (smokeCase) => {
+ const controlServer = await startFakeControlServer(smokeCase);
const env = {
...makeTestEnv(smokeCase),
SUBMINER_FAKE_APP_RUNNING: '1',
+ SUBMINER_APP_CONTROL_SOCKET: controlServer.socketPath,
};
- const result = runLauncher(
- smokeCase,
- ['--backend', 'x11', '--start-overlay', smokeCase.videoPath],
- env,
- 'overlay-borrow-background',
- );
+ try {
+ const result = runLauncher(
+ smokeCase,
+ ['--backend', 'x11', '--start-overlay', smokeCase.videoPath],
+ env,
+ 'overlay-borrow-background',
+ );
- const appLogPath = path.join(smokeCase.artifactsDir, 'fake-app.log');
- const appStartPath = path.join(smokeCase.artifactsDir, 'fake-app-start.log');
- const appStopPath = path.join(smokeCase.artifactsDir, 'fake-app-stop.log');
- await waitForJsonLines(appStartPath, 1);
+ const appLogPath = path.join(smokeCase.artifactsDir, 'fake-app.log');
+ const appStartPath = path.join(smokeCase.artifactsDir, 'fake-app-start.log');
+ const appStopPath = path.join(smokeCase.artifactsDir, 'fake-app-stop.log');
+ await waitForJsonLines(controlServer.logPath, 1);
- const appEntries = readJsonLines(appLogPath);
- const appStartEntries = readJsonLines(appStartPath);
- const appStopEntries = readJsonLines(appStopPath);
- const mpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log'));
- const mpvError = mpvEntries.find(
- (entry): entry is { error: string } => typeof entry.error === 'string',
- )?.error;
- const unixSocketDenied =
- typeof mpvError === 'string' && /eperm|operation not permitted/i.test(mpvError);
+ const appEntries = readJsonLines(appLogPath);
+ const appStartEntries = readJsonLines(appStartPath);
+ const appStopEntries = readJsonLines(appStopPath);
+ const controlEntries = readJsonLines(controlServer.logPath);
+ const mpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log'));
+ const mpvError = mpvEntries.find(
+ (entry): entry is { error: string } => typeof entry.error === 'string',
+ )?.error;
+ const unixSocketDenied =
+ typeof mpvError === 'string' && /eperm|operation not permitted/i.test(mpvError);
- assert.equal(result.status, unixSocketDenied ? 3 : 0);
- assert.ok(
- appEntries.some(
- (entry) => Array.isArray(entry.argv) && (entry.argv as string[]).includes('--app-ping'),
- ),
- );
- assert.equal(appStartEntries.length, 1);
- assert.equal(appStopEntries.length, 0);
+ assert.equal(result.status, unixSocketDenied ? 3 : 0);
+ assert.equal(appEntries.length, 0);
+ assert.equal(appStartEntries.length, 0);
+ assert.equal(appStopEntries.length, 0);
+ assert.equal(controlEntries.length, 1);
+ const controlArgs = controlEntries[0]?.argv;
+ assert.equal(Array.isArray(controlArgs), true);
+ assert.equal((controlArgs as string[]).includes('--background'), false);
+ assert.equal((controlArgs as string[]).includes('--start'), true);
+ assert.equal((controlArgs as string[]).includes('--managed-playback'), true);
+ } finally {
+ await controlServer.stop();
+ }
});
},
);
diff --git a/plugin/subminer/process.lua b/plugin/subminer/process.lua
index 1306ffbe..b0fcf3cc 100644
--- a/plugin/subminer/process.lua
+++ b/plugin/subminer/process.lua
@@ -207,6 +207,9 @@ function M.create(ctx)
end
if action == "start" then
+ if overrides.background ~= false then
+ table.insert(args, "--background")
+ end
table.insert(args, "--managed-playback")
local backend = resolve_backend(overrides.backend)
@@ -504,10 +507,13 @@ function M.create(ctx)
end)
end
- launch_overlay_with_retry(1)
- if texthooker_enabled then
- ensure_texthooker_running(function() end)
- end
+ environment.is_subminer_app_running_async(function(app_running)
+ overrides.background = not app_running
+ launch_overlay_with_retry(1)
+ if texthooker_enabled then
+ ensure_texthooker_running(function() end)
+ end
+ end, { force_refresh = true })
end
local function start_overlay_from_script_message(...)
diff --git a/scripts/test-plugin-start-gate.lua b/scripts/test-plugin-start-gate.lua
index adb1511b..fbad4042 100644
--- a/scripts/test-plugin-start-gate.lua
+++ b/scripts/test-plugin-start-gate.lua
@@ -757,17 +757,17 @@ do
assert_true(call ~= nil, "AppImage start should issue an async subprocess")
assert_true(#call.args == 1 and call.args[1] == appimage_path, "AppImage subprocess should not receive raw CLI flags")
assert_true(env_has(call, "PATH=/usr/bin"), "AppImage subprocess should preserve existing environment")
- assert_true(env_has(call, "SUBMINER_APP_ARGC=7"), "AppImage subprocess should transport app arg count")
+ assert_true(env_has(call, "SUBMINER_APP_ARGC=8"), "AppImage subprocess should transport app arg count")
assert_true(env_has(call, "SUBMINER_APP_ARG_0=--start"), "AppImage subprocess should transport --start")
assert_true(
- env_has(call, "SUBMINER_APP_ARG_1=--managed-playback"),
- "AppImage subprocess should transport --managed-playback"
+ env_has(call, "SUBMINER_APP_ARG_1=--background"),
+ "AppImage subprocess should transport --background"
)
assert_true(
- not env_has(call, "SUBMINER_APP_ARG_1=--background"),
- "AppImage subprocess should not transport --background for video-owned playback"
+ env_has(call, "SUBMINER_APP_ARG_2=--managed-playback"),
+ "AppImage subprocess should transport --managed-playback"
)
- assert_true(env_has(call, "SUBMINER_APP_ARG_6=--hide-visible-overlay"), "AppImage subprocess should transport visibility flag")
+ assert_true(env_has(call, "SUBMINER_APP_ARG_7=--hide-visible-overlay"), "AppImage subprocess should transport visibility flag")
assert_true(env_has_prefix(call, "SUBMINER_APP_LOG="), "AppImage subprocess should include app log env")
assert_true(env_has_prefix(call, "SUBMINER_MPV_LOG="), "AppImage subprocess should include mpv log env")
assert_true(
@@ -1274,12 +1274,12 @@ do
local start_call = find_start_call(recorded.async_calls)
assert_true(start_call ~= nil, "auto-start should issue --start command")
assert_true(
- not call_has_arg(start_call, "--background"),
- "auto-start should not mark video-owned playback as background/tray mode"
+ call_has_arg(start_call, "--background"),
+ "auto-start should launch SubMiner in background/tray mode"
)
assert_true(
call_has_arg(start_call, "--managed-playback"),
- "auto-start should mark SubMiner as launcher-managed playback"
+ "auto-start should mark SubMiner as managed playback"
)
assert_true(call_has_arg(start_call, "--texthooker"), "auto-start should include --texthooker on the main --start command when enabled")
assert_true(find_control_call(recorded.async_calls, "--texthooker") == nil, "auto-start should not issue a separate texthooker helper command")
@@ -1596,7 +1596,7 @@ do
[binary_path] = true,
},
})
- assert_true(recorded ~= nil, "plugin failed to load for shutdown-preserve-background scenario: " .. tostring(err))
+ assert_true(recorded ~= nil, "plugin failed to load for shutdown-managed-background scenario: " .. tostring(err))
fire_event(recorded, "file-loaded")
fire_event(recorded, "end-file", { reason = "quit" })
assert_true(
@@ -1606,7 +1606,7 @@ do
fire_event(recorded, "shutdown")
assert_true(
find_control_call(recorded.async_calls, "--stop") == nil,
- "mpv shutdown should not stop the background SubMiner process"
+ "mpv shutdown should leave managed-playback ownership to the app process"
)
assert_true(
find_control_call(recorded.async_calls, "--hide-visible-overlay") == nil,
@@ -1614,6 +1614,41 @@ do
)
end
+do
+ local recorded, err = run_plugin_scenario({
+ process_list = "/opt/SubMiner/subminer --background\n",
+ option_overrides = {
+ binary_path = binary_path,
+ auto_start = "yes",
+ auto_start_visible_overlay = "yes",
+ socket_path = "/tmp/subminer-socket",
+ },
+ input_ipc_server = "/tmp/subminer-socket",
+ media_title = "Random Movie",
+ files = {
+ [binary_path] = true,
+ },
+ })
+ assert_true(recorded ~= nil, "plugin failed to load for shutdown-borrowed-background scenario: " .. tostring(err))
+ fire_event(recorded, "file-loaded")
+ local start_call = find_start_call(recorded.async_calls)
+ assert_true(start_call ~= nil, "auto-start should attach playback to the existing app")
+ assert_true(
+ not call_has_arg(start_call, "--background"),
+ "borrowed app auto-start should not use the background launch wrapper"
+ )
+ assert_true(
+ call_has_arg(start_call, "--managed-playback"),
+ "borrowed app auto-start should still attach managed playback to the existing app"
+ )
+ fire_event(recorded, "end-file", { reason = "quit" })
+ fire_event(recorded, "shutdown")
+ assert_true(
+ find_control_call(recorded.async_calls, "--stop") == nil,
+ "mpv shutdown should leave a pre-existing background SubMiner process running"
+ )
+end
+
do
local recorded, err = run_plugin_scenario({
process_list = "",
@@ -1633,6 +1668,14 @@ do
fire_event(recorded, "file-loaded")
local start_call = find_start_call(recorded.async_calls)
assert_true(start_call ~= nil, "auto-start should issue --start command")
+ assert_true(
+ call_has_arg(start_call, "--background"),
+ "auto-start should launch SubMiner in background mode"
+ )
+ assert_true(
+ call_has_arg(start_call, "--managed-playback"),
+ "auto-start should mark SubMiner as managed playback"
+ )
assert_true(
call_has_arg(start_call, "--hide-visible-overlay"),
"auto-start with visible overlay disabled should include --hide-visible-overlay on --start"
diff --git a/src/config/config.test.ts b/src/config/config.test.ts
index ff80ac77..9e975e30 100644
--- a/src/config/config.test.ts
+++ b/src/config/config.test.ts
@@ -101,6 +101,7 @@ test('loads defaults when config is missing', () => {
assert.equal(config.subtitleStyle.autoPauseVideoOnHover, true);
assert.equal(config.subtitleStyle.autoPauseVideoOnYomitanPopup, true);
assert.equal(config.subtitleSidebar.enabled, true);
+ assert.equal(config.subtitleSidebar.pauseVideoOnHover, true);
assert.equal(config.subtitleStyle.hoverTokenColor, '#f4dbd6');
assert.equal(config.subtitleStyle.hoverTokenBackgroundColor, 'transparent');
assert.equal(config.subtitleStyle.fontFamily, DEFAULT_SUBTITLE_FONT_FAMILY);
diff --git a/src/config/definitions/defaults-subtitle.ts b/src/config/definitions/defaults-subtitle.ts
index f3436bfd..94a487c6 100644
--- a/src/config/definitions/defaults-subtitle.ts
+++ b/src/config/definitions/defaults-subtitle.ts
@@ -69,7 +69,7 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick {
+ assert.equal(field('websocket.enabled').defaultValue, false);
+ assert.equal(
+ field('websocket.enabled').description,
+ 'Built-in subtitle WebSocket server mode. Auto starts the built-in server only when mpv_websocket is not detected; otherwise it defers to the plugin.',
+ );
+});
+
test('settings registry places immersion tracking after other tracking and app sections', () => {
const trackingSections = [
...new Set(
diff --git a/src/config/settings/registry.ts b/src/config/settings/registry.ts
index 093a5668..5ae21dcd 100644
--- a/src/config/settings/registry.ts
+++ b/src/config/settings/registry.ts
@@ -247,6 +247,8 @@ const DESCRIPTION_OVERRIDES: Record = {
'CSS declarations applied to secondary subtitles. Includes color, background-color, and all font properties.',
'subtitleSidebar.css':
'CSS declarations applied to the subtitle sidebar. Includes color, background-color, all font properties, and sidebar CSS variables.',
+ 'websocket.enabled':
+ 'Built-in subtitle WebSocket server mode. Auto starts the built-in server only when mpv_websocket is not detected; otherwise it defers to the plugin.',
'discordPresence.updateIntervalMs':
'Minimum interval between presence payload updates, in milliseconds.',
};
diff --git a/src/core/services/app-lifecycle.test.ts b/src/core/services/app-lifecycle.test.ts
index 2e8715b9..651f292e 100644
--- a/src/core/services/app-lifecycle.test.ts
+++ b/src/core/services/app-lifecycle.test.ts
@@ -224,6 +224,62 @@ test('startAppLifecycle queues second-instance commands until app ready runtime
assert.deepEqual(handled, ['ready', 'second-instance:start', 'second-instance:start']);
});
+test('startAppLifecycle routes control socket commands through the second-instance queue', async () => {
+ const handled: string[] = [];
+ let controlArgvHandler: ((argv: string[]) => void) | null = null;
+ let readyHandler: (() => Promise) | null = null;
+ let releaseReady: (() => void) | null = null;
+ const readyFinished = new Promise((resolve) => {
+ releaseReady = resolve;
+ });
+
+ const { deps } = createDeps({
+ shouldStartApp: () => true,
+ parseArgs: (argv) => makeArgs({ start: argv.includes('--start') }),
+ handleCliCommand: (args, source) => {
+ handled.push(`${source}:${args.start ? 'start' : 'other'}`);
+ },
+ startControlServer: (handler) => {
+ controlArgvHandler = handler;
+ return () => {
+ handled.push('control-close');
+ };
+ },
+ whenReady: (handler) => {
+ readyHandler = handler;
+ },
+ onReady: async () => {
+ await readyFinished;
+ handled.push('ready');
+ },
+ });
+
+ let willQuitHandler: (() => void) | null = null;
+ deps.onWillQuit = (handler) => {
+ willQuitHandler = handler;
+ };
+
+ startAppLifecycle(makeArgs({ background: true }), deps);
+
+ assert.ok(controlArgvHandler);
+ (controlArgvHandler as (argv: string[]) => void)(['--start']);
+ assert.deepEqual(handled, []);
+
+ assert.ok(readyHandler);
+ const readyRun = (readyHandler as () => Promise)();
+ await Promise.resolve();
+ assert.deepEqual(handled, []);
+
+ assert.ok(releaseReady);
+ (releaseReady as () => void)();
+ await readyRun;
+ assert.deepEqual(handled, ['ready', 'second-instance:start']);
+
+ assert.ok(willQuitHandler);
+ (willQuitHandler as () => void)();
+ assert.deepEqual(handled, ['ready', 'second-instance:start', 'control-close']);
+});
+
test('startAppLifecycle quits macOS config-only launch when all windows close', () => {
let windowAllClosedHandler: (() => void) | null = null;
const { deps, calls } = createDeps({
diff --git a/src/core/services/app-lifecycle.ts b/src/core/services/app-lifecycle.ts
index 59e204d9..cabf8f5c 100644
--- a/src/core/services/app-lifecycle.ts
+++ b/src/core/services/app-lifecycle.ts
@@ -13,6 +13,7 @@ export interface AppLifecycleServiceDeps {
handleCliCommand: (args: CliArgs, source: CliCommandSource) => void;
printHelp: () => void;
logNoRunningInstance: () => void;
+ startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void;
whenReady: (handler: () => Promise) => void;
onWindowAllClosed: (handler: () => void) => void;
onWillQuit: (handler: () => void) => void;
@@ -41,6 +42,7 @@ export interface AppLifecycleDepsRuntimeOptions {
handleCliCommand: (args: CliArgs, source: CliCommandSource) => void;
printHelp: () => void;
logNoRunningInstance: () => void;
+ startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void;
onReady: () => Promise;
onWillQuitCleanup: () => void;
shouldRestoreWindowsOnActivate: () => boolean;
@@ -70,6 +72,7 @@ export function createAppLifecycleDepsRuntime(
handleCliCommand: options.handleCliCommand,
printHelp: options.printHelp,
logNoRunningInstance: options.logNoRunningInstance,
+ startControlServer: options.startControlServer,
whenReady: (handler) => {
options.app
.whenReady()
@@ -116,6 +119,7 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
let appReadyRuntimeComplete = false;
const pendingSecondInstanceCommands: CliArgs[] = [];
+ let stopControlServer: (() => void) | null = null;
const handleSecondInstanceCommand = (args: CliArgs): void => {
try {
deps.handleCliCommand(args, 'second-instance');
@@ -133,7 +137,7 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
}
};
- deps.onSecondInstance((_event, argv) => {
+ const dispatchSecondInstanceArgv = (argv: string[]): void => {
try {
const nextArgs = deps.parseArgs(argv);
if (!appReadyRuntimeComplete) {
@@ -145,6 +149,10 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
} catch (error) {
logger.error('Failed to handle second-instance CLI command:', error);
}
+ };
+
+ deps.onSecondInstance((_event, argv) => {
+ dispatchSecondInstanceArgv(argv);
});
if (!deps.shouldStartApp(initialArgs)) {
@@ -157,6 +165,12 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
return;
}
+ try {
+ stopControlServer = deps.startControlServer?.(dispatchSecondInstanceArgv) ?? null;
+ } catch (error) {
+ logger.error('Failed to start app control socket:', error);
+ }
+
deps.whenReady(async () => {
await deps.onReady();
appReadyRuntimeComplete = true;
@@ -173,6 +187,8 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
});
deps.onWillQuit(() => {
+ stopControlServer?.();
+ stopControlServer = null;
deps.onWillQuitCleanup();
});
diff --git a/src/core/services/cli-command.test.ts b/src/core/services/cli-command.test.ts
index 07f28d0c..73455a44 100644
--- a/src/core/services/cli-command.test.ts
+++ b/src/core/services/cli-command.test.ts
@@ -1,7 +1,11 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { CliArgs } from '../../cli/args';
-import { CliCommandServiceDeps, handleCliCommand } from './cli-command';
+import {
+ CliCommandServiceDeps,
+ createCliCommandDepsRuntime,
+ handleCliCommand,
+} from './cli-command';
function makeArgs(overrides: Partial = {}): CliArgs {
return {
@@ -501,6 +505,132 @@ test('handleCliCommand applies socket path and connects on start', () => {
assert.ok(calls.includes('connectMpvClient'));
});
+test('createCliCommandDepsRuntime reconnects MPV client when reconnect hook exists', () => {
+ const calls: string[] = [];
+ const client = {
+ setSocketPath: (socketPath: string) => {
+ calls.push(`setSocketPath:${socketPath}`);
+ },
+ connect: () => {
+ calls.push('connect');
+ },
+ reconnect: () => {
+ calls.push('reconnect');
+ },
+ };
+ const deps = createCliCommandDepsRuntime({
+ mpv: {
+ getSocketPath: () => '/tmp/runtime.sock',
+ setSocketPath: () => {},
+ getClient: () => client,
+ showOsd: () => {},
+ },
+ texthooker: {
+ service: { isRunning: () => false, start: () => {} },
+ getPort: () => 5174,
+ setPort: () => {},
+ getWebsocketUrl: () => undefined,
+ shouldOpenBrowser: () => false,
+ openInBrowser: () => {},
+ },
+ overlay: {
+ isInitialized: () => true,
+ initialize: () => {},
+ toggleVisible: () => {},
+ togglePrimarySubtitleBar: () => {},
+ setVisible: () => {},
+ },
+ mining: {
+ copyCurrentSubtitle: () => {},
+ startPendingMultiCopy: () => {},
+ mineSentenceCard: async () => {},
+ startPendingMineSentenceMultiple: () => {},
+ updateLastCardFromClipboard: async () => {},
+ refreshKnownWords: async () => {},
+ triggerFieldGrouping: async () => {},
+ triggerSubsyncFromConfig: async () => {},
+ markLastCardAsAudioCard: async () => {},
+ },
+ anilist: {
+ getStatus: () => ({
+ tokenStatus: 'not_checked',
+ tokenSource: 'none',
+ tokenMessage: null,
+ tokenResolvedAt: null,
+ tokenErrorAt: null,
+ queuePending: 0,
+ queueReady: 0,
+ queueDeadLetter: 0,
+ queueLastAttemptAt: null,
+ queueLastError: null,
+ }),
+ clearToken: () => {},
+ openSetup: () => {},
+ getQueueStatus: () => ({
+ pending: 0,
+ ready: 0,
+ deadLetter: 0,
+ lastAttemptAt: null,
+ lastError: null,
+ }),
+ retryQueueNow: async () => ({ ok: true, message: 'ok' }),
+ },
+ dictionary: {
+ generate: async () => ({
+ zipPath: '/tmp/test.zip',
+ fromCache: false,
+ mediaId: 1,
+ mediaTitle: 'Test',
+ entryCount: 0,
+ }),
+ getSelection: async () => ({
+ seriesKey: 'test',
+ guessTitle: null,
+ current: null,
+ override: null,
+ candidates: [],
+ }),
+ setSelection: async () => ({
+ ok: true,
+ seriesKey: 'test',
+ selected: { id: 1, title: 'Test', episodes: null },
+ staleMediaIds: [],
+ }),
+ },
+ jellyfin: {
+ openSetup: () => {},
+ runStatsCommand: async () => {},
+ runCommand: async () => {},
+ },
+ ui: {
+ openFirstRunSetup: () => {},
+ openYomitanSettings: () => {},
+ openConfigSettingsWindow: () => {},
+ cycleSecondarySubMode: () => {},
+ openRuntimeOptionsPalette: () => {},
+ printHelp: () => {},
+ },
+ app: {
+ stop: () => {},
+ hasMainWindow: () => true,
+ runUpdateCommand: async () => {},
+ runYoutubePlaybackFlow: async () => {},
+ },
+ dispatchSessionAction: async () => {},
+ getMultiCopyTimeoutMs: () => 2500,
+ schedule: () => undefined,
+ log: () => {},
+ logDebug: () => {},
+ warn: () => {},
+ error: () => {},
+ });
+
+ deps.setMpvClientSocketPath('/tmp/runtime.sock');
+ deps.connectMpvClient();
+
+ assert.deepEqual(calls, ['setSocketPath:/tmp/runtime.sock', 'reconnect']);
+});
+
test('handleCliCommand warns when texthooker port override used while running', () => {
const { deps, calls } = createDeps({
isTexthookerRunning: () => true,
diff --git a/src/core/services/cli-command.ts b/src/core/services/cli-command.ts
index 5cf22329..a1041ccd 100644
--- a/src/core/services/cli-command.ts
+++ b/src/core/services/cli-command.ts
@@ -115,6 +115,7 @@ export interface CliCommandServiceDeps {
interface MpvClientLike {
setSocketPath: (socketPath: string) => void;
connect: () => void;
+ reconnect?: () => void;
}
interface TexthookerServiceLike {
@@ -235,6 +236,10 @@ export function createCliCommandDepsRuntime(
connectMpvClient: () => {
const client = options.mpv.getClient();
if (!client) return;
+ if (client.reconnect) {
+ client.reconnect();
+ return;
+ }
client.connect();
},
isTexthookerRunning: () => options.texthooker.service.isRunning(),
diff --git a/src/core/services/mpv-transport.test.ts b/src/core/services/mpv-transport.test.ts
index b602b61c..33cedf56 100644
--- a/src/core/services/mpv-transport.test.ts
+++ b/src/core/services/mpv-transport.test.ts
@@ -32,6 +32,12 @@ class FakeSocket extends EventEmitter {
}
}
+class ManualCloseSocket extends FakeSocket {
+ override destroy(): void {
+ this.destroyed = true;
+ }
+}
+
const wait = () => new Promise((resolve) => setTimeout(resolve, 0));
test('getMpvReconnectDelay follows existing reconnect ramp', () => {
@@ -203,12 +209,15 @@ test('MpvSocketTransport ignores connect requests while already connecting or co
});
test('MpvSocketTransport.shutdown clears socket and lifecycle flags', async () => {
+ const events: string[] = [];
const transport = new MpvSocketTransport({
socketPath: '/tmp/mpv.sock',
onConnect: () => {},
onData: () => {},
onError: () => {},
- onClose: () => {},
+ onClose: () => {
+ events.push('close');
+ },
socketFactory: () => new FakeSocket() as unknown as net.Socket,
});
@@ -220,4 +229,45 @@ test('MpvSocketTransport.shutdown clears socket and lifecycle flags', async () =
assert.equal(transport.isConnected, false);
assert.equal(transport.isConnecting, false);
assert.equal(transport.getSocket(), null);
+ assert.deepEqual(events, []);
+});
+
+test('MpvSocketTransport ignores stale socket events after shutdown and reconnect', async () => {
+ const events: string[] = [];
+ const sockets: ManualCloseSocket[] = [];
+ const transport = new MpvSocketTransport({
+ socketPath: '/tmp/mpv.sock',
+ onConnect: () => {
+ events.push('connect');
+ },
+ onData: () => {
+ events.push('data');
+ },
+ onError: () => {
+ events.push('error');
+ },
+ onClose: () => {
+ events.push('close');
+ },
+ socketFactory: () => {
+ const socket = new ManualCloseSocket();
+ sockets.push(socket);
+ return socket as unknown as net.Socket;
+ },
+ });
+
+ transport.connect();
+ await wait();
+ transport.shutdown();
+ transport.connect();
+ await wait();
+ const eventsBeforeStaleSocket = [...events];
+
+ sockets[0]!.emit('data', Buffer.from('{}'));
+ sockets[0]!.emit('error', new Error('stale'));
+ sockets[0]!.emit('close');
+
+ assert.deepEqual(events, eventsBeforeStaleSocket);
+ assert.equal(transport.isConnected, true);
+ assert.equal(transport.getSocket(), sockets[1]);
});
diff --git a/src/core/services/mpv-transport.ts b/src/core/services/mpv-transport.ts
index 02aa5dd9..17fedd0c 100644
--- a/src/core/services/mpv-transport.ts
+++ b/src/core/services/mpv-transport.ts
@@ -105,32 +105,37 @@ export class MpvSocketTransport {
}
this.connecting = true;
- this.socketRef = this.socketFactory();
- this.socket = this.socketRef;
+ const socket = this.socketFactory();
+ this.socketRef = socket;
+ this.socket = socket;
- this.socketRef.on('connect', () => {
+ socket.on('connect', () => {
+ if (this.socketRef !== socket) return;
this.connected = true;
this.connecting = false;
this.callbacks.onConnect();
});
- this.socketRef.on('data', (data: Buffer) => {
+ socket.on('data', (data: Buffer) => {
+ if (this.socketRef !== socket) return;
this.callbacks.onData(data);
});
- this.socketRef.on('error', (error: Error) => {
+ socket.on('error', (error: Error) => {
+ if (this.socketRef !== socket) return;
this.connected = false;
this.connecting = false;
this.callbacks.onError(error);
});
- this.socketRef.on('close', () => {
+ socket.on('close', () => {
+ if (this.socketRef !== socket) return;
this.connected = false;
this.connecting = false;
this.callbacks.onClose();
});
- this.socketRef.connect(this.socketPath);
+ socket.connect(this.socketPath);
}
send(payload: MpvSocketMessagePayload): boolean {
@@ -144,13 +149,14 @@ export class MpvSocketTransport {
}
shutdown(): void {
- if (this.socketRef) {
- this.socketRef.destroy();
- }
+ const socket = this.socketRef;
this.socketRef = null;
this.socket = null;
this.connected = false;
this.connecting = false;
+ if (socket) {
+ socket.destroy();
+ }
}
getSocket(): net.Socket | null {
diff --git a/src/core/services/mpv.test.ts b/src/core/services/mpv.test.ts
index f243674f..1ca76ffd 100644
--- a/src/core/services/mpv.test.ts
+++ b/src/core/services/mpv.test.ts
@@ -168,6 +168,32 @@ test('MpvIpcClient connect logs connect-request at debug level', () => {
assert.equal(requestLogs.length, 1);
});
+test('MpvIpcClient reconnect clears stale connected state and starts a fresh transport connect', () => {
+ const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
+ const calls: string[] = [];
+ const resolved: unknown[] = [];
+ (client as any).connected = true;
+ (client as any).connecting = false;
+ (client as any).socket = {};
+ (client as any).pendingRequests.set(10, (message: unknown) => {
+ resolved.push(message);
+ });
+ (client as any).transport.shutdown = () => {
+ calls.push('shutdown');
+ };
+ (client as any).transport.connect = () => {
+ calls.push('connect');
+ };
+
+ client.reconnect();
+
+ assert.deepEqual(calls, ['shutdown', 'connect']);
+ assert.equal(client.connected, false);
+ assert.equal((client as any).connecting, true);
+ assert.equal((client as any).socket, null);
+ assert.deepEqual(resolved, [{ request_id: 10, error: 'disconnected' }]);
+});
+
test('MpvIpcClient failPendingRequests resolves outstanding requests as disconnected', () => {
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
const resolved: unknown[] = [];
diff --git a/src/core/services/mpv.ts b/src/core/services/mpv.ts
index 19685ebe..46f81475 100644
--- a/src/core/services/mpv.ts
+++ b/src/core/services/mpv.ts
@@ -275,6 +275,17 @@ export class MpvIpcClient implements MpvClient {
this.transport.connect();
}
+ reconnect(): void {
+ logger.debug('MPV IPC reconnect requested.');
+ this.transport.shutdown();
+ this.connected = false;
+ this.connecting = false;
+ this.socket = null;
+ this.playbackPaused = null;
+ this.failPendingRequests();
+ this.connect();
+ }
+
private scheduleReconnect(): void {
this.reconnectAttempt = scheduleMpvReconnect({
attempt: this.reconnectAttempt,
diff --git a/src/core/services/subtitle-ws.test.ts b/src/core/services/subtitle-ws.test.ts
index 011594f5..b8a1312a 100644
--- a/src/core/services/subtitle-ws.test.ts
+++ b/src/core/services/subtitle-ws.test.ts
@@ -217,6 +217,38 @@ test('serializeSubtitleWebsocketMessage emits structured token api payload', ()
});
});
+test('serializeSubtitleWebsocketMessage can force plain subtitle payloads', () => {
+ const payload: SubtitleData = {
+ text: '無事',
+ tokens: [
+ {
+ surface: '無事',
+ reading: 'ぶじ',
+ headword: '無事',
+ startPos: 0,
+ endPos: 2,
+ partOfSpeech: PartOfSpeech.other,
+ isMerged: false,
+ isKnown: true,
+ isNPlusOneTarget: false,
+ jlptLevel: 'N2',
+ frequencyRank: 745,
+ },
+ ],
+ };
+
+ const raw = serializeSubtitleWebsocketMessage(payload, frequencyOptions, {
+ payloadMode: 'plain',
+ });
+
+ assert.deepEqual(JSON.parse(raw), {
+ version: 1,
+ text: '無事',
+ sentence: '無事',
+ tokens: [],
+ });
+});
+
test('serializeInitialSubtitleWebsocketMessage keeps annotated current subtitle content', () => {
const payload: SubtitleData = {
text: 'ignored fallback',
diff --git a/src/core/services/subtitle-ws.ts b/src/core/services/subtitle-ws.ts
index 3338ef62..b8cb48ed 100644
--- a/src/core/services/subtitle-ws.ts
+++ b/src/core/services/subtitle-ws.ts
@@ -18,6 +18,12 @@ export type SubtitleWebsocketFrequencyOptions = {
mode: 'single' | 'banded';
};
+export type SubtitleWebsocketPayloadMode = 'plain' | 'annotated';
+
+type SubtitleWebsocketMessageOptions = {
+ payloadMode?: SubtitleWebsocketPayloadMode;
+};
+
type SerializedSubtitleToken = Pick<
MergedToken,
| 'surface'
@@ -198,7 +204,17 @@ export function serializeSubtitleMarkup(
export function serializeSubtitleWebsocketMessage(
payload: SubtitleData,
options: SubtitleWebsocketFrequencyOptions,
+ messageOptions: SubtitleWebsocketMessageOptions = {},
): string {
+ if (messageOptions.payloadMode === 'plain') {
+ return JSON.stringify({
+ version: 1,
+ text: payload.text,
+ sentence: escapeHtml(payload.text).replaceAll('\n', ' '),
+ tokens: [],
+ });
+ }
+
return JSON.stringify({
version: 1,
text: payload.text,
@@ -210,18 +226,21 @@ export function serializeSubtitleWebsocketMessage(
export function serializeInitialSubtitleWebsocketMessage(
payload: SubtitleData | null,
options: SubtitleWebsocketFrequencyOptions,
+ messageOptions: SubtitleWebsocketMessageOptions = {},
): string | null {
if (!payload || !payload.text.trim()) {
return null;
}
- return serializeSubtitleWebsocketMessage(payload, options);
+ return serializeSubtitleWebsocketMessage(payload, options, messageOptions);
}
export class SubtitleWebSocket {
private server: WebSocket.Server | null = null;
private latestMessage = '';
+ public constructor(private readonly payloadMode: SubtitleWebsocketPayloadMode = 'annotated') {}
+
public isRunning(): boolean {
return this.server !== null;
}
@@ -247,6 +266,7 @@ export class SubtitleWebSocket {
const currentMessage = serializeInitialSubtitleWebsocketMessage(
getCurrentSubtitleData(),
getFrequencyOptions(),
+ { payloadMode: this.payloadMode },
);
if (currentMessage) {
ws.send(currentMessage);
@@ -262,7 +282,9 @@ export class SubtitleWebSocket {
public broadcast(payload: SubtitleData, options: SubtitleWebsocketFrequencyOptions): void {
if (!this.server) return;
- const message = serializeSubtitleWebsocketMessage(payload, options);
+ const message = serializeSubtitleWebsocketMessage(payload, options, {
+ payloadMode: this.payloadMode,
+ });
this.latestMessage = message;
for (const client of this.server.clients) {
if (client.readyState === WebSocket.OPEN) {
diff --git a/src/main.ts b/src/main.ts
index 8321e1dd..d77c3143 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -34,6 +34,8 @@ import {
import { applyControllerConfigUpdate } from './main/controller-config-update.js';
import { openPlaylistBrowser as openPlaylistBrowserRuntime } from './main/runtime/playlist-browser-open';
import { createDiscordRpcClient } from './main/runtime/discord-rpc-client.js';
+import { startAppControlServer } from './main/runtime/app-control-server';
+import { getAppControlSocketPath } from './shared/app-control';
import {
type CancelLinuxMpvFullscreenOverlayRefreshBurst,
clearLinuxMpvFullscreenOverlayRefreshTimeouts,
@@ -166,6 +168,7 @@ import {
rememberAnilistAttemptedUpdateKey,
} from './main/runtime/domains/anilist';
import { DEFAULT_MIN_WATCH_RATIO } from './shared/watch-threshold';
+import { shouldShowTexthookerTrayEntry } from './main/runtime/tray-main-actions';
import {
createApplyJellyfinMpvDefaultsHandler,
createBuildApplyJellyfinMpvDefaultsMainDepsHandler,
@@ -790,7 +793,7 @@ const bootServices = createMainBootServices({
warn: (message: string, details?: unknown) => console.warn(message, details),
error: (message: string, details?: unknown) => console.error(message, details),
}),
- createSubtitleWebSocket: () => new SubtitleWebSocket(),
+ createSubtitleWebSocket: (payloadMode) => new SubtitleWebSocket(payloadMode),
createLogger,
createMainRuntimeRegistry,
createOverlayManager,
@@ -3073,6 +3076,12 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
: 'Yomitan settings are unavailable while external read-only profile mode is enabled.';
return;
}
+ if (submission.action === 'open-config-settings') {
+ firstRunSetupMessage = openConfigSettingsWindow()
+ ? 'Opened SubMiner settings.'
+ : 'SubMiner settings are unavailable.';
+ return { skipRender: true };
+ }
if (submission.action === 'refresh') {
const snapshot = await firstRunSetupService.refreshStatus('Status refreshed.');
firstRunSetupMessage = snapshot.message;
@@ -5796,6 +5805,16 @@ const { runAndApplyStartupState } = composeHeadlessStartupHandlers<
handleCliCommand(nextArgs, source),
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
logNoRunningInstance: () => appLogger.logNoRunningInstance(),
+ startControlServer: (handleArgv: (argv: string[]) => void) => {
+ const server = startAppControlServer({
+ socketPath: getAppControlSocketPath({ configDir: CONFIG_DIR }),
+ platform: process.platform,
+ handleArgv,
+ logDebug: (message) => logger.debug(message),
+ logWarn: (message, error) => logger.warn(message, error),
+ });
+ return () => server.close();
+ },
onReady: runAppReadyRuntimeWithFatalReporting,
onWillQuitCleanup: () => onWillQuitCleanupHandler(),
shouldRestoreWindowsOnActivate: () => shouldRestoreWindowsOnActivateHandler(),
@@ -5943,12 +5962,11 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
openSessionHelpModal: () => openSessionHelpOverlay(),
openTexthookerInBrowser: () =>
handleCliCommand(parseArgs(['--texthooker', '--open-browser'])),
- showTexthookerPage: () => getResolvedConfig().texthooker.launchAtStartup !== false,
+ showTexthookerPage: () => shouldShowTexthookerTrayEntry(getResolvedConfig()),
showFirstRunSetup: () => !firstRunSetupService.isSetupCompleted(),
openFirstRunSetupWindow: () => openFirstRunSetupWindow(),
showWindowsMpvLauncherSetup: () => process.platform === 'win32',
openYomitanSettings: () => openYomitanSettings(),
- openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
openConfigSettingsWindow: () => openConfigSettingsWindow(),
openJellyfinSetupWindow: () => openJellyfinSetupWindow(),
isJellyfinConfigured: () =>
diff --git a/src/main/app-lifecycle.ts b/src/main/app-lifecycle.ts
index dc907a72..29b24823 100644
--- a/src/main/app-lifecycle.ts
+++ b/src/main/app-lifecycle.ts
@@ -11,6 +11,7 @@ export interface AppLifecycleRuntimeDepsFactoryInput {
handleCliCommand: (nextArgs: CliArgs, source: CliCommandSource) => void;
printHelp: () => void;
logNoRunningInstance: () => void;
+ startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void;
onReady: () => Promise;
onWillQuitCleanup: () => void;
shouldRestoreWindowsOnActivate: () => boolean;
@@ -73,6 +74,7 @@ export function createAppLifecycleRuntimeDeps(
handleCliCommand: params.handleCliCommand,
printHelp: params.printHelp,
logNoRunningInstance: params.logNoRunningInstance,
+ startControlServer: params.startControlServer,
onReady: params.onReady,
onWillQuitCleanup: params.onWillQuitCleanup,
shouldRestoreWindowsOnActivate: params.shouldRestoreWindowsOnActivate,
diff --git a/src/main/boot/services.test.ts b/src/main/boot/services.test.ts
index dad13311..3725aa6f 100644
--- a/src/main/boot/services.test.ts
+++ b/src/main/boot/services.test.ts
@@ -21,7 +21,7 @@ test('createMainBootServices builds boot-phase service bundle', () => {
{ targetPath: string },
{ targetPath: string },
{ targetPath: string },
- { kind: string },
+ { kind: string; payloadMode: 'plain' | 'annotated' },
{ scope: string; warn: () => void; info: () => void; error: () => void },
{ registry: boolean },
{ getMainWindow: () => null; getModalWindow: () => null },
@@ -76,7 +76,7 @@ test('createMainBootServices builds boot-phase service bundle', () => {
createAnilistTokenStore: (targetPath) => ({ targetPath }),
createJellyfinTokenStore: (targetPath) => ({ targetPath }),
createAnilistUpdateQueue: (targetPath) => ({ targetPath }),
- createSubtitleWebSocket: () => ({ kind: 'ws' }),
+ createSubtitleWebSocket: (payloadMode) => ({ kind: 'ws', payloadMode }),
createLogger: (scope) =>
({
scope,
@@ -115,6 +115,11 @@ test('createMainBootServices builds boot-phase service bundle', () => {
assert.deepEqual(services.anilistUpdateQueue, {
targetPath: '/tmp/subminer-config/anilist-retry-queue.json',
});
+ assert.deepEqual(services.subtitleWsService, { kind: 'ws', payloadMode: 'plain' });
+ assert.deepEqual(services.annotationSubtitleWsService, {
+ kind: 'ws',
+ payloadMode: 'annotated',
+ });
assert.deepEqual(services.appState, {
mpvSocketPath: '/tmp/subminer.sock',
texthookerPort: 5174,
diff --git a/src/main/boot/services.ts b/src/main/boot/services.ts
index f4e796ae..dd80ec7a 100644
--- a/src/main/boot/services.ts
+++ b/src/main/boot/services.ts
@@ -64,7 +64,7 @@ export interface MainBootServicesParams<
createAnilistTokenStore: (targetPath: string) => TAnilistTokenStore;
createJellyfinTokenStore: (targetPath: string) => TJellyfinTokenStore;
createAnilistUpdateQueue: (targetPath: string) => TAnilistUpdateQueue;
- createSubtitleWebSocket: () => TSubtitleWebSocket;
+ createSubtitleWebSocket: (payloadMode: 'plain' | 'annotated') => TSubtitleWebSocket;
createLogger: (scope: string) => TLogger & {
warn: (message: string) => void;
info: (message: string) => void;
@@ -205,8 +205,8 @@ export function createMainBootServices<
const anilistUpdateQueue = params.createAnilistUpdateQueue(
params.joinPath(userDataPath, 'anilist-retry-queue.json'),
);
- const subtitleWsService = params.createSubtitleWebSocket();
- const annotationSubtitleWsService = params.createSubtitleWebSocket();
+ const subtitleWsService = params.createSubtitleWebSocket('plain');
+ const annotationSubtitleWsService = params.createSubtitleWebSocket('annotated');
const logger = params.createLogger('main');
const runtimeRegistry = params.createMainRuntimeRegistry();
const overlayManager = params.createOverlayManager();
diff --git a/src/main/runtime/app-control-server.test.ts b/src/main/runtime/app-control-server.test.ts
new file mode 100644
index 00000000..737bf2f4
--- /dev/null
+++ b/src/main/runtime/app-control-server.test.ts
@@ -0,0 +1,43 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import test from 'node:test';
+import { sendAppControlCommand } from '../../shared/app-control-client';
+import { startAppControlServer } from './app-control-server';
+
+async function waitForSocketPath(socketPath: string): Promise {
+ const deadline = Date.now() + 1000;
+ while (Date.now() < deadline) {
+ if (fs.existsSync(socketPath)) return;
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ }
+}
+
+test('app control server dispatches argv requests and replies ok', async () => {
+ if (process.platform === 'win32') return;
+
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-control-test-'));
+ const socketPath = path.join(dir, 'control.sock');
+ const received: string[][] = [];
+ const server = startAppControlServer({
+ socketPath,
+ platform: 'linux',
+ handleArgv: (argv) => {
+ received.push(argv);
+ },
+ });
+
+ try {
+ await waitForSocketPath(socketPath);
+ const result = await sendAppControlCommand(['--start', '--socket', '/tmp/mpv.sock'], {
+ socketPath,
+ });
+
+ assert.deepEqual(result, { ok: true });
+ assert.deepEqual(received, [['--start', '--socket', '/tmp/mpv.sock']]);
+ } finally {
+ server.close();
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+});
diff --git a/src/main/runtime/app-control-server.ts b/src/main/runtime/app-control-server.ts
new file mode 100644
index 00000000..ea45f189
--- /dev/null
+++ b/src/main/runtime/app-control-server.ts
@@ -0,0 +1,96 @@
+import fs from 'node:fs';
+import net from 'node:net';
+import path from 'node:path';
+import {
+ encodeAppControlResponse,
+ parseAppControlRequestLine,
+ type AppControlResponse,
+} from '../../shared/app-control';
+
+export interface AppControlServerOptions {
+ socketPath: string;
+ platform?: NodeJS.Platform;
+ handleArgv: (argv: string[]) => void;
+ logDebug?: (message: string) => void;
+ logWarn?: (message: string, error?: unknown) => void;
+}
+
+export interface AppControlServerHandle {
+ close: () => void;
+}
+
+function prepareSocketPath(socketPath: string, platform: NodeJS.Platform): void {
+ if (platform === 'win32') return;
+ fs.mkdirSync(path.dirname(socketPath), { recursive: true });
+ fs.rmSync(socketPath, { force: true });
+}
+
+function cleanupSocketPath(socketPath: string, platform: NodeJS.Platform): void {
+ if (platform === 'win32') return;
+ try {
+ fs.rmSync(socketPath, { force: true });
+ } catch {
+ // ignore
+ }
+}
+
+function writeResponse(socket: net.Socket, response: AppControlResponse): void {
+ socket.end(encodeAppControlResponse(response));
+}
+
+export function startAppControlServer(options: AppControlServerOptions): AppControlServerHandle {
+ const platform = options.platform ?? process.platform;
+ prepareSocketPath(options.socketPath, platform);
+
+ const server = net.createServer((socket) => {
+ let buffer = '';
+ let handled = false;
+
+ socket.on('data', (chunk) => {
+ if (handled) return;
+ buffer += chunk.toString('utf8');
+ if (buffer.length > 65536) {
+ handled = true;
+ writeResponse(socket, { ok: false, error: 'App control request too large' });
+ return;
+ }
+
+ const newlineIndex = buffer.indexOf('\n');
+ if (newlineIndex < 0) return;
+ handled = true;
+
+ try {
+ const request = parseAppControlRequestLine(buffer.slice(0, newlineIndex));
+ options.handleArgv(request.argv);
+ writeResponse(socket, { ok: true });
+ } catch (error) {
+ options.logWarn?.('Failed to handle app control command.', error);
+ writeResponse(socket, {
+ ok: false,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
+ });
+ });
+
+ server.on('error', (error) => {
+ options.logWarn?.(`App control socket failed: ${options.socketPath}`, error);
+ });
+ server.listen(options.socketPath, () => {
+ options.logDebug?.(`App control socket listening: ${options.socketPath}`);
+ });
+
+ let closed = false;
+ return {
+ close: () => {
+ if (closed) return;
+ closed = true;
+ try {
+ server.close();
+ } catch {
+ // ignore
+ }
+ cleanupSocketPath(options.socketPath, platform);
+ },
+ };
+}
diff --git a/src/main/runtime/first-run-setup-window.test.ts b/src/main/runtime/first-run-setup-window.test.ts
index 537e8689..e62dc335 100644
--- a/src/main/runtime/first-run-setup-window.test.ts
+++ b/src/main/runtime/first-run-setup-window.test.ts
@@ -59,10 +59,15 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish
assert.match(html, /SubMiner setup/);
assert.doesNotMatch(html, /Install legacy mpv plugin/);
assert.doesNotMatch(html, /action=install-plugin/);
- assert.match(html, /Ready/);
+ assert.doesNotMatch(html, /mpv runtime plugin/);
assert.doesNotMatch(html, /Bundled ready/);
- assert.match(html, /Managed mpv launches use the bundled runtime plugin\./);
+ assert.doesNotMatch(html, /Managed mpv launches use the bundled runtime plugin\./);
assert.match(html, /Open Yomitan Settings/);
+ assert.match(html, /Open SubMiner Settings/);
+ assert.match(
+ html,
+ /action=open-yomitan-settings'">Open Yomitan Settings<\/button>\s*