mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
Fix mpv tlang and profile parsing
This commit is contained in:
@@ -170,7 +170,7 @@ This keeps side effects explicit and makes behavior easy to unit-test with fakes
|
|||||||
## Program Lifecycle
|
## 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.
|
- **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.
|
- **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.
|
- **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"]
|
subgraph Init["Initialization"]
|
||||||
direction LR
|
direction LR
|
||||||
Config["Load config<br/>resolve keybindings"]:::init
|
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
|
Platform["Start window tracker<br/>WebSocket policy"]:::init
|
||||||
end
|
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"]
|
subgraph Loop["Runtime — event-driven"]
|
||||||
direction LR
|
direction LR
|
||||||
Events["mpv · IPC · CLI<br/>shortcut events"]:::runtime
|
Events["mpv · IPC · CLI<br/>shortcut events"]:::runtime
|
||||||
|
|||||||
@@ -13,6 +13,17 @@ Watch video → See subtitle → Click word → Yomitan lookup → Add to Anki
|
|||||||
sentence, audio, image, translation
|
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
|
## The Two Overlay Layers
|
||||||
|
|
||||||
SubMiner uses two overlay layers, each serving a different purpose.
|
SubMiner uses two overlay layers, each serving a different purpose.
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"format:check": "prettier --check .",
|
"format:check": "prettier --check .",
|
||||||
"test:config:dist": "node --test dist/config/config.test.js",
|
"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:subtitle:dist": "echo \"Subtitle tests are currently not configured\"",
|
||||||
"test": "bun run test:config && bun run test:core",
|
"test": "bun run test:config && bun run test:core",
|
||||||
"test:config": "bun run build && bun run test:config:dist",
|
"test:config": "bun run build && bun run test:config:dist",
|
||||||
|
|||||||
@@ -34,10 +34,18 @@ function makeDeps(overrides: Partial<AppReadyRuntimeDeps> = {}) {
|
|||||||
loadYomitanExtension: async () => {
|
loadYomitanExtension: async () => {
|
||||||
calls.push('loadYomitanExtension');
|
calls.push('loadYomitanExtension');
|
||||||
},
|
},
|
||||||
|
prewarmSubtitleDictionaries: async () => {
|
||||||
|
calls.push('prewarmSubtitleDictionaries');
|
||||||
|
},
|
||||||
|
startBackgroundWarmups: () => {
|
||||||
|
calls.push('startBackgroundWarmups');
|
||||||
|
},
|
||||||
texthookerOnlyMode: false,
|
texthookerOnlyMode: false,
|
||||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => true,
|
shouldAutoInitializeOverlayRuntimeFromConfig: () => true,
|
||||||
initializeOverlayRuntime: () => calls.push('initializeOverlayRuntime'),
|
initializeOverlayRuntime: () => calls.push('initializeOverlayRuntime'),
|
||||||
handleInitialArgs: () => calls.push('handleInitialArgs'),
|
handleInitialArgs: () => calls.push('handleInitialArgs'),
|
||||||
|
logDebug: (message) => calls.push(`debug:${message}`),
|
||||||
|
now: () => 1000,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
return { deps, calls };
|
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('startSubtitleWebsocket:9001'));
|
||||||
assert.ok(calls.includes('initializeOverlayRuntime'));
|
assert.ok(calls.includes('initializeOverlayRuntime'));
|
||||||
assert.ok(calls.includes('createImmersionTracker'));
|
assert.ok(calls.includes('createImmersionTracker'));
|
||||||
assert.ok(calls.includes('startJellyfinRemoteSession'));
|
assert.ok(calls.includes('startBackgroundWarmups'));
|
||||||
assert.ok(calls.includes('log:Runtime ready: invoking createImmersionTracker.'));
|
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);
|
await runAppReadyRuntime(deps);
|
||||||
|
|
||||||
assert.equal(calls.includes('startJellyfinRemoteSession'), false);
|
assert.equal(calls.includes('startJellyfinRemoteSession'), false);
|
||||||
assert.ok(calls.includes('createMecabTokenizerAndCheck'));
|
|
||||||
assert.ok(calls.includes('createMpvClient'));
|
assert.ok(calls.includes('createMpvClient'));
|
||||||
assert.ok(calls.includes('createSubtitleTimingTracker'));
|
assert.ok(calls.includes('createSubtitleTimingTracker'));
|
||||||
assert.ok(calls.includes('handleInitialArgs'));
|
assert.ok(calls.includes('handleInitialArgs'));
|
||||||
|
assert.ok(calls.includes('startBackgroundWarmups'));
|
||||||
assert.ok(
|
assert.ok(
|
||||||
calls.includes('initializeOverlayRuntime') ||
|
calls.includes('initializeOverlayRuntime') ||
|
||||||
calls.includes('log:Overlay runtime deferred: waiting for explicit overlay command.'),
|
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);
|
await runAppReadyRuntime(deps);
|
||||||
assert.ok(calls.includes('setLogLevel:warn:config'));
|
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();
|
||||||
|
});
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export {
|
|||||||
} from './startup';
|
} from './startup';
|
||||||
export { openYomitanSettingsWindow } from './yomitan-settings';
|
export { openYomitanSettingsWindow } from './yomitan-settings';
|
||||||
export { createTokenizerDepsRuntime, tokenizeSubtitle } from './tokenizer';
|
export { createTokenizerDepsRuntime, tokenizeSubtitle } from './tokenizer';
|
||||||
|
export { createSubtitleProcessingController } from './subtitle-processing-controller';
|
||||||
export { createFrequencyDictionaryLookup } from './frequency-dictionary';
|
export { createFrequencyDictionaryLookup } from './frequency-dictionary';
|
||||||
export { createJlptVocabularyLookup } from './jlpt-vocab';
|
export { createJlptVocabularyLookup } from './jlpt-vocab';
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
|
|||||||
quitApp: () => {},
|
quitApp: () => {},
|
||||||
toggleVisibleOverlay: () => {},
|
toggleVisibleOverlay: () => {},
|
||||||
tokenizeCurrentSubtitle: async () => null,
|
tokenizeCurrentSubtitle: async () => null,
|
||||||
|
getCurrentSubtitleRaw: () => '',
|
||||||
getCurrentSubtitleAss: () => '',
|
getCurrentSubtitleAss: () => '',
|
||||||
getMpvSubtitleRenderMetrics: () => null,
|
getMpvSubtitleRenderMetrics: () => null,
|
||||||
getSubtitlePosition: () => null,
|
getSubtitlePosition: () => null,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface IpcServiceDeps {
|
|||||||
toggleVisibleOverlay: () => void;
|
toggleVisibleOverlay: () => void;
|
||||||
getInvisibleOverlayVisibility: () => boolean;
|
getInvisibleOverlayVisibility: () => boolean;
|
||||||
tokenizeCurrentSubtitle: () => Promise<unknown>;
|
tokenizeCurrentSubtitle: () => Promise<unknown>;
|
||||||
|
getCurrentSubtitleRaw: () => string;
|
||||||
getCurrentSubtitleAss: () => string;
|
getCurrentSubtitleAss: () => string;
|
||||||
getMpvSubtitleRenderMetrics: () => unknown;
|
getMpvSubtitleRenderMetrics: () => unknown;
|
||||||
getSubtitlePosition: () => unknown;
|
getSubtitlePosition: () => unknown;
|
||||||
@@ -75,6 +76,7 @@ export interface IpcDepsRuntimeOptions {
|
|||||||
quitApp: () => void;
|
quitApp: () => void;
|
||||||
toggleVisibleOverlay: () => void;
|
toggleVisibleOverlay: () => void;
|
||||||
tokenizeCurrentSubtitle: () => Promise<unknown>;
|
tokenizeCurrentSubtitle: () => Promise<unknown>;
|
||||||
|
getCurrentSubtitleRaw: () => string;
|
||||||
getCurrentSubtitleAss: () => string;
|
getCurrentSubtitleAss: () => string;
|
||||||
getMpvSubtitleRenderMetrics: () => unknown;
|
getMpvSubtitleRenderMetrics: () => unknown;
|
||||||
getSubtitlePosition: () => unknown;
|
getSubtitlePosition: () => unknown;
|
||||||
@@ -122,6 +124,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
|||||||
toggleVisibleOverlay: options.toggleVisibleOverlay,
|
toggleVisibleOverlay: options.toggleVisibleOverlay,
|
||||||
getInvisibleOverlayVisibility: options.getInvisibleOverlayVisibility,
|
getInvisibleOverlayVisibility: options.getInvisibleOverlayVisibility,
|
||||||
tokenizeCurrentSubtitle: options.tokenizeCurrentSubtitle,
|
tokenizeCurrentSubtitle: options.tokenizeCurrentSubtitle,
|
||||||
|
getCurrentSubtitleRaw: options.getCurrentSubtitleRaw,
|
||||||
getCurrentSubtitleAss: options.getCurrentSubtitleAss,
|
getCurrentSubtitleAss: options.getCurrentSubtitleAss,
|
||||||
getMpvSubtitleRenderMetrics: options.getMpvSubtitleRenderMetrics,
|
getMpvSubtitleRenderMetrics: options.getMpvSubtitleRenderMetrics,
|
||||||
getSubtitlePosition: options.getSubtitlePosition,
|
getSubtitlePosition: options.getSubtitlePosition,
|
||||||
@@ -220,6 +223,10 @@ export function registerIpcHandlers(deps: IpcServiceDeps): void {
|
|||||||
return await deps.tokenizeCurrentSubtitle();
|
return await deps.tokenizeCurrentSubtitle();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('get-current-subtitle-raw', () => {
|
||||||
|
return deps.getCurrentSubtitleRaw();
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.handle('get-current-subtitle-ass', () => {
|
ipcMain.handle('get-current-subtitle-ass', () => {
|
||||||
return deps.getCurrentSubtitleAss();
|
return deps.getCurrentSubtitleAss();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -106,10 +106,14 @@ export interface AppReadyRuntimeDeps {
|
|||||||
createImmersionTracker?: () => void;
|
createImmersionTracker?: () => void;
|
||||||
startJellyfinRemoteSession?: () => Promise<void>;
|
startJellyfinRemoteSession?: () => Promise<void>;
|
||||||
loadYomitanExtension: () => Promise<void>;
|
loadYomitanExtension: () => Promise<void>;
|
||||||
|
prewarmSubtitleDictionaries?: () => Promise<void>;
|
||||||
|
startBackgroundWarmups: () => void;
|
||||||
texthookerOnlyMode: boolean;
|
texthookerOnlyMode: boolean;
|
||||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => boolean;
|
shouldAutoInitializeOverlayRuntimeFromConfig: () => boolean;
|
||||||
initializeOverlayRuntime: () => void;
|
initializeOverlayRuntime: () => void;
|
||||||
handleInitialArgs: () => void;
|
handleInitialArgs: () => void;
|
||||||
|
logDebug?: (message: string) => void;
|
||||||
|
now?: () => number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getInitialInvisibleOverlayVisibility(
|
export function getInitialInvisibleOverlayVisibility(
|
||||||
@@ -143,9 +147,12 @@ export function isAutoUpdateEnabledRuntime(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<void> {
|
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.loadSubtitlePosition();
|
||||||
deps.resolveKeybindings();
|
deps.resolveKeybindings();
|
||||||
await deps.createMecabTokenizerAndCheck();
|
|
||||||
deps.createMpvClient();
|
deps.createMpvClient();
|
||||||
|
|
||||||
deps.reloadConfig();
|
deps.reloadConfig();
|
||||||
@@ -178,10 +185,6 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
|||||||
} else {
|
} else {
|
||||||
deps.log('Runtime ready: createImmersionTracker dependency is missing.');
|
deps.log('Runtime ready: createImmersionTracker dependency is missing.');
|
||||||
}
|
}
|
||||||
await deps.loadYomitanExtension();
|
|
||||||
if (deps.startJellyfinRemoteSession) {
|
|
||||||
await deps.startJellyfinRemoteSession();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deps.texthookerOnlyMode) {
|
if (deps.texthookerOnlyMode) {
|
||||||
deps.log('Texthooker-only mode enabled; skipping overlay window.');
|
deps.log('Texthooker-only mode enabled; skipping overlay window.');
|
||||||
@@ -192,4 +195,6 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
|||||||
}
|
}
|
||||||
|
|
||||||
deps.handleInitialArgs();
|
deps.handleInitialArgs();
|
||||||
|
deps.startBackgroundWarmups();
|
||||||
|
deps.logDebug?.(`App-ready critical path finished in ${now() - startupStartedAtMs}ms.`);
|
||||||
}
|
}
|
||||||
|
|||||||
72
src/core/services/subtitle-processing-controller.test.ts
Normal file
72
src/core/services/subtitle-processing-controller.test.ts
Normal 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);
|
||||||
|
});
|
||||||
96
src/core/services/subtitle-processing-controller.ts
Normal file
96
src/core/services/subtitle-processing-controller.ts
Normal 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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
127
src/main.ts
127
src/main.ts
@@ -90,6 +90,7 @@ import {
|
|||||||
createFieldGroupingOverlayRuntime,
|
createFieldGroupingOverlayRuntime,
|
||||||
createNumericShortcutRuntime,
|
createNumericShortcutRuntime,
|
||||||
createOverlayContentMeasurementStore,
|
createOverlayContentMeasurementStore,
|
||||||
|
createSubtitleProcessingController,
|
||||||
createOverlayWindow as createOverlayWindowCore,
|
createOverlayWindow as createOverlayWindowCore,
|
||||||
createTokenizerDepsRuntime,
|
createTokenizerDepsRuntime,
|
||||||
cycleSecondarySubMode as cycleSecondarySubModeCore,
|
cycleSecondarySubMode as cycleSecondarySubModeCore,
|
||||||
@@ -236,6 +237,8 @@ type ActiveJellyfinRemotePlaybackState = {
|
|||||||
let activeJellyfinRemotePlayback: ActiveJellyfinRemotePlaybackState | null = null;
|
let activeJellyfinRemotePlayback: ActiveJellyfinRemotePlaybackState | null = null;
|
||||||
let jellyfinRemoteLastProgressAtMs = 0;
|
let jellyfinRemoteLastProgressAtMs = 0;
|
||||||
let jellyfinMpvAutoLaunchInFlight: Promise<boolean> | null = null;
|
let jellyfinMpvAutoLaunchInFlight: Promise<boolean> | null = null;
|
||||||
|
let backgroundWarmupsStarted = false;
|
||||||
|
let yomitanLoadInFlight: Promise<Extension | null> | null = null;
|
||||||
|
|
||||||
function applyJellyfinMpvDefaults(client: MpvIpcClient): void {
|
function applyJellyfinMpvDefaults(client: MpvIpcClient): void {
|
||||||
sendMpvCommandRuntime(client, ['set_property', 'sub-auto', 'fuzzy']);
|
sendMpvCommandRuntime(client, ['set_property', 'sub-auto', 'fuzzy']);
|
||||||
@@ -364,6 +367,21 @@ const appState = createAppState({
|
|||||||
texthookerPort: DEFAULT_TEXTHOOKER_PORT,
|
texthookerPort: DEFAULT_TEXTHOOKER_PORT,
|
||||||
});
|
});
|
||||||
let appTray: Tray | null = null;
|
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({
|
const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService({
|
||||||
getConfiguredShortcuts: () => getConfiguredShortcuts(),
|
getConfiguredShortcuts: () => getConfiguredShortcuts(),
|
||||||
getShortcutsRegistered: () => appState.shortcutsRegistered,
|
getShortcutsRegistered: () => appState.shortcutsRegistered,
|
||||||
@@ -2205,9 +2223,7 @@ const startupState = runStartupBootstrapRuntime(
|
|||||||
},
|
},
|
||||||
log: (message) => appLogger.logInfo(message),
|
log: (message) => appLogger.logInfo(message),
|
||||||
createMecabTokenizerAndCheck: async () => {
|
createMecabTokenizerAndCheck: async () => {
|
||||||
const tokenizer = new MecabTokenizer();
|
await createMecabTokenizerAndCheck();
|
||||||
appState.mecabTokenizer = tokenizer;
|
|
||||||
await tokenizer.checkAvailability();
|
|
||||||
},
|
},
|
||||||
createSubtitleTimingTracker: () => {
|
createSubtitleTimingTracker: () => {
|
||||||
const tracker = new SubtitleTimingTracker();
|
const tracker = new SubtitleTimingTracker();
|
||||||
@@ -2258,11 +2274,21 @@ const startupState = runStartupBootstrapRuntime(
|
|||||||
startJellyfinRemoteSession: async () => {
|
startJellyfinRemoteSession: async () => {
|
||||||
await startJellyfinRemoteSession();
|
await startJellyfinRemoteSession();
|
||||||
},
|
},
|
||||||
|
prewarmSubtitleDictionaries: async () => {
|
||||||
|
await prewarmSubtitleDictionaries();
|
||||||
|
},
|
||||||
|
startBackgroundWarmups: () => {
|
||||||
|
startBackgroundWarmups();
|
||||||
|
},
|
||||||
texthookerOnlyMode: appState.texthookerOnlyMode,
|
texthookerOnlyMode: appState.texthookerOnlyMode,
|
||||||
shouldAutoInitializeOverlayRuntimeFromConfig: () =>
|
shouldAutoInitializeOverlayRuntimeFromConfig: () =>
|
||||||
appState.backgroundMode ? false : shouldAutoInitializeOverlayRuntimeFromConfig(),
|
appState.backgroundMode ? false : shouldAutoInitializeOverlayRuntimeFromConfig(),
|
||||||
initializeOverlayRuntime: () => initializeOverlayRuntime(),
|
initializeOverlayRuntime: () => initializeOverlayRuntime(),
|
||||||
handleInitialArgs: () => handleInitialArgs(),
|
handleInitialArgs: () => handleInitialArgs(),
|
||||||
|
logDebug: (message: string) => {
|
||||||
|
logger.debug(message);
|
||||||
|
},
|
||||||
|
now: () => Date.now(),
|
||||||
}),
|
}),
|
||||||
onWillQuitCleanup: () => {
|
onWillQuitCleanup: () => {
|
||||||
destroyTray();
|
destroyTray();
|
||||||
@@ -2417,12 +2443,7 @@ function bindMpvClientEventHandlers(mpvClient: MpvIpcClient): void {
|
|||||||
mpvClient.on('subtitle-change', ({ text }) => {
|
mpvClient.on('subtitle-change', ({ text }) => {
|
||||||
appState.currentSubText = text;
|
appState.currentSubText = text;
|
||||||
subtitleWsService.broadcast(text);
|
subtitleWsService.broadcast(text);
|
||||||
void (async () => {
|
subtitleProcessingController.onSubtitleChange(text);
|
||||||
if (getOverlayWindows().length > 0) {
|
|
||||||
const subtitleData = await tokenizeSubtitle(text);
|
|
||||||
broadcastToOverlayWindows('subtitle:set', subtitleData);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
});
|
});
|
||||||
mpvClient.on('subtitle-ass-change', ({ text }) => {
|
mpvClient.on('subtitle-ass-change', ({ text }) => {
|
||||||
appState.currentSubAssText = 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 {
|
function updateVisibleOverlayBounds(geometry: WindowGeometry): void {
|
||||||
overlayManager.setOverlayWindowBounds('visible', geometry);
|
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 {
|
function createOverlayWindow(kind: 'visible' | 'invisible'): BrowserWindow {
|
||||||
return createOverlayWindowCore(kind, {
|
return createOverlayWindowCore(kind, {
|
||||||
isDev,
|
isDev,
|
||||||
@@ -2769,15 +2854,26 @@ function initializeOverlayRuntime(): void {
|
|||||||
});
|
});
|
||||||
overlayManager.setInvisibleOverlayVisible(result.invisibleOverlayVisible);
|
overlayManager.setInvisibleOverlayVisible(result.invisibleOverlayVisible);
|
||||||
appState.overlayRuntimeInitialized = true;
|
appState.overlayRuntimeInitialized = true;
|
||||||
|
startBackgroundWarmups();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openYomitanSettings(): void {
|
function openYomitanSettings(): void {
|
||||||
openYomitanSettingsWindow({
|
void (async () => {
|
||||||
yomitanExt: appState.yomitanExt,
|
const extension = await ensureYomitanExtensionLoaded();
|
||||||
getExistingWindow: () => appState.yomitanSettingsWindow,
|
if (!extension) {
|
||||||
setWindow: (window: BrowserWindow | null) => {
|
logger.warn('Unable to open Yomitan settings: extension failed to load.');
|
||||||
appState.yomitanSettingsWindow = window;
|
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 {
|
function registerGlobalShortcuts(): void {
|
||||||
@@ -3108,6 +3204,7 @@ registerIpcRuntimeServices({
|
|||||||
quitApp: () => app.quit(),
|
quitApp: () => app.quit(),
|
||||||
toggleVisibleOverlay: () => toggleVisibleOverlay(),
|
toggleVisibleOverlay: () => toggleVisibleOverlay(),
|
||||||
tokenizeCurrentSubtitle: () => tokenizeSubtitle(appState.currentSubText),
|
tokenizeCurrentSubtitle: () => tokenizeSubtitle(appState.currentSubText),
|
||||||
|
getCurrentSubtitleRaw: () => appState.currentSubText,
|
||||||
getCurrentSubtitleAss: () => appState.currentSubAssText,
|
getCurrentSubtitleAss: () => appState.currentSubAssText,
|
||||||
getMpvSubtitleRenderMetrics: () => appState.mpvSubtitleRenderMetrics,
|
getMpvSubtitleRenderMetrics: () => appState.mpvSubtitleRenderMetrics,
|
||||||
getSubtitlePosition: () => loadSubtitlePosition(),
|
getSubtitlePosition: () => loadSubtitlePosition(),
|
||||||
|
|||||||
@@ -39,10 +39,14 @@ export interface AppReadyRuntimeDepsFactoryInput {
|
|||||||
createImmersionTracker?: AppReadyRuntimeDeps['createImmersionTracker'];
|
createImmersionTracker?: AppReadyRuntimeDeps['createImmersionTracker'];
|
||||||
startJellyfinRemoteSession?: AppReadyRuntimeDeps['startJellyfinRemoteSession'];
|
startJellyfinRemoteSession?: AppReadyRuntimeDeps['startJellyfinRemoteSession'];
|
||||||
loadYomitanExtension: AppReadyRuntimeDeps['loadYomitanExtension'];
|
loadYomitanExtension: AppReadyRuntimeDeps['loadYomitanExtension'];
|
||||||
|
prewarmSubtitleDictionaries?: AppReadyRuntimeDeps['prewarmSubtitleDictionaries'];
|
||||||
|
startBackgroundWarmups: AppReadyRuntimeDeps['startBackgroundWarmups'];
|
||||||
texthookerOnlyMode: AppReadyRuntimeDeps['texthookerOnlyMode'];
|
texthookerOnlyMode: AppReadyRuntimeDeps['texthookerOnlyMode'];
|
||||||
shouldAutoInitializeOverlayRuntimeFromConfig: AppReadyRuntimeDeps['shouldAutoInitializeOverlayRuntimeFromConfig'];
|
shouldAutoInitializeOverlayRuntimeFromConfig: AppReadyRuntimeDeps['shouldAutoInitializeOverlayRuntimeFromConfig'];
|
||||||
initializeOverlayRuntime: AppReadyRuntimeDeps['initializeOverlayRuntime'];
|
initializeOverlayRuntime: AppReadyRuntimeDeps['initializeOverlayRuntime'];
|
||||||
handleInitialArgs: AppReadyRuntimeDeps['handleInitialArgs'];
|
handleInitialArgs: AppReadyRuntimeDeps['handleInitialArgs'];
|
||||||
|
logDebug?: AppReadyRuntimeDeps['logDebug'];
|
||||||
|
now?: AppReadyRuntimeDeps['now'];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createAppLifecycleRuntimeDeps(
|
export function createAppLifecycleRuntimeDeps(
|
||||||
@@ -88,11 +92,15 @@ export function createAppReadyRuntimeDeps(
|
|||||||
createImmersionTracker: params.createImmersionTracker,
|
createImmersionTracker: params.createImmersionTracker,
|
||||||
startJellyfinRemoteSession: params.startJellyfinRemoteSession,
|
startJellyfinRemoteSession: params.startJellyfinRemoteSession,
|
||||||
loadYomitanExtension: params.loadYomitanExtension,
|
loadYomitanExtension: params.loadYomitanExtension,
|
||||||
|
prewarmSubtitleDictionaries: params.prewarmSubtitleDictionaries,
|
||||||
|
startBackgroundWarmups: params.startBackgroundWarmups,
|
||||||
texthookerOnlyMode: params.texthookerOnlyMode,
|
texthookerOnlyMode: params.texthookerOnlyMode,
|
||||||
shouldAutoInitializeOverlayRuntimeFromConfig:
|
shouldAutoInitializeOverlayRuntimeFromConfig:
|
||||||
params.shouldAutoInitializeOverlayRuntimeFromConfig,
|
params.shouldAutoInitializeOverlayRuntimeFromConfig,
|
||||||
initializeOverlayRuntime: params.initializeOverlayRuntime,
|
initializeOverlayRuntime: params.initializeOverlayRuntime,
|
||||||
handleInitialArgs: params.handleInitialArgs,
|
handleInitialArgs: params.handleInitialArgs,
|
||||||
|
logDebug: params.logDebug,
|
||||||
|
now: params.now,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export interface MainIpcRuntimeServiceDepsParams {
|
|||||||
quitApp: IpcDepsRuntimeOptions['quitApp'];
|
quitApp: IpcDepsRuntimeOptions['quitApp'];
|
||||||
toggleVisibleOverlay: IpcDepsRuntimeOptions['toggleVisibleOverlay'];
|
toggleVisibleOverlay: IpcDepsRuntimeOptions['toggleVisibleOverlay'];
|
||||||
tokenizeCurrentSubtitle: IpcDepsRuntimeOptions['tokenizeCurrentSubtitle'];
|
tokenizeCurrentSubtitle: IpcDepsRuntimeOptions['tokenizeCurrentSubtitle'];
|
||||||
|
getCurrentSubtitleRaw: IpcDepsRuntimeOptions['getCurrentSubtitleRaw'];
|
||||||
getCurrentSubtitleAss: IpcDepsRuntimeOptions['getCurrentSubtitleAss'];
|
getCurrentSubtitleAss: IpcDepsRuntimeOptions['getCurrentSubtitleAss'];
|
||||||
focusMainWindow?: IpcDepsRuntimeOptions['focusMainWindow'];
|
focusMainWindow?: IpcDepsRuntimeOptions['focusMainWindow'];
|
||||||
getMpvSubtitleRenderMetrics: IpcDepsRuntimeOptions['getMpvSubtitleRenderMetrics'];
|
getMpvSubtitleRenderMetrics: IpcDepsRuntimeOptions['getMpvSubtitleRenderMetrics'];
|
||||||
@@ -205,6 +206,7 @@ export function createMainIpcRuntimeServiceDeps(
|
|||||||
quitApp: params.quitApp,
|
quitApp: params.quitApp,
|
||||||
toggleVisibleOverlay: params.toggleVisibleOverlay,
|
toggleVisibleOverlay: params.toggleVisibleOverlay,
|
||||||
tokenizeCurrentSubtitle: params.tokenizeCurrentSubtitle,
|
tokenizeCurrentSubtitle: params.tokenizeCurrentSubtitle,
|
||||||
|
getCurrentSubtitleRaw: params.getCurrentSubtitleRaw,
|
||||||
getCurrentSubtitleAss: params.getCurrentSubtitleAss,
|
getCurrentSubtitleAss: params.getCurrentSubtitleAss,
|
||||||
getMpvSubtitleRenderMetrics: params.getMpvSubtitleRenderMetrics,
|
getMpvSubtitleRenderMetrics: params.getMpvSubtitleRenderMetrics,
|
||||||
getSubtitlePosition: params.getSubtitlePosition,
|
getSubtitlePosition: params.getSubtitlePosition,
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ const electronAPI: ElectronAPI = {
|
|||||||
|
|
||||||
getOverlayVisibility: (): Promise<boolean> => ipcRenderer.invoke('get-overlay-visibility'),
|
getOverlayVisibility: (): Promise<boolean> => ipcRenderer.invoke('get-overlay-visibility'),
|
||||||
getCurrentSubtitle: (): Promise<SubtitleData> => ipcRenderer.invoke('get-current-subtitle'),
|
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'),
|
getCurrentSubtitleAss: (): Promise<string> => ipcRenderer.invoke('get-current-subtitle-ass'),
|
||||||
getMpvSubtitleRenderMetrics: () => ipcRenderer.invoke('get-mpv-subtitle-render-metrics'),
|
getMpvSubtitleRenderMetrics: () => ipcRenderer.invoke('get-mpv-subtitle-render-metrics'),
|
||||||
onMpvSubtitleRenderMetrics: (callback: (metrics: MpvSubtitleRenderMetrics) => void) => {
|
onMpvSubtitleRenderMetrics: (callback: (metrics: MpvSubtitleRenderMetrics) => void) => {
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ async function init(): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialSubtitle = await window.electronAPI.getCurrentSubtitle();
|
const initialSubtitle = await window.electronAPI.getCurrentSubtitleRaw();
|
||||||
subtitleRenderer.renderSubtitle(initialSubtitle);
|
subtitleRenderer.renderSubtitle(initialSubtitle);
|
||||||
measurementReporter.schedule();
|
measurementReporter.schedule();
|
||||||
|
|
||||||
|
|||||||
@@ -724,6 +724,7 @@ export interface ElectronAPI {
|
|||||||
onSubtitlePosition: (callback: (position: SubtitlePosition | null) => void) => void;
|
onSubtitlePosition: (callback: (position: SubtitlePosition | null) => void) => void;
|
||||||
getOverlayVisibility: () => Promise<boolean>;
|
getOverlayVisibility: () => Promise<boolean>;
|
||||||
getCurrentSubtitle: () => Promise<SubtitleData>;
|
getCurrentSubtitle: () => Promise<SubtitleData>;
|
||||||
|
getCurrentSubtitleRaw: () => Promise<string>;
|
||||||
getCurrentSubtitleAss: () => Promise<string>;
|
getCurrentSubtitleAss: () => Promise<string>;
|
||||||
getMpvSubtitleRenderMetrics: () => Promise<MpvSubtitleRenderMetrics>;
|
getMpvSubtitleRenderMetrics: () => Promise<MpvSubtitleRenderMetrics>;
|
||||||
onMpvSubtitleRenderMetrics: (callback: (metrics: MpvSubtitleRenderMetrics) => void) => void;
|
onMpvSubtitleRenderMetrics: (callback: (metrics: MpvSubtitleRenderMetrics) => void) => void;
|
||||||
|
|||||||
54
src/window-trackers/x11-tracker.test.ts
Normal file
54
src/window-trackers/x11-tracker.test.ts
Normal 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));
|
||||||
|
});
|
||||||
@@ -16,20 +16,69 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
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';
|
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 {
|
export class X11WindowTracker extends BaseWindowTracker {
|
||||||
private pollInterval: ReturnType<typeof setInterval> | null = null;
|
private pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
private readonly targetMpvSocketPath: string | 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();
|
super();
|
||||||
this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null;
|
this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null;
|
||||||
|
this.runCommand = runCommand;
|
||||||
}
|
}
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
this.pollInterval = setInterval(() => this.pollGeometry(), 250);
|
this.resetPollInterval(this.currentPollIntervalMs);
|
||||||
this.pollGeometry();
|
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 {
|
private pollGeometry(): void {
|
||||||
try {
|
if (this.pollInFlight) {
|
||||||
const windowIds = execSync('xdotool search --class mpv', {
|
return;
|
||||||
encoding: 'utf-8',
|
}
|
||||||
}).trim();
|
this.pollInFlight = true;
|
||||||
|
void this.pollGeometryAsync()
|
||||||
if (!windowIds) {
|
.catch(() => {
|
||||||
this.updateGeometry(null);
|
this.updateGeometry(null);
|
||||||
return;
|
})
|
||||||
}
|
.finally(() => {
|
||||||
|
this.pollInFlight = false;
|
||||||
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',
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const xMatch = winInfo.match(/Absolute upper-left X:\s*(\d+)/);
|
private async pollGeometryAsync(): Promise<void> {
|
||||||
const yMatch = winInfo.match(/Absolute upper-left Y:\s*(\d+)/);
|
const windowIdsOutput = await this.runCommand('xdotool', ['search', '--class', 'mpv']);
|
||||||
const widthMatch = winInfo.match(/Width:\s*(\d+)/);
|
const windowIds = windowIdsOutput.trim();
|
||||||
const heightMatch = winInfo.match(/Height:\s*(\d+)/);
|
if (!windowIds) {
|
||||||
|
|
||||||
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) {
|
|
||||||
this.updateGeometry(null);
|
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<string | null> {
|
||||||
if (!this.targetMpvSocketPath) {
|
if (!this.targetMpvSocketPath) {
|
||||||
return windowIds[0] ?? null;
|
return windowIds[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const windowId of windowIds) {
|
for (const windowId of windowIds) {
|
||||||
if (this.isWindowForTargetSocket(windowId)) {
|
if (await this.isWindowForTargetSocket(windowId)) {
|
||||||
return windowId;
|
return windowId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,13 +159,13 @@ export class X11WindowTracker extends BaseWindowTracker {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private isWindowForTargetSocket(windowId: string): boolean {
|
private async isWindowForTargetSocket(windowId: string): Promise<boolean> {
|
||||||
const pid = this.getWindowPid(windowId);
|
const pid = await this.getWindowPid(windowId);
|
||||||
if (pid === null) {
|
if (pid === null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const commandLine = this.getWindowCommandLine(pid);
|
const commandLine = await this.getWindowCommandLine(pid);
|
||||||
if (!commandLine) {
|
if (!commandLine) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -118,23 +176,24 @@ export class X11WindowTracker extends BaseWindowTracker {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getWindowPid(windowId: string): number | null {
|
private async getWindowPid(windowId: string): Promise<number | null> {
|
||||||
const windowPid = execSync(`xprop -id ${windowId} _NET_WM_PID`, {
|
let windowPid: string;
|
||||||
encoding: 'utf-8',
|
try {
|
||||||
});
|
windowPid = await this.runCommand('xprop', ['-id', windowId, '_NET_WM_PID']);
|
||||||
const pidMatch = windowPid.match(/= (\d+)/);
|
} catch {
|
||||||
if (!pidMatch) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
return parseX11WindowPid(windowPid);
|
||||||
const pid = Number.parseInt(pidMatch[1], 10);
|
|
||||||
return Number.isInteger(pid) ? pid : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private getWindowCommandLine(pid: number): string | null {
|
private async getWindowCommandLine(pid: number): Promise<string | null> {
|
||||||
const commandLine = execSync(`ps -p ${pid} -o args=`, {
|
let raw: string;
|
||||||
encoding: 'utf-8',
|
try {
|
||||||
}).trim();
|
raw = await this.runCommand('ps', ['-p', String(pid), '-o', 'args=']);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const commandLine = raw.trim();
|
||||||
return commandLine || null;
|
return commandLine || null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user