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:
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -15,6 +15,7 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
|
||||
quitApp: () => {},
|
||||
toggleVisibleOverlay: () => {},
|
||||
tokenizeCurrentSubtitle: async () => null,
|
||||
getCurrentSubtitleRaw: () => '',
|
||||
getCurrentSubtitleAss: () => '',
|
||||
getMpvSubtitleRenderMetrics: () => null,
|
||||
getSubtitlePosition: () => null,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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.`);
|
||||
}
|
||||
|
||||
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,
|
||||
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,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(),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
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/>.
|
||||
*/
|
||||
|
||||
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,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<void> {
|
||||
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<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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user