Fix mpv tlang and profile parsing

This commit is contained in:
2026-02-18 19:04:24 -08:00
parent f299f2a19e
commit d1aeb3b754
18 changed files with 537 additions and 88 deletions

View File

@@ -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<br/>resolve keybindings"]:::init
Runtime["Create mpv client<br/>init MeCab tokenizer"]:::init
Runtime["Create mpv client<br/>init runtime options"]:::init
Platform["Start window tracker<br/>WebSocket policy"]:::init
end
Init --> Create["Create overlay window<br/>Establish IPC bridge<br/>Load Yomitan extension"]:::phase
Init --> Create["Create overlay window<br/>Establish IPC bridge"]:::phase
Create --> Warm["Background warmups<br/>MeCab · Yomitan · dictionaries · Jellyfin"]:::phase
Create --> Loop
Warm --> Loop
subgraph Loop["Runtime — event-driven"]
direction LR
Events["mpv · IPC · CLI<br/>shortcut events"]:::runtime

View File

@@ -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.

View File

@@ -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",

View File

@@ -34,10 +34,18 @@ function makeDeps(overrides: Partial<AppReadyRuntimeDeps> = {}) {
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<void>((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();
});

View File

@@ -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 {

View File

@@ -15,6 +15,7 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
quitApp: () => {},
toggleVisibleOverlay: () => {},
tokenizeCurrentSubtitle: async () => null,
getCurrentSubtitleRaw: () => '',
getCurrentSubtitleAss: () => '',
getMpvSubtitleRenderMetrics: () => null,
getSubtitlePosition: () => null,

View File

@@ -12,6 +12,7 @@ export interface IpcServiceDeps {
toggleVisibleOverlay: () => void;
getInvisibleOverlayVisibility: () => boolean;
tokenizeCurrentSubtitle: () => Promise<unknown>;
getCurrentSubtitleRaw: () => string;
getCurrentSubtitleAss: () => string;
getMpvSubtitleRenderMetrics: () => unknown;
getSubtitlePosition: () => unknown;
@@ -75,6 +76,7 @@ export interface IpcDepsRuntimeOptions {
quitApp: () => void;
toggleVisibleOverlay: () => void;
tokenizeCurrentSubtitle: () => Promise<unknown>;
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();
});

View File

@@ -106,10 +106,14 @@ export interface AppReadyRuntimeDeps {
createImmersionTracker?: () => void;
startJellyfinRemoteSession?: () => Promise<void>;
loadYomitanExtension: () => Promise<void>;
prewarmSubtitleDictionaries?: () => Promise<void>;
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<void> {
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<voi
} else {
deps.log('Runtime ready: createImmersionTracker dependency is missing.');
}
await deps.loadYomitanExtension();
if (deps.startJellyfinRemoteSession) {
await deps.startJellyfinRemoteSession();
}
if (deps.texthookerOnlyMode) {
deps.log('Texthooker-only mode enabled; skipping overlay window.');
@@ -192,4 +195,6 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
}
deps.handleInitialArgs();
deps.startBackgroundWarmups();
deps.logDebug?.(`App-ready critical path finished in ${now() - startupStartedAtMs}ms.`);
}

View File

@@ -0,0 +1,72 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createSubtitleProcessingController } from './subtitle-processing-controller';
import type { SubtitleData } from '../../types';
function flushMicrotasks(): Promise<void> {
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<SubtitleData | null>((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);
});

View File

@@ -0,0 +1,96 @@
import type { SubtitleData } from '../../types';
export interface SubtitleProcessingControllerDeps {
tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>;
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();
},
};
}

View File

@@ -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<boolean> | null = null;
let backgroundWarmupsStarted = false;
let yomitanLoadInFlight: Promise<Extension | null> | 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<SubtitleData> {
);
}
async function createMecabTokenizerAndCheck(): Promise<void> {
if (!appState.mecabTokenizer) {
appState.mecabTokenizer = new MecabTokenizer();
}
await appState.mecabTokenizer.checkAvailability();
}
async function prewarmSubtitleDictionaries(): Promise<void> {
await Promise.all([
jlptDictionaryRuntime.ensureJlptDictionaryLookup(),
frequencyDictionaryRuntime.ensureFrequencyDictionaryLookup(),
]);
}
function launchBackgroundWarmupTask(label: string, task: () => Promise<void>): 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<Extension | null> {
});
}
async function ensureYomitanExtensionLoaded(): Promise<Extension | null> {
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,16 +2854,27 @@ function initializeOverlayRuntime(): void {
});
overlayManager.setInvisibleOverlayVisible(result.invisibleOverlayVisible);
appState.overlayRuntimeInitialized = true;
startBackgroundWarmups();
}
function openYomitanSettings(): void {
void (async () => {
const extension = await ensureYomitanExtensionLoaded();
if (!extension) {
logger.warn('Unable to open Yomitan settings: extension failed to load.');
return;
}
openYomitanSettingsWindow({
yomitanExt: appState.yomitanExt,
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 {
registerGlobalShortcutsCore({
@@ -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(),

View File

@@ -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,
};
}

View File

@@ -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,

View File

@@ -83,6 +83,7 @@ const electronAPI: ElectronAPI = {
getOverlayVisibility: (): Promise<boolean> => ipcRenderer.invoke('get-overlay-visibility'),
getCurrentSubtitle: (): Promise<SubtitleData> => ipcRenderer.invoke('get-current-subtitle'),
getCurrentSubtitleRaw: (): Promise<string> => ipcRenderer.invoke('get-current-subtitle-raw'),
getCurrentSubtitleAss: (): Promise<string> => ipcRenderer.invoke('get-current-subtitle-ass'),
getMpvSubtitleRenderMetrics: () => ipcRenderer.invoke('get-mpv-subtitle-render-metrics'),
onMpvSubtitleRenderMetrics: (callback: (metrics: MpvSubtitleRenderMetrics) => void) => {

View File

@@ -153,7 +153,7 @@ async function init(): Promise<void> {
});
}
const initialSubtitle = await window.electronAPI.getCurrentSubtitle();
const initialSubtitle = await window.electronAPI.getCurrentSubtitleRaw();
subtitleRenderer.renderSubtitle(initialSubtitle);
measurementReporter.schedule();

View File

@@ -724,6 +724,7 @@ export interface ElectronAPI {
onSubtitlePosition: (callback: (position: SubtitlePosition | null) => void) => void;
getOverlayVisibility: () => Promise<boolean>;
getCurrentSubtitle: () => Promise<SubtitleData>;
getCurrentSubtitleRaw: () => Promise<string>;
getCurrentSubtitleAss: () => Promise<string>;
getMpvSubtitleRenderMetrics: () => Promise<MpvSubtitleRenderMetrics>;
onMpvSubtitleRenderMetrics: (callback: (metrics: MpvSubtitleRenderMetrics) => void) => void;

View File

@@ -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<void>((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));
});

View File

@@ -16,20 +16,69 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { execSync } from 'child_process';
import { execFile } from 'child_process';
import { BaseWindowTracker } from './base-tracker';
type CommandRunner = (command: string, args: string[]) => Promise<string>;
function execFileUtf8(command: string, args: string[]): Promise<string> {
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<typeof setInterval> | 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,12 +89,31 @@ export class X11WindowTracker extends BaseWindowTracker {
}
}
private pollGeometry(): void {
try {
const windowIds = execSync('xdotool search --class mpv', {
encoding: 'utf-8',
}).trim();
private resetPollInterval(intervalMs: number): void {
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
this.pollInterval = setInterval(() => this.pollGeometry(), intervalMs);
}
private pollGeometry(): void {
if (this.pollInFlight) {
return;
}
this.pollInFlight = true;
void this.pollGeometryAsync()
.catch(() => {
this.updateGeometry(null);
})
.finally(() => {
this.pollInFlight = false;
});
}
private async pollGeometryAsync(): Promise<void> {
const windowIdsOutput = await this.runCommand('xdotool', ['search', '--class', 'mpv']);
const windowIds = windowIdsOutput.trim();
if (!windowIds) {
this.updateGeometry(null);
return;
@@ -57,43 +125,33 @@ export class X11WindowTracker extends BaseWindowTracker {
return;
}
const windowId = this.findTargetWindowId(windowIdList);
const windowId = await this.findTargetWindowId(windowIdList);
if (!windowId) {
this.updateGeometry(null);
return;
}
const winInfo = execSync(`xwininfo -id ${windowId}`, {
encoding: 'utf-8',
});
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 {
const winInfo = await this.runCommand('xwininfo', ['-id', windowId]);
const geometry = parseX11WindowGeometry(winInfo);
if (!geometry) {
this.updateGeometry(null);
return;
}
} catch (err) {
this.updateGeometry(null);
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<string | null> {
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<boolean> {
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<number | null> {
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<string | null> {
let raw: string;
try {
raw = await this.runCommand('ps', ['-p', String(pid), '-o', 'args=']);
} catch {
return null;
}
const commandLine = raw.trim();
return commandLine || null;
}
}