diff --git a/docs/architecture.md b/docs/architecture.md
index 4cf8815..a20fd2c 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -170,7 +170,7 @@ This keeps side effects explicit and makes behavior easy to unit-test with fakes
## Program Lifecycle
- **Startup:** `startup.ts` parses CLI args and detects the compositor backend. If `--generate-config` is passed, it writes the template and exits. Otherwise `app-lifecycle.ts` acquires the single-instance lock and registers Electron lifecycle hooks.
-- **Initialization:** Once `app.whenReady()` fires, `startup-lifecycle.ts` loads config, resolves keybindings, creates the mpv client, initializes the MeCab tokenizer, starts the window tracker, and applies WebSocket policy — then creates the overlay window and establishes the IPC bridge.
+- **Initialization:** Once `app.whenReady()` fires, `startup-lifecycle.ts` runs a short critical path first (config reload, keybindings, mpv client, overlay setup, IPC bridge), then schedules non-critical warmups in the background (MeCab availability check, Yomitan extension load, dictionary prewarm, optional Jellyfin remote startup).
- **Runtime:** Event-driven. mpv property changes, IPC messages, CLI commands, and keyboard shortcuts all route through the composition layer to domain services, which update state and broadcast to the renderer.
- **Shutdown:** Electron's `will-quit` triggers service teardown — closes the mpv socket, unregisters shortcuts, stops WebSocket and texthooker servers, destroys the window tracker, and cleans up Anki state.
@@ -194,13 +194,14 @@ flowchart TD
subgraph Init["Initialization"]
direction LR
Config["Load config
resolve keybindings"]:::init
- Runtime["Create mpv client
init MeCab tokenizer"]:::init
+ Runtime["Create mpv client
init runtime options"]:::init
Platform["Start window tracker
WebSocket policy"]:::init
end
- Init --> Create["Create overlay window
Establish IPC bridge
Load Yomitan extension"]:::phase
+ Init --> Create["Create overlay window
Establish IPC bridge"]:::phase
+ Create --> Warm["Background warmups
MeCab · Yomitan · dictionaries · Jellyfin"]:::phase
- Create --> Loop
+ Warm --> Loop
subgraph Loop["Runtime — event-driven"]
direction LR
Events["mpv · IPC · CLI
shortcut events"]:::runtime
diff --git a/docs/mining-workflow.md b/docs/mining-workflow.md
index ecd2fcc..c4fa353 100644
--- a/docs/mining-workflow.md
+++ b/docs/mining-workflow.md
@@ -13,6 +13,17 @@ Watch video → See subtitle → Click word → Yomitan lookup → Add to Anki
sentence, audio, image, translation
```
+## Subtitle Delivery Path (Startup + Runtime)
+
+SubMiner now prioritizes subtitle responsiveness over heavy initialization:
+
+1. The first subtitle render is **plain text first** (no tokenization wait).
+2. Tokenized enrichment (word spans, known-word flags, JLPT/frequency metadata) is applied right after parsing completes.
+3. Under rapid subtitle churn, SubMiner uses a **latest-only tokenization queue** so stale lines are dropped instead of building lag.
+4. MeCab, Yomitan extension load, and dictionary prewarm run as background warmups after overlay initialization.
+
+This keeps early playback snappy and avoids mpv-side sluggishness while startup work completes.
+
## The Two Overlay Layers
SubMiner uses two overlay layers, each serving a different purpose.
diff --git a/package.json b/package.json
index 9aeec7e..14c4f0c 100644
--- a/package.json
+++ b/package.json
@@ -17,7 +17,7 @@
"format": "prettier --write .",
"format:check": "prettier --check .",
"test:config:dist": "node --test dist/config/config.test.js",
- "test:core:dist": "node --test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/config-hot-reload.test.js dist/core/services/tokenizer.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js",
+ "test:core:dist": "node --test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/config-hot-reload.test.js dist/core/services/tokenizer.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
"test:subtitle:dist": "echo \"Subtitle tests are currently not configured\"",
"test": "bun run test:config && bun run test:core",
"test:config": "bun run build && bun run test:config:dist",
diff --git a/src/core/services/app-ready.test.ts b/src/core/services/app-ready.test.ts
index d010b2c..55e2f73 100644
--- a/src/core/services/app-ready.test.ts
+++ b/src/core/services/app-ready.test.ts
@@ -34,10 +34,18 @@ function makeDeps(overrides: Partial = {}) {
loadYomitanExtension: async () => {
calls.push('loadYomitanExtension');
},
+ prewarmSubtitleDictionaries: async () => {
+ calls.push('prewarmSubtitleDictionaries');
+ },
+ startBackgroundWarmups: () => {
+ calls.push('startBackgroundWarmups');
+ },
texthookerOnlyMode: false,
shouldAutoInitializeOverlayRuntimeFromConfig: () => true,
initializeOverlayRuntime: () => calls.push('initializeOverlayRuntime'),
handleInitialArgs: () => calls.push('handleInitialArgs'),
+ logDebug: (message) => calls.push(`debug:${message}`),
+ now: () => 1000,
...overrides,
};
return { deps, calls };
@@ -51,7 +59,7 @@ test('runAppReadyRuntime starts websocket in auto mode when plugin missing', asy
assert.ok(calls.includes('startSubtitleWebsocket:9001'));
assert.ok(calls.includes('initializeOverlayRuntime'));
assert.ok(calls.includes('createImmersionTracker'));
- assert.ok(calls.includes('startJellyfinRemoteSession'));
+ assert.ok(calls.includes('startBackgroundWarmups'));
assert.ok(calls.includes('log:Runtime ready: invoking createImmersionTracker.'));
});
@@ -63,10 +71,10 @@ test('runAppReadyRuntime skips Jellyfin remote startup when dependency is not wi
await runAppReadyRuntime(deps);
assert.equal(calls.includes('startJellyfinRemoteSession'), false);
- assert.ok(calls.includes('createMecabTokenizerAndCheck'));
assert.ok(calls.includes('createMpvClient'));
assert.ok(calls.includes('createSubtitleTimingTracker'));
assert.ok(calls.includes('handleInitialArgs'));
+ assert.ok(calls.includes('startBackgroundWarmups'));
assert.ok(
calls.includes('initializeOverlayRuntime') ||
calls.includes('log:Overlay runtime deferred: waiting for explicit overlay command.'),
@@ -116,3 +124,28 @@ test('runAppReadyRuntime applies config logging level during app-ready', async (
await runAppReadyRuntime(deps);
assert.ok(calls.includes('setLogLevel:warn:config'));
});
+
+test('runAppReadyRuntime does not await background warmups', async () => {
+ const calls: string[] = [];
+ let releaseWarmup: (() => void) | undefined;
+ const warmupGate = new Promise((resolve) => {
+ releaseWarmup = resolve;
+ });
+ const { deps } = makeDeps({
+ startBackgroundWarmups: () => {
+ calls.push('startBackgroundWarmups');
+ void warmupGate.then(() => {
+ calls.push('warmupDone');
+ });
+ },
+ handleInitialArgs: () => {
+ calls.push('handleInitialArgs');
+ },
+ });
+
+ await runAppReadyRuntime(deps);
+ assert.deepEqual(calls.slice(0, 2), ['handleInitialArgs', 'startBackgroundWarmups']);
+ assert.equal(calls.includes('warmupDone'), false);
+ assert.ok(releaseWarmup);
+ releaseWarmup();
+});
diff --git a/src/core/services/index.ts b/src/core/services/index.ts
index 4b5252c..5dd66e0 100644
--- a/src/core/services/index.ts
+++ b/src/core/services/index.ts
@@ -30,6 +30,7 @@ export {
} from './startup';
export { openYomitanSettingsWindow } from './yomitan-settings';
export { createTokenizerDepsRuntime, tokenizeSubtitle } from './tokenizer';
+export { createSubtitleProcessingController } from './subtitle-processing-controller';
export { createFrequencyDictionaryLookup } from './frequency-dictionary';
export { createJlptVocabularyLookup } from './jlpt-vocab';
export {
diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts
index 60953ec..961f2a8 100644
--- a/src/core/services/ipc.test.ts
+++ b/src/core/services/ipc.test.ts
@@ -15,6 +15,7 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
quitApp: () => {},
toggleVisibleOverlay: () => {},
tokenizeCurrentSubtitle: async () => null,
+ getCurrentSubtitleRaw: () => '',
getCurrentSubtitleAss: () => '',
getMpvSubtitleRenderMetrics: () => null,
getSubtitlePosition: () => null,
diff --git a/src/core/services/ipc.ts b/src/core/services/ipc.ts
index 9d967af..f12f650 100644
--- a/src/core/services/ipc.ts
+++ b/src/core/services/ipc.ts
@@ -12,6 +12,7 @@ export interface IpcServiceDeps {
toggleVisibleOverlay: () => void;
getInvisibleOverlayVisibility: () => boolean;
tokenizeCurrentSubtitle: () => Promise;
+ getCurrentSubtitleRaw: () => string;
getCurrentSubtitleAss: () => string;
getMpvSubtitleRenderMetrics: () => unknown;
getSubtitlePosition: () => unknown;
@@ -75,6 +76,7 @@ export interface IpcDepsRuntimeOptions {
quitApp: () => void;
toggleVisibleOverlay: () => void;
tokenizeCurrentSubtitle: () => Promise;
+ getCurrentSubtitleRaw: () => string;
getCurrentSubtitleAss: () => string;
getMpvSubtitleRenderMetrics: () => unknown;
getSubtitlePosition: () => unknown;
@@ -122,6 +124,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
toggleVisibleOverlay: options.toggleVisibleOverlay,
getInvisibleOverlayVisibility: options.getInvisibleOverlayVisibility,
tokenizeCurrentSubtitle: options.tokenizeCurrentSubtitle,
+ getCurrentSubtitleRaw: options.getCurrentSubtitleRaw,
getCurrentSubtitleAss: options.getCurrentSubtitleAss,
getMpvSubtitleRenderMetrics: options.getMpvSubtitleRenderMetrics,
getSubtitlePosition: options.getSubtitlePosition,
@@ -220,6 +223,10 @@ export function registerIpcHandlers(deps: IpcServiceDeps): void {
return await deps.tokenizeCurrentSubtitle();
});
+ ipcMain.handle('get-current-subtitle-raw', () => {
+ return deps.getCurrentSubtitleRaw();
+ });
+
ipcMain.handle('get-current-subtitle-ass', () => {
return deps.getCurrentSubtitleAss();
});
diff --git a/src/core/services/startup.ts b/src/core/services/startup.ts
index ac0515e..556ffdb 100644
--- a/src/core/services/startup.ts
+++ b/src/core/services/startup.ts
@@ -106,10 +106,14 @@ export interface AppReadyRuntimeDeps {
createImmersionTracker?: () => void;
startJellyfinRemoteSession?: () => Promise;
loadYomitanExtension: () => Promise;
+ prewarmSubtitleDictionaries?: () => Promise;
+ startBackgroundWarmups: () => void;
texthookerOnlyMode: boolean;
shouldAutoInitializeOverlayRuntimeFromConfig: () => boolean;
initializeOverlayRuntime: () => void;
handleInitialArgs: () => void;
+ logDebug?: (message: string) => void;
+ now?: () => number;
}
export function getInitialInvisibleOverlayVisibility(
@@ -143,9 +147,12 @@ export function isAutoUpdateEnabledRuntime(
}
export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise {
+ const now = deps.now ?? (() => Date.now());
+ const startupStartedAtMs = now();
+ deps.logDebug?.('App-ready critical path started.');
+
deps.loadSubtitlePosition();
deps.resolveKeybindings();
- await deps.createMecabTokenizerAndCheck();
deps.createMpvClient();
deps.reloadConfig();
@@ -178,10 +185,6 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise {
+ return new Promise((resolve) => setTimeout(resolve, 0));
+}
+
+test('subtitle processing emits plain subtitle immediately before tokenized payload', async () => {
+ const emitted: SubtitleData[] = [];
+ const controller = createSubtitleProcessingController({
+ tokenizeSubtitle: async (text) => ({ text, tokens: [] }),
+ emitSubtitle: (payload) => emitted.push(payload),
+ });
+
+ controller.onSubtitleChange('字幕');
+ assert.deepEqual(emitted[0], { text: '字幕', tokens: null });
+
+ await flushMicrotasks();
+ assert.deepEqual(emitted[1], { text: '字幕', tokens: [] });
+});
+
+test('subtitle processing drops stale tokenization and delivers latest subtitle only', async () => {
+ const emitted: SubtitleData[] = [];
+ let firstResolve: ((value: SubtitleData | null) => void) | undefined;
+ const controller = createSubtitleProcessingController({
+ tokenizeSubtitle: async (text) => {
+ if (text === 'first') {
+ return await new Promise((resolve) => {
+ firstResolve = resolve;
+ });
+ }
+ return { text, tokens: [] };
+ },
+ emitSubtitle: (payload) => emitted.push(payload),
+ });
+
+ controller.onSubtitleChange('first');
+ controller.onSubtitleChange('second');
+ assert.ok(firstResolve);
+ firstResolve({ text: 'first', tokens: [] });
+ await flushMicrotasks();
+ await flushMicrotasks();
+
+ assert.deepEqual(emitted, [
+ { text: 'first', tokens: null },
+ { text: 'second', tokens: null },
+ { text: 'second', tokens: [] },
+ ]);
+});
+
+test('subtitle processing skips duplicate plain subtitle emission', async () => {
+ const emitted: SubtitleData[] = [];
+ let tokenizeCalls = 0;
+ const controller = createSubtitleProcessingController({
+ tokenizeSubtitle: async (text) => {
+ tokenizeCalls += 1;
+ return { text, tokens: [] };
+ },
+ emitSubtitle: (payload) => emitted.push(payload),
+ });
+
+ controller.onSubtitleChange('same');
+ await flushMicrotasks();
+ controller.onSubtitleChange('same');
+ await flushMicrotasks();
+
+ const plainEmits = emitted.filter((entry) => entry.tokens === null);
+ assert.equal(plainEmits.length, 1);
+ assert.equal(tokenizeCalls, 1);
+});
diff --git a/src/core/services/subtitle-processing-controller.ts b/src/core/services/subtitle-processing-controller.ts
new file mode 100644
index 0000000..8d82929
--- /dev/null
+++ b/src/core/services/subtitle-processing-controller.ts
@@ -0,0 +1,96 @@
+import type { SubtitleData } from '../../types';
+
+export interface SubtitleProcessingControllerDeps {
+ tokenizeSubtitle: (text: string) => Promise;
+ emitSubtitle: (payload: SubtitleData) => void;
+ logDebug?: (message: string) => void;
+ now?: () => number;
+}
+
+export interface SubtitleProcessingController {
+ onSubtitleChange: (text: string) => void;
+}
+
+export function createSubtitleProcessingController(
+ deps: SubtitleProcessingControllerDeps,
+): SubtitleProcessingController {
+ let latestText = '';
+ let lastPlainText = '';
+ let processing = false;
+ let staleDropCount = 0;
+ const now = deps.now ?? (() => Date.now());
+
+ const emitPlainSubtitle = (text: string): void => {
+ if (text === lastPlainText) {
+ return;
+ }
+ lastPlainText = text;
+ deps.emitSubtitle({ text, tokens: null });
+ };
+
+ const processLatest = (): void => {
+ if (processing) {
+ return;
+ }
+
+ processing = true;
+
+ void (async () => {
+ while (true) {
+ const text = latestText;
+ if (!text.trim()) {
+ break;
+ }
+
+ const startedAtMs = now();
+ let tokenized: SubtitleData | null = null;
+ try {
+ tokenized = await deps.tokenizeSubtitle(text);
+ } catch (error) {
+ deps.logDebug?.(`Subtitle tokenization failed: ${(error as Error).message}`);
+ }
+
+ if (latestText !== text) {
+ staleDropCount += 1;
+ deps.logDebug?.(
+ `Dropped stale subtitle tokenization result; dropped=${staleDropCount}, elapsed=${now() - startedAtMs}ms`,
+ );
+ continue;
+ }
+
+ if (tokenized) {
+ deps.emitSubtitle(tokenized);
+ deps.logDebug?.(
+ `Subtitle tokenization delivered; elapsed=${now() - startedAtMs}ms, staleDrops=${staleDropCount}`,
+ );
+ }
+ break;
+ }
+ })()
+ .catch((error) => {
+ deps.logDebug?.(`Subtitle processing loop failed: ${(error as Error).message}`);
+ })
+ .finally(() => {
+ processing = false;
+ if (latestText !== lastPlainText) {
+ processLatest();
+ }
+ });
+ };
+
+ return {
+ onSubtitleChange: (text: string) => {
+ if (text === latestText) {
+ return;
+ }
+ const plainStartedAtMs = now();
+ latestText = text;
+ emitPlainSubtitle(text);
+ deps.logDebug?.(`Subtitle plain emit completed in ${now() - plainStartedAtMs}ms`);
+ if (!text.trim()) {
+ return;
+ }
+ processLatest();
+ },
+ };
+}
diff --git a/src/main.ts b/src/main.ts
index aa1b142..de39f28 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -90,6 +90,7 @@ import {
createFieldGroupingOverlayRuntime,
createNumericShortcutRuntime,
createOverlayContentMeasurementStore,
+ createSubtitleProcessingController,
createOverlayWindow as createOverlayWindowCore,
createTokenizerDepsRuntime,
cycleSecondarySubMode as cycleSecondarySubModeCore,
@@ -236,6 +237,8 @@ type ActiveJellyfinRemotePlaybackState = {
let activeJellyfinRemotePlayback: ActiveJellyfinRemotePlaybackState | null = null;
let jellyfinRemoteLastProgressAtMs = 0;
let jellyfinMpvAutoLaunchInFlight: Promise | null = null;
+let backgroundWarmupsStarted = false;
+let yomitanLoadInFlight: Promise | null = null;
function applyJellyfinMpvDefaults(client: MpvIpcClient): void {
sendMpvCommandRuntime(client, ['set_property', 'sub-auto', 'fuzzy']);
@@ -364,6 +367,21 @@ const appState = createAppState({
texthookerPort: DEFAULT_TEXTHOOKER_PORT,
});
let appTray: Tray | null = null;
+const subtitleProcessingController = createSubtitleProcessingController({
+ tokenizeSubtitle: async (text: string) => {
+ if (getOverlayWindows().length === 0) {
+ return null;
+ }
+ return await tokenizeSubtitle(text);
+ },
+ emitSubtitle: (payload) => {
+ broadcastToOverlayWindows('subtitle:set', payload);
+ },
+ logDebug: (message) => {
+ logger.debug(`[subtitle-processing] ${message}`);
+ },
+ now: () => Date.now(),
+});
const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService({
getConfiguredShortcuts: () => getConfiguredShortcuts(),
getShortcutsRegistered: () => appState.shortcutsRegistered,
@@ -2205,9 +2223,7 @@ const startupState = runStartupBootstrapRuntime(
},
log: (message) => appLogger.logInfo(message),
createMecabTokenizerAndCheck: async () => {
- const tokenizer = new MecabTokenizer();
- appState.mecabTokenizer = tokenizer;
- await tokenizer.checkAvailability();
+ await createMecabTokenizerAndCheck();
},
createSubtitleTimingTracker: () => {
const tracker = new SubtitleTimingTracker();
@@ -2258,11 +2274,21 @@ const startupState = runStartupBootstrapRuntime(
startJellyfinRemoteSession: async () => {
await startJellyfinRemoteSession();
},
+ prewarmSubtitleDictionaries: async () => {
+ await prewarmSubtitleDictionaries();
+ },
+ startBackgroundWarmups: () => {
+ startBackgroundWarmups();
+ },
texthookerOnlyMode: appState.texthookerOnlyMode,
shouldAutoInitializeOverlayRuntimeFromConfig: () =>
appState.backgroundMode ? false : shouldAutoInitializeOverlayRuntimeFromConfig(),
initializeOverlayRuntime: () => initializeOverlayRuntime(),
handleInitialArgs: () => handleInitialArgs(),
+ logDebug: (message: string) => {
+ logger.debug(message);
+ },
+ now: () => Date.now(),
}),
onWillQuitCleanup: () => {
destroyTray();
@@ -2417,12 +2443,7 @@ function bindMpvClientEventHandlers(mpvClient: MpvIpcClient): void {
mpvClient.on('subtitle-change', ({ text }) => {
appState.currentSubText = text;
subtitleWsService.broadcast(text);
- void (async () => {
- if (getOverlayWindows().length > 0) {
- const subtitleData = await tokenizeSubtitle(text);
- broadcastToOverlayWindows('subtitle:set', subtitleData);
- }
- })();
+ subtitleProcessingController.onSubtitleChange(text);
});
mpvClient.on('subtitle-ass-change', ({ text }) => {
appState.currentSubAssText = text;
@@ -2548,6 +2569,56 @@ async function tokenizeSubtitle(text: string): Promise {
);
}
+async function createMecabTokenizerAndCheck(): Promise {
+ if (!appState.mecabTokenizer) {
+ appState.mecabTokenizer = new MecabTokenizer();
+ }
+ await appState.mecabTokenizer.checkAvailability();
+}
+
+async function prewarmSubtitleDictionaries(): Promise {
+ await Promise.all([
+ jlptDictionaryRuntime.ensureJlptDictionaryLookup(),
+ frequencyDictionaryRuntime.ensureFrequencyDictionaryLookup(),
+ ]);
+}
+
+function launchBackgroundWarmupTask(label: string, task: () => Promise): void {
+ const startedAtMs = Date.now();
+ void task()
+ .then(() => {
+ logger.debug(`[startup-warmup] ${label} completed in ${Date.now() - startedAtMs}ms`);
+ })
+ .catch((error) => {
+ logger.warn(`[startup-warmup] ${label} failed: ${(error as Error).message}`);
+ });
+}
+
+function startBackgroundWarmups(): void {
+ if (backgroundWarmupsStarted) {
+ return;
+ }
+ if (appState.texthookerOnlyMode) {
+ return;
+ }
+
+ backgroundWarmupsStarted = true;
+ launchBackgroundWarmupTask('mecab', async () => {
+ await createMecabTokenizerAndCheck();
+ });
+ launchBackgroundWarmupTask('yomitan-extension', async () => {
+ await ensureYomitanExtensionLoaded();
+ });
+ launchBackgroundWarmupTask('subtitle-dictionaries', async () => {
+ await prewarmSubtitleDictionaries();
+ });
+ if (getResolvedConfig().jellyfin.remoteControlAutoConnect) {
+ launchBackgroundWarmupTask('jellyfin-remote-session', async () => {
+ await startJellyfinRemoteSession();
+ });
+ }
+}
+
function updateVisibleOverlayBounds(geometry: WindowGeometry): void {
overlayManager.setOverlayWindowBounds('visible', geometry);
}
@@ -2589,6 +2660,20 @@ async function loadYomitanExtension(): Promise {
});
}
+async function ensureYomitanExtensionLoaded(): Promise {
+ if (appState.yomitanExt) {
+ return appState.yomitanExt;
+ }
+ if (yomitanLoadInFlight) {
+ return yomitanLoadInFlight;
+ }
+
+ yomitanLoadInFlight = loadYomitanExtension().finally(() => {
+ yomitanLoadInFlight = null;
+ });
+ return yomitanLoadInFlight;
+}
+
function createOverlayWindow(kind: 'visible' | 'invisible'): BrowserWindow {
return createOverlayWindowCore(kind, {
isDev,
@@ -2769,15 +2854,26 @@ function initializeOverlayRuntime(): void {
});
overlayManager.setInvisibleOverlayVisible(result.invisibleOverlayVisible);
appState.overlayRuntimeInitialized = true;
+ startBackgroundWarmups();
}
function openYomitanSettings(): void {
- openYomitanSettingsWindow({
- yomitanExt: appState.yomitanExt,
- getExistingWindow: () => appState.yomitanSettingsWindow,
- setWindow: (window: BrowserWindow | null) => {
- appState.yomitanSettingsWindow = window;
- },
+ void (async () => {
+ const extension = await ensureYomitanExtensionLoaded();
+ if (!extension) {
+ logger.warn('Unable to open Yomitan settings: extension failed to load.');
+ return;
+ }
+
+ openYomitanSettingsWindow({
+ yomitanExt: extension,
+ getExistingWindow: () => appState.yomitanSettingsWindow,
+ setWindow: (window: BrowserWindow | null) => {
+ appState.yomitanSettingsWindow = window;
+ },
+ });
+ })().catch((error) => {
+ logger.error('Failed to open Yomitan settings window.', error);
});
}
function registerGlobalShortcuts(): void {
@@ -3108,6 +3204,7 @@ registerIpcRuntimeServices({
quitApp: () => app.quit(),
toggleVisibleOverlay: () => toggleVisibleOverlay(),
tokenizeCurrentSubtitle: () => tokenizeSubtitle(appState.currentSubText),
+ getCurrentSubtitleRaw: () => appState.currentSubText,
getCurrentSubtitleAss: () => appState.currentSubAssText,
getMpvSubtitleRenderMetrics: () => appState.mpvSubtitleRenderMetrics,
getSubtitlePosition: () => loadSubtitlePosition(),
diff --git a/src/main/app-lifecycle.ts b/src/main/app-lifecycle.ts
index 32769f4..51968b2 100644
--- a/src/main/app-lifecycle.ts
+++ b/src/main/app-lifecycle.ts
@@ -39,10 +39,14 @@ export interface AppReadyRuntimeDepsFactoryInput {
createImmersionTracker?: AppReadyRuntimeDeps['createImmersionTracker'];
startJellyfinRemoteSession?: AppReadyRuntimeDeps['startJellyfinRemoteSession'];
loadYomitanExtension: AppReadyRuntimeDeps['loadYomitanExtension'];
+ prewarmSubtitleDictionaries?: AppReadyRuntimeDeps['prewarmSubtitleDictionaries'];
+ startBackgroundWarmups: AppReadyRuntimeDeps['startBackgroundWarmups'];
texthookerOnlyMode: AppReadyRuntimeDeps['texthookerOnlyMode'];
shouldAutoInitializeOverlayRuntimeFromConfig: AppReadyRuntimeDeps['shouldAutoInitializeOverlayRuntimeFromConfig'];
initializeOverlayRuntime: AppReadyRuntimeDeps['initializeOverlayRuntime'];
handleInitialArgs: AppReadyRuntimeDeps['handleInitialArgs'];
+ logDebug?: AppReadyRuntimeDeps['logDebug'];
+ now?: AppReadyRuntimeDeps['now'];
}
export function createAppLifecycleRuntimeDeps(
@@ -88,11 +92,15 @@ export function createAppReadyRuntimeDeps(
createImmersionTracker: params.createImmersionTracker,
startJellyfinRemoteSession: params.startJellyfinRemoteSession,
loadYomitanExtension: params.loadYomitanExtension,
+ prewarmSubtitleDictionaries: params.prewarmSubtitleDictionaries,
+ startBackgroundWarmups: params.startBackgroundWarmups,
texthookerOnlyMode: params.texthookerOnlyMode,
shouldAutoInitializeOverlayRuntimeFromConfig:
params.shouldAutoInitializeOverlayRuntimeFromConfig,
initializeOverlayRuntime: params.initializeOverlayRuntime,
handleInitialArgs: params.handleInitialArgs,
+ logDebug: params.logDebug,
+ now: params.now,
};
}
diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts
index b3bc11a..0ef19d7 100644
--- a/src/main/dependencies.ts
+++ b/src/main/dependencies.ts
@@ -68,6 +68,7 @@ export interface MainIpcRuntimeServiceDepsParams {
quitApp: IpcDepsRuntimeOptions['quitApp'];
toggleVisibleOverlay: IpcDepsRuntimeOptions['toggleVisibleOverlay'];
tokenizeCurrentSubtitle: IpcDepsRuntimeOptions['tokenizeCurrentSubtitle'];
+ getCurrentSubtitleRaw: IpcDepsRuntimeOptions['getCurrentSubtitleRaw'];
getCurrentSubtitleAss: IpcDepsRuntimeOptions['getCurrentSubtitleAss'];
focusMainWindow?: IpcDepsRuntimeOptions['focusMainWindow'];
getMpvSubtitleRenderMetrics: IpcDepsRuntimeOptions['getMpvSubtitleRenderMetrics'];
@@ -205,6 +206,7 @@ export function createMainIpcRuntimeServiceDeps(
quitApp: params.quitApp,
toggleVisibleOverlay: params.toggleVisibleOverlay,
tokenizeCurrentSubtitle: params.tokenizeCurrentSubtitle,
+ getCurrentSubtitleRaw: params.getCurrentSubtitleRaw,
getCurrentSubtitleAss: params.getCurrentSubtitleAss,
getMpvSubtitleRenderMetrics: params.getMpvSubtitleRenderMetrics,
getSubtitlePosition: params.getSubtitlePosition,
diff --git a/src/preload.ts b/src/preload.ts
index 79b58f2..3ac0f8e 100644
--- a/src/preload.ts
+++ b/src/preload.ts
@@ -83,6 +83,7 @@ const electronAPI: ElectronAPI = {
getOverlayVisibility: (): Promise => ipcRenderer.invoke('get-overlay-visibility'),
getCurrentSubtitle: (): Promise => ipcRenderer.invoke('get-current-subtitle'),
+ getCurrentSubtitleRaw: (): Promise => ipcRenderer.invoke('get-current-subtitle-raw'),
getCurrentSubtitleAss: (): Promise => ipcRenderer.invoke('get-current-subtitle-ass'),
getMpvSubtitleRenderMetrics: () => ipcRenderer.invoke('get-mpv-subtitle-render-metrics'),
onMpvSubtitleRenderMetrics: (callback: (metrics: MpvSubtitleRenderMetrics) => void) => {
diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts
index 58f9617..acc2d2f 100644
--- a/src/renderer/renderer.ts
+++ b/src/renderer/renderer.ts
@@ -153,7 +153,7 @@ async function init(): Promise {
});
}
- const initialSubtitle = await window.electronAPI.getCurrentSubtitle();
+ const initialSubtitle = await window.electronAPI.getCurrentSubtitleRaw();
subtitleRenderer.renderSubtitle(initialSubtitle);
measurementReporter.schedule();
diff --git a/src/types.ts b/src/types.ts
index 6b625fb..8f09e6b 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -724,6 +724,7 @@ export interface ElectronAPI {
onSubtitlePosition: (callback: (position: SubtitlePosition | null) => void) => void;
getOverlayVisibility: () => Promise;
getCurrentSubtitle: () => Promise;
+ getCurrentSubtitleRaw: () => Promise;
getCurrentSubtitleAss: () => Promise;
getMpvSubtitleRenderMetrics: () => Promise;
onMpvSubtitleRenderMetrics: (callback: (metrics: MpvSubtitleRenderMetrics) => void) => void;
diff --git a/src/window-trackers/x11-tracker.test.ts b/src/window-trackers/x11-tracker.test.ts
new file mode 100644
index 0000000..0d1c42b
--- /dev/null
+++ b/src/window-trackers/x11-tracker.test.ts
@@ -0,0 +1,54 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { parseX11WindowGeometry, parseX11WindowPid, X11WindowTracker } from './x11-tracker';
+
+test('parseX11WindowGeometry parses xwininfo output', () => {
+ const geometry = parseX11WindowGeometry(`
+Absolute upper-left X: 120
+Absolute upper-left Y: 240
+Width: 1280
+Height: 720
+`);
+ assert.deepEqual(geometry, {
+ x: 120,
+ y: 240,
+ width: 1280,
+ height: 720,
+ });
+});
+
+test('parseX11WindowPid parses xprop output', () => {
+ assert.equal(parseX11WindowPid('_NET_WM_PID(CARDINAL) = 4242'), 4242);
+ assert.equal(parseX11WindowPid('_NET_WM_PID(CARDINAL) = not-a-number'), null);
+});
+
+test('X11WindowTracker skips overlapping polls while one command is in flight', async () => {
+ let commandCalls = 0;
+ let release: (() => void) | undefined;
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+
+ const tracker = new X11WindowTracker(undefined, async (command) => {
+ commandCalls += 1;
+ if (command === 'xdotool') {
+ await gate;
+ return '123';
+ }
+ if (command === 'xwininfo') {
+ return `Absolute upper-left X: 0
+Absolute upper-left Y: 0
+Width: 640
+Height: 360`;
+ }
+ return '';
+ });
+
+ (tracker as unknown as { pollGeometry: () => void }).pollGeometry();
+ (tracker as unknown as { pollGeometry: () => void }).pollGeometry();
+ assert.equal(commandCalls, 1);
+
+ assert.ok(release);
+ release();
+ await new Promise((resolve) => setTimeout(resolve, 0));
+});
diff --git a/src/window-trackers/x11-tracker.ts b/src/window-trackers/x11-tracker.ts
index c75a6de..339d8cc 100644
--- a/src/window-trackers/x11-tracker.ts
+++ b/src/window-trackers/x11-tracker.ts
@@ -16,20 +16,69 @@
along with this program. If not, see .
*/
-import { execSync } from 'child_process';
+import { execFile } from 'child_process';
import { BaseWindowTracker } from './base-tracker';
+type CommandRunner = (command: string, args: string[]) => Promise;
+
+function execFileUtf8(command: string, args: string[]): Promise {
+ return new Promise((resolve, reject) => {
+ execFile(command, args, { encoding: 'utf-8' }, (error, stdout) => {
+ if (error) {
+ reject(error);
+ return;
+ }
+ resolve(stdout);
+ });
+ });
+}
+
+export function parseX11WindowGeometry(winInfo: string): {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+} | null {
+ const xMatch = winInfo.match(/Absolute upper-left X:\s*(\d+)/);
+ const yMatch = winInfo.match(/Absolute upper-left Y:\s*(\d+)/);
+ const widthMatch = winInfo.match(/Width:\s*(\d+)/);
+ const heightMatch = winInfo.match(/Height:\s*(\d+)/);
+ if (!xMatch || !yMatch || !widthMatch || !heightMatch) {
+ return null;
+ }
+ return {
+ x: parseInt(xMatch[1], 10),
+ y: parseInt(yMatch[1], 10),
+ width: parseInt(widthMatch[1], 10),
+ height: parseInt(heightMatch[1], 10),
+ };
+}
+
+export function parseX11WindowPid(raw: string): number | null {
+ const pidMatch = raw.match(/= (\d+)/);
+ if (!pidMatch) {
+ return null;
+ }
+ const pid = Number.parseInt(pidMatch[1], 10);
+ return Number.isInteger(pid) ? pid : null;
+}
+
export class X11WindowTracker extends BaseWindowTracker {
private pollInterval: ReturnType | null = null;
private readonly targetMpvSocketPath: string | null;
+ private readonly runCommand: CommandRunner;
+ private pollInFlight = false;
+ private currentPollIntervalMs = 750;
+ private readonly stablePollIntervalMs = 250;
- constructor(targetMpvSocketPath?: string) {
+ constructor(targetMpvSocketPath?: string, runCommand: CommandRunner = execFileUtf8) {
super();
this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null;
+ this.runCommand = runCommand;
}
start(): void {
- this.pollInterval = setInterval(() => this.pollGeometry(), 250);
+ this.resetPollInterval(this.currentPollIntervalMs);
this.pollGeometry();
}
@@ -40,60 +89,69 @@ export class X11WindowTracker extends BaseWindowTracker {
}
}
+ private resetPollInterval(intervalMs: number): void {
+ if (this.pollInterval) {
+ clearInterval(this.pollInterval);
+ this.pollInterval = null;
+ }
+ this.pollInterval = setInterval(() => this.pollGeometry(), intervalMs);
+ }
+
private pollGeometry(): void {
- try {
- const windowIds = execSync('xdotool search --class mpv', {
- encoding: 'utf-8',
- }).trim();
-
- if (!windowIds) {
+ if (this.pollInFlight) {
+ return;
+ }
+ this.pollInFlight = true;
+ void this.pollGeometryAsync()
+ .catch(() => {
this.updateGeometry(null);
- return;
- }
-
- const windowIdList = windowIds.split(/\s+/).filter(Boolean);
- if (windowIdList.length === 0) {
- this.updateGeometry(null);
- return;
- }
-
- const windowId = this.findTargetWindowId(windowIdList);
- if (!windowId) {
- this.updateGeometry(null);
- return;
- }
-
- const winInfo = execSync(`xwininfo -id ${windowId}`, {
- encoding: 'utf-8',
+ })
+ .finally(() => {
+ this.pollInFlight = false;
});
+ }
- const xMatch = winInfo.match(/Absolute upper-left X:\s*(\d+)/);
- const yMatch = winInfo.match(/Absolute upper-left Y:\s*(\d+)/);
- const widthMatch = winInfo.match(/Width:\s*(\d+)/);
- const heightMatch = winInfo.match(/Height:\s*(\d+)/);
-
- if (xMatch && yMatch && widthMatch && heightMatch) {
- this.updateGeometry({
- x: parseInt(xMatch[1], 10),
- y: parseInt(yMatch[1], 10),
- width: parseInt(widthMatch[1], 10),
- height: parseInt(heightMatch[1], 10),
- });
- } else {
- this.updateGeometry(null);
- }
- } catch (err) {
+ private async pollGeometryAsync(): Promise {
+ const windowIdsOutput = await this.runCommand('xdotool', ['search', '--class', 'mpv']);
+ const windowIds = windowIdsOutput.trim();
+ if (!windowIds) {
this.updateGeometry(null);
+ return;
+ }
+
+ const windowIdList = windowIds.split(/\s+/).filter(Boolean);
+ if (windowIdList.length === 0) {
+ this.updateGeometry(null);
+ return;
+ }
+
+ const windowId = await this.findTargetWindowId(windowIdList);
+ if (!windowId) {
+ this.updateGeometry(null);
+ return;
+ }
+
+ const winInfo = await this.runCommand('xwininfo', ['-id', windowId]);
+ const geometry = parseX11WindowGeometry(winInfo);
+ if (!geometry) {
+ this.updateGeometry(null);
+ return;
+ }
+
+ this.updateGeometry(geometry);
+ if (this.pollInterval && this.currentPollIntervalMs !== this.stablePollIntervalMs) {
+ this.currentPollIntervalMs = this.stablePollIntervalMs;
+ this.resetPollInterval(this.currentPollIntervalMs);
}
}
- private findTargetWindowId(windowIds: string[]): string | null {
+ private async findTargetWindowId(windowIds: string[]): Promise {
if (!this.targetMpvSocketPath) {
return windowIds[0] ?? null;
}
for (const windowId of windowIds) {
- if (this.isWindowForTargetSocket(windowId)) {
+ if (await this.isWindowForTargetSocket(windowId)) {
return windowId;
}
}
@@ -101,13 +159,13 @@ export class X11WindowTracker extends BaseWindowTracker {
return null;
}
- private isWindowForTargetSocket(windowId: string): boolean {
- const pid = this.getWindowPid(windowId);
+ private async isWindowForTargetSocket(windowId: string): Promise {
+ const pid = await this.getWindowPid(windowId);
if (pid === null) {
return false;
}
- const commandLine = this.getWindowCommandLine(pid);
+ const commandLine = await this.getWindowCommandLine(pid);
if (!commandLine) {
return false;
}
@@ -118,23 +176,24 @@ export class X11WindowTracker extends BaseWindowTracker {
);
}
- private getWindowPid(windowId: string): number | null {
- const windowPid = execSync(`xprop -id ${windowId} _NET_WM_PID`, {
- encoding: 'utf-8',
- });
- const pidMatch = windowPid.match(/= (\d+)/);
- if (!pidMatch) {
+ private async getWindowPid(windowId: string): Promise {
+ let windowPid: string;
+ try {
+ windowPid = await this.runCommand('xprop', ['-id', windowId, '_NET_WM_PID']);
+ } catch {
return null;
}
-
- const pid = Number.parseInt(pidMatch[1], 10);
- return Number.isInteger(pid) ? pid : null;
+ return parseX11WindowPid(windowPid);
}
- private getWindowCommandLine(pid: number): string | null {
- const commandLine = execSync(`ps -p ${pid} -o args=`, {
- encoding: 'utf-8',
- }).trim();
+ private async getWindowCommandLine(pid: number): Promise {
+ let raw: string;
+ try {
+ raw = await this.runCommand('ps', ['-p', String(pid), '-o', 'args=']);
+ } catch {
+ return null;
+ }
+ const commandLine = raw.trim();
return commandLine || null;
}
}