From d1aeb3b754e646c4fa62b2cb58214339f8c7006e Mon Sep 17 00:00:00 2001 From: sudacode Date: Wed, 18 Feb 2026 19:04:24 -0800 Subject: [PATCH] Fix mpv tlang and profile parsing --- docs/architecture.md | 9 +- docs/mining-workflow.md | 11 ++ package.json | 2 +- src/core/services/app-ready.test.ts | 37 +++- src/core/services/index.ts | 1 + src/core/services/ipc.test.ts | 1 + src/core/services/ipc.ts | 7 + src/core/services/startup.ts | 15 +- .../subtitle-processing-controller.test.ts | 72 +++++++ .../subtitle-processing-controller.ts | 96 ++++++++++ src/main.ts | 127 +++++++++++-- src/main/app-lifecycle.ts | 8 + src/main/dependencies.ts | 2 + src/preload.ts | 1 + src/renderer/renderer.ts | 2 +- src/types.ts | 1 + src/window-trackers/x11-tracker.test.ts | 54 ++++++ src/window-trackers/x11-tracker.ts | 179 ++++++++++++------ 18 files changed, 537 insertions(+), 88 deletions(-) create mode 100644 src/core/services/subtitle-processing-controller.test.ts create mode 100644 src/core/services/subtitle-processing-controller.ts create mode 100644 src/window-trackers/x11-tracker.test.ts 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; } }