fix(subtitle-ws): send tokenized payloads to texthooker

This commit is contained in:
2026-02-19 17:21:26 -08:00
parent d5d71816ac
commit 7795cc3d69
5 changed files with 376 additions and 179 deletions

View File

@@ -7,7 +7,7 @@ function flushMicrotasks(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0)); return new Promise((resolve) => setTimeout(resolve, 0));
} }
test('subtitle processing emits plain subtitle immediately before tokenized payload', async () => { test('subtitle processing emits tokenized payload when tokenization succeeds', async () => {
const emitted: SubtitleData[] = []; const emitted: SubtitleData[] = [];
const controller = createSubtitleProcessingController({ const controller = createSubtitleProcessingController({
tokenizeSubtitle: async (text) => ({ text, tokens: [] }), tokenizeSubtitle: async (text) => ({ text, tokens: [] }),
@@ -15,13 +15,11 @@ test('subtitle processing emits plain subtitle immediately before tokenized payl
}); });
controller.onSubtitleChange('字幕'); controller.onSubtitleChange('字幕');
assert.deepEqual(emitted[0], { text: '字幕', tokens: null });
await flushMicrotasks(); await flushMicrotasks();
assert.deepEqual(emitted[1], { text: '字幕', tokens: [] }); assert.deepEqual(emitted, [{ text: '字幕', tokens: [] }]);
}); });
test('subtitle processing drops stale tokenization and delivers latest subtitle only', async () => { test('subtitle processing drops stale tokenization and delivers latest subtitle only once', async () => {
const emitted: SubtitleData[] = []; const emitted: SubtitleData[] = [];
let firstResolve: ((value: SubtitleData | null) => void) | undefined; let firstResolve: ((value: SubtitleData | null) => void) | undefined;
const controller = createSubtitleProcessingController({ const controller = createSubtitleProcessingController({
@@ -43,14 +41,10 @@ test('subtitle processing drops stale tokenization and delivers latest subtitle
await flushMicrotasks(); await flushMicrotasks();
await flushMicrotasks(); await flushMicrotasks();
assert.deepEqual(emitted, [ assert.deepEqual(emitted, [{ text: 'second', tokens: [] }]);
{ text: 'first', tokens: null },
{ text: 'second', tokens: null },
{ text: 'second', tokens: [] },
]);
}); });
test('subtitle processing skips duplicate plain subtitle emission', async () => { test('subtitle processing skips duplicate subtitle emission', async () => {
const emitted: SubtitleData[] = []; const emitted: SubtitleData[] = [];
let tokenizeCalls = 0; let tokenizeCalls = 0;
const controller = createSubtitleProcessingController({ const controller = createSubtitleProcessingController({
@@ -66,7 +60,19 @@ test('subtitle processing skips duplicate plain subtitle emission', async () =>
controller.onSubtitleChange('same'); controller.onSubtitleChange('same');
await flushMicrotasks(); await flushMicrotasks();
const plainEmits = emitted.filter((entry) => entry.tokens === null); assert.equal(emitted.length, 1);
assert.equal(plainEmits.length, 1);
assert.equal(tokenizeCalls, 1); assert.equal(tokenizeCalls, 1);
}); });
test('subtitle processing falls back to plain subtitle when tokenization returns null', async () => {
const emitted: SubtitleData[] = [];
const controller = createSubtitleProcessingController({
tokenizeSubtitle: async () => null,
emitSubtitle: (payload) => emitted.push(payload),
});
controller.onSubtitleChange('fallback');
await flushMicrotasks();
assert.deepEqual(emitted, [{ text: 'fallback', tokens: null }]);
});

View File

@@ -15,19 +15,11 @@ export function createSubtitleProcessingController(
deps: SubtitleProcessingControllerDeps, deps: SubtitleProcessingControllerDeps,
): SubtitleProcessingController { ): SubtitleProcessingController {
let latestText = ''; let latestText = '';
let lastPlainText = ''; let lastEmittedText = '';
let processing = false; let processing = false;
let staleDropCount = 0; let staleDropCount = 0;
const now = deps.now ?? (() => Date.now()); 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 => { const processLatest = (): void => {
if (processing) { if (processing) {
return; return;
@@ -38,14 +30,20 @@ export function createSubtitleProcessingController(
void (async () => { void (async () => {
while (true) { while (true) {
const text = latestText; const text = latestText;
const startedAtMs = now();
if (!text.trim()) { if (!text.trim()) {
deps.emitSubtitle({ text, tokens: null });
lastEmittedText = text;
break; break;
} }
const startedAtMs = now(); let output: SubtitleData = { text, tokens: null };
let tokenized: SubtitleData | null = null;
try { try {
tokenized = await deps.tokenizeSubtitle(text); const tokenized = await deps.tokenizeSubtitle(text);
if (tokenized) {
output = tokenized;
}
} catch (error) { } catch (error) {
deps.logDebug?.(`Subtitle tokenization failed: ${(error as Error).message}`); deps.logDebug?.(`Subtitle tokenization failed: ${(error as Error).message}`);
} }
@@ -58,12 +56,11 @@ export function createSubtitleProcessingController(
continue; continue;
} }
if (tokenized) { deps.emitSubtitle(output);
deps.emitSubtitle(tokenized); lastEmittedText = text;
deps.logDebug?.( deps.logDebug?.(
`Subtitle tokenization delivered; elapsed=${now() - startedAtMs}ms, staleDrops=${staleDropCount}`, `Subtitle tokenization delivered; elapsed=${now() - startedAtMs}ms, staleDrops=${staleDropCount}`,
); );
}
break; break;
} }
})() })()
@@ -72,7 +69,7 @@ export function createSubtitleProcessingController(
}) })
.finally(() => { .finally(() => {
processing = false; processing = false;
if (latestText !== lastPlainText) { if (latestText !== lastEmittedText) {
processLatest(); processLatest();
} }
}); });
@@ -83,13 +80,7 @@ export function createSubtitleProcessingController(
if (text === latestText) { if (text === latestText) {
return; return;
} }
const plainStartedAtMs = now();
latestText = text; latestText = text;
emitPlainSubtitle(text);
deps.logDebug?.(`Subtitle plain emit completed in ${now() - plainStartedAtMs}ms`);
if (!text.trim()) {
return;
}
processLatest(); processLatest();
}, },
}; };

View File

@@ -0,0 +1,89 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { serializeSubtitleMarkup, serializeSubtitleWebsocketMessage } from './subtitle-ws';
import { PartOfSpeech, type SubtitleData } from '../../types';
const frequencyOptions = {
enabled: true,
topX: 1000,
mode: 'banded' as const,
};
test('serializeSubtitleMarkup escapes plain text and preserves line breaks', () => {
const payload: SubtitleData = {
text: 'a < b\nx & y',
tokens: null,
};
assert.equal(serializeSubtitleMarkup(payload, frequencyOptions), 'a &lt; b<br>x &amp; y');
});
test('serializeSubtitleMarkup includes known, n+1, jlpt, and frequency classes', () => {
const payload: SubtitleData = {
text: 'ignored',
tokens: [
{
surface: '既知',
reading: '',
headword: '',
startPos: 0,
endPos: 2,
partOfSpeech: PartOfSpeech.other,
isMerged: false,
isKnown: true,
isNPlusOneTarget: false,
},
{
surface: '新語',
reading: '',
headword: '',
startPos: 2,
endPos: 4,
partOfSpeech: PartOfSpeech.other,
isMerged: false,
isKnown: false,
isNPlusOneTarget: true,
},
{
surface: '級',
reading: '',
headword: '',
startPos: 4,
endPos: 5,
partOfSpeech: PartOfSpeech.other,
isMerged: false,
isKnown: false,
isNPlusOneTarget: false,
jlptLevel: 'N3',
},
{
surface: '頻度',
reading: '',
headword: '',
startPos: 5,
endPos: 7,
partOfSpeech: PartOfSpeech.other,
isMerged: false,
isKnown: false,
isNPlusOneTarget: false,
frequencyRank: 10,
},
],
};
const markup = serializeSubtitleMarkup(payload, frequencyOptions);
assert.match(markup, /word word-known/);
assert.match(markup, /word word-n-plus-one/);
assert.match(markup, /word word-jlpt-n3/);
assert.match(markup, /word word-frequency-band-1/);
});
test('serializeSubtitleWebsocketMessage emits sentence payload', () => {
const payload: SubtitleData = {
text: '字幕',
tokens: null,
};
const raw = serializeSubtitleWebsocketMessage(payload, frequencyOptions);
assert.deepEqual(JSON.parse(raw), { sentence: '字幕' });
});

View File

@@ -3,6 +3,7 @@ import * as os from 'os';
import * as path from 'path'; import * as path from 'path';
import WebSocket from 'ws'; import WebSocket from 'ws';
import { createLogger } from '../../logger'; import { createLogger } from '../../logger';
import type { MergedToken, SubtitleData } from '../../types';
const logger = createLogger('main:subtitle-ws'); const logger = createLogger('main:subtitle-ws');
@@ -11,18 +12,117 @@ export function hasMpvWebsocketPlugin(): boolean {
return fs.existsSync(mpvWebsocketPath); return fs.existsSync(mpvWebsocketPath);
} }
export type SubtitleWebsocketFrequencyOptions = {
enabled: boolean;
topX: number;
mode: 'single' | 'banded';
};
function escapeHtml(text: string): string {
return text
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function computeFrequencyClass(
token: MergedToken,
options: SubtitleWebsocketFrequencyOptions,
): string | null {
if (!options.enabled) return null;
if (typeof token.frequencyRank !== 'number' || !Number.isFinite(token.frequencyRank)) return null;
const rank = Math.max(1, Math.floor(token.frequencyRank));
const topX = Math.max(1, Math.floor(options.topX));
if (rank > topX) return null;
if (options.mode === 'banded') {
const band = Math.min(5, Math.max(1, Math.ceil((rank / topX) * 5)));
return `word-frequency-band-${band}`;
}
return 'word-frequency-single';
}
function computeWordClass(token: MergedToken, options: SubtitleWebsocketFrequencyOptions): string {
const classes = ['word'];
if (token.isNPlusOneTarget) {
classes.push('word-n-plus-one');
} else if (token.isKnown) {
classes.push('word-known');
}
if (token.jlptLevel) {
classes.push(`word-jlpt-${token.jlptLevel.toLowerCase()}`);
}
if (!token.isKnown && !token.isNPlusOneTarget) {
const frequencyClass = computeFrequencyClass(token, options);
if (frequencyClass) {
classes.push(frequencyClass);
}
}
return classes.join(' ');
}
export function serializeSubtitleMarkup(
payload: SubtitleData,
options: SubtitleWebsocketFrequencyOptions,
): string {
if (!payload.tokens || payload.tokens.length === 0) {
return escapeHtml(payload.text).replaceAll('\n', '<br>');
}
const chunks: string[] = [];
for (const token of payload.tokens) {
const klass = computeWordClass(token, options);
const parts = token.surface.split('\n');
for (let index = 0; index < parts.length; index += 1) {
if (parts[index]) {
chunks.push(`<span class="${klass}">${escapeHtml(parts[index])}</span>`);
}
if (index < parts.length - 1) {
chunks.push('<br>');
}
}
}
return chunks.join('');
}
export function serializeSubtitleWebsocketMessage(
payload: SubtitleData,
options: SubtitleWebsocketFrequencyOptions,
): string {
return JSON.stringify({ sentence: serializeSubtitleMarkup(payload, options) });
}
export class SubtitleWebSocket { export class SubtitleWebSocket {
private server: WebSocket.Server | null = null; private server: WebSocket.Server | null = null;
private latestMessage = '';
public isRunning(): boolean { public isRunning(): boolean {
return this.server !== null; return this.server !== null;
} }
public hasClients(): boolean {
return (this.server?.clients.size ?? 0) > 0;
}
public start(port: number, getCurrentSubtitleText: () => string): void { public start(port: number, getCurrentSubtitleText: () => string): void {
this.server = new WebSocket.Server({ port, host: '127.0.0.1' }); this.server = new WebSocket.Server({ port, host: '127.0.0.1' });
this.server.on('connection', (ws: WebSocket) => { this.server.on('connection', (ws: WebSocket) => {
logger.info('WebSocket client connected'); logger.info('WebSocket client connected');
if (this.latestMessage) {
ws.send(this.latestMessage);
return;
}
const currentText = getCurrentSubtitleText(); const currentText = getCurrentSubtitleText();
if (currentText) { if (currentText) {
ws.send(JSON.stringify({ sentence: currentText })); ws.send(JSON.stringify({ sentence: currentText }));
@@ -36,9 +136,10 @@ export class SubtitleWebSocket {
logger.info(`Subtitle WebSocket server running on ws://127.0.0.1:${port}`); logger.info(`Subtitle WebSocket server running on ws://127.0.0.1:${port}`);
} }
public broadcast(text: string): void { public broadcast(payload: SubtitleData, options: SubtitleWebsocketFrequencyOptions): void {
if (!this.server) return; if (!this.server) return;
const message = JSON.stringify({ sentence: text }); const message = serializeSubtitleWebsocketMessage(payload, options);
this.latestMessage = message;
for (const client of this.server.clients) { for (const client of this.server.clients) {
if (client.readyState === WebSocket.OPEN) { if (client.readyState === WebSocket.OPEN) {
client.send(message); client.send(message);
@@ -51,5 +152,6 @@ export class SubtitleWebSocket {
this.server.close(); this.server.close();
this.server = null; this.server = null;
} }
this.latestMessage = '';
} }
} }

View File

@@ -158,6 +158,18 @@ import {
} from './main/runtime/jellyfin-remote-session-lifecycle'; } from './main/runtime/jellyfin-remote-session-lifecycle';
import { createHandleInitialArgsHandler } from './main/runtime/initial-args-handler'; import { createHandleInitialArgsHandler } from './main/runtime/initial-args-handler';
import { createHandleTexthookerOnlyModeTransitionHandler } from './main/runtime/cli-command-prechecks'; import { createHandleTexthookerOnlyModeTransitionHandler } from './main/runtime/cli-command-prechecks';
import { createCliCommandContext } from './main/runtime/cli-command-context';
import {
createBindMpvClientEventHandlers,
createHandleMpvConnectionChangeHandler,
createHandleMpvSubtitleTimingHandler,
} from './main/runtime/mpv-client-event-bindings';
import { createMpvClientRuntimeServiceFactory } from './main/runtime/mpv-client-runtime-service';
import { createUpdateMpvSubtitleRenderMetricsHandler } from './main/runtime/mpv-subtitle-render-metrics';
import {
createLaunchBackgroundWarmupTaskHandler,
createStartBackgroundWarmupsHandler,
} from './main/runtime/startup-warmups';
import { import {
buildRestartRequiredConfigMessage, buildRestartRequiredConfigMessage,
createConfigHotReloadAppliedHandler, createConfigHotReloadAppliedHandler,
@@ -460,13 +472,18 @@ const subsyncRuntime = createMainSubsyncRuntime({
let appTray: Tray | null = null; let appTray: Tray | null = null;
const subtitleProcessingController = createSubtitleProcessingController({ const subtitleProcessingController = createSubtitleProcessingController({
tokenizeSubtitle: async (text: string) => { tokenizeSubtitle: async (text: string) => {
if (getOverlayWindows().length === 0) { if (getOverlayWindows().length === 0 && !subtitleWsService.hasClients()) {
return null; return null;
} }
return await tokenizeSubtitle(text); return await tokenizeSubtitle(text);
}, },
emitSubtitle: (payload) => { emitSubtitle: (payload) => {
broadcastToOverlayWindows('subtitle:set', payload); broadcastToOverlayWindows('subtitle:set', payload);
subtitleWsService.broadcast(payload, {
enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled,
topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX,
mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode,
});
}, },
logDebug: (message) => { logDebug: (message) => {
logger.debug(`[subtitle-processing] ${message}`); logger.debug(`[subtitle-processing] ${message}`);
@@ -1871,12 +1888,12 @@ function handleCliCommand(args: CliArgs, source: CliCommandSource = 'initial'):
logInfo: (message) => logger.info(message), logInfo: (message) => logger.info(message),
})(args); })(args);
handleCliCommandRuntimeServiceWithContext(args, source, { const cliContext = createCliCommandContext({
getSocketPath: () => appState.mpvSocketPath, getSocketPath: () => appState.mpvSocketPath,
setSocketPath: (socketPath: string) => { setSocketPath: (socketPath: string) => {
appState.mpvSocketPath = socketPath; appState.mpvSocketPath = socketPath;
}, },
getClient: () => appState.mpvClient, getMpvClient: () => appState.mpvClient,
showOsd: (text: string) => showMpvOsd(text), showOsd: (text: string) => showMpvOsd(text),
texthookerService, texthookerService,
getTexthookerPort: () => appState.texthookerPort, getTexthookerPort: () => appState.texthookerPort,
@@ -1884,11 +1901,9 @@ function handleCliCommand(args: CliArgs, source: CliCommandSource = 'initial'):
appState.texthookerPort = port; appState.texthookerPort = port;
}, },
shouldOpenBrowser: () => getResolvedConfig().texthooker?.openBrowser !== false, shouldOpenBrowser: () => getResolvedConfig().texthooker?.openBrowser !== false,
openInBrowser: (url: string) => { openExternal: (url: string) => shell.openExternal(url),
void shell.openExternal(url).catch((error) => { logBrowserOpenError: (url: string, error: unknown) =>
logger.error(`Failed to open browser for texthooker URL: ${url}`, error); logger.error(`Failed to open browser for texthooker URL: ${url}`, error),
});
},
isOverlayInitialized: () => appState.overlayRuntimeInitialized, isOverlayInitialized: () => appState.overlayRuntimeInitialized,
initializeOverlay: () => initializeOverlayRuntime(), initializeOverlay: () => initializeOverlayRuntime(),
toggleVisibleOverlay: () => toggleVisibleOverlay(), toggleVisibleOverlay: () => toggleVisibleOverlay(),
@@ -1920,16 +1935,11 @@ function handleCliCommand(args: CliArgs, source: CliCommandSource = 'initial'):
hasMainWindow: () => Boolean(overlayManager.getMainWindow()), hasMainWindow: () => Boolean(overlayManager.getMainWindow()),
getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs, getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs,
schedule: (fn: () => void, delayMs: number) => setTimeout(fn, delayMs), schedule: (fn: () => void, delayMs: number) => setTimeout(fn, delayMs),
log: (message: string) => { logInfo: (message: string) => logger.info(message),
logger.info(message); logWarn: (message: string) => logger.warn(message),
}, logError: (message: string, err: unknown) => logger.error(message, err),
warn: (message: string) => {
logger.warn(message);
},
error: (message: string, err: unknown) => {
logger.error(message, err);
},
}); });
handleCliCommandRuntimeServiceWithContext(args, source, cliContext);
} }
function handleInitialArgs(): void { function handleInitialArgs(): void {
@@ -1946,104 +1956,119 @@ function handleInitialArgs(): void {
} }
function bindMpvClientEventHandlers(mpvClient: MpvIpcClient): void { function bindMpvClientEventHandlers(mpvClient: MpvIpcClient): void {
mpvClient.on('connection-change', ({ connected }) => { const handleMpvConnectionChange = createHandleMpvConnectionChangeHandler({
if (connected) return; reportJellyfinRemoteStopped: () => {
void reportJellyfinRemoteStopped();
if (!appState.initialArgs?.jellyfinPlay) return;
if (appState.overlayRuntimeInitialized) return;
if (!jellyfinPlayQuitOnDisconnectArmed) return;
setTimeout(() => {
if (appState.mpvClient?.connected) return;
app.quit();
}, 500);
});
mpvClient.on('subtitle-change', ({ text }) => {
appState.currentSubText = text;
subtitleWsService.broadcast(text);
subtitleProcessingController.onSubtitleChange(text);
});
mpvClient.on('subtitle-ass-change', ({ text }) => {
appState.currentSubAssText = text;
broadcastToOverlayWindows('subtitle-ass:set', text);
});
mpvClient.on('secondary-subtitle-change', ({ text }) => {
broadcastToOverlayWindows('secondary-subtitle:set', text);
});
mpvClient.on('subtitle-timing', ({ text, start, end }) => {
if (!text.trim()) {
return;
}
appState.immersionTracker?.recordSubtitleLine(text, start, end);
if (!appState.subtitleTimingTracker) {
return;
}
appState.subtitleTimingTracker.recordSubtitle(text, start, end);
void maybeRunAnilistPostWatchUpdate().catch((error) => {
logger.error('AniList post-watch update failed unexpectedly', error);
});
});
mpvClient.on('media-path-change', ({ path }) => {
mediaRuntime.updateCurrentMediaPath(path);
if (!path) {
void reportJellyfinRemoteStopped(); void reportJellyfinRemoteStopped();
} },
const mediaKey = getCurrentAnilistMediaKey(); hasInitialJellyfinPlayArg: () => Boolean(appState.initialArgs?.jellyfinPlay),
resetAnilistMediaTracking(mediaKey); isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
if (mediaKey) { isQuitOnDisconnectArmed: () => jellyfinPlayQuitOnDisconnectArmed,
void maybeProbeAnilistDuration(mediaKey); scheduleQuitCheck: (callback) => {
void ensureAnilistMediaGuess(mediaKey); setTimeout(callback, 500);
} },
immersionMediaRuntime.syncFromCurrentMediaState(); isMpvConnected: () => Boolean(appState.mpvClient?.connected),
quitApp: () => app.quit(),
}); });
mpvClient.on('media-title-change', ({ title }) => { const handleMpvSubtitleTiming = createHandleMpvSubtitleTimingHandler({
mediaRuntime.updateCurrentMediaTitle(title); recordImmersionSubtitleLine: (text, start, end) => {
anilistCurrentMediaGuess = null; appState.immersionTracker?.recordSubtitleLine(text, start, end);
anilistCurrentMediaGuessPromise = null; },
appState.immersionTracker?.handleMediaTitleUpdate(title); hasSubtitleTimingTracker: () => Boolean(appState.subtitleTimingTracker),
immersionMediaRuntime.syncFromCurrentMediaState(); recordSubtitleTiming: (text, start, end) => {
}); appState.subtitleTimingTracker?.recordSubtitle(text, start, end);
mpvClient.on('time-pos-change', ({ time }) => { },
appState.immersionTracker?.recordPlaybackPosition(time); maybeRunAnilistPostWatchUpdate: () => maybeRunAnilistPostWatchUpdate(),
void reportJellyfinRemoteProgress(false); logError: (message, error) => logger.error(message, error),
});
mpvClient.on('pause-change', ({ paused }) => {
appState.immersionTracker?.recordPauseState(paused);
void reportJellyfinRemoteProgress(true);
});
mpvClient.on('subtitle-metrics-change', ({ patch }) => {
updateMpvSubtitleRenderMetrics(patch);
});
mpvClient.on('secondary-subtitle-visibility', ({ visible }) => {
appState.previousSecondarySubVisibility = visible;
}); });
createBindMpvClientEventHandlers({
onConnectionChange: (payload) => {
handleMpvConnectionChange(payload);
},
onSubtitleChange: ({ text }) => {
appState.currentSubText = text;
broadcastToOverlayWindows('subtitle:set', { text, tokens: null });
subtitleProcessingController.onSubtitleChange(text);
},
onSubtitleAssChange: ({ text }) => {
appState.currentSubAssText = text;
broadcastToOverlayWindows('subtitle-ass:set', text);
},
onSecondarySubtitleChange: ({ text }) => {
broadcastToOverlayWindows('secondary-subtitle:set', text);
},
onSubtitleTiming: (payload) => {
handleMpvSubtitleTiming(payload);
},
onMediaPathChange: ({ path }) => {
mediaRuntime.updateCurrentMediaPath(path);
if (!path) {
void reportJellyfinRemoteStopped();
}
const mediaKey = getCurrentAnilistMediaKey();
resetAnilistMediaTracking(mediaKey);
if (mediaKey) {
void maybeProbeAnilistDuration(mediaKey);
void ensureAnilistMediaGuess(mediaKey);
}
immersionMediaRuntime.syncFromCurrentMediaState();
},
onMediaTitleChange: ({ title }) => {
mediaRuntime.updateCurrentMediaTitle(title);
anilistCurrentMediaGuess = null;
anilistCurrentMediaGuessPromise = null;
appState.immersionTracker?.handleMediaTitleUpdate(title);
immersionMediaRuntime.syncFromCurrentMediaState();
},
onTimePosChange: ({ time }) => {
appState.immersionTracker?.recordPlaybackPosition(time);
void reportJellyfinRemoteProgress(false);
},
onPauseChange: ({ paused }) => {
appState.immersionTracker?.recordPauseState(paused);
void reportJellyfinRemoteProgress(true);
},
onSubtitleMetricsChange: ({ patch }) => {
updateMpvSubtitleRenderMetrics(patch as Partial<MpvSubtitleRenderMetrics>);
},
onSecondarySubtitleVisibility: ({ visible }) => {
appState.previousSecondarySubVisibility = visible;
},
})(mpvClient);
} }
function createMpvClientRuntimeService(): MpvIpcClient { function createMpvClientRuntimeService(): MpvIpcClient {
const mpvClient = new MpvIpcClient(appState.mpvSocketPath, { return createMpvClientRuntimeServiceFactory({
getResolvedConfig: () => getResolvedConfig(), createClient: MpvIpcClient,
autoStartOverlay: appState.autoStartOverlay, socketPath: appState.mpvSocketPath,
setOverlayVisible: (visible: boolean) => setOverlayVisible(visible), options: {
shouldBindVisibleOverlayToMpvSubVisibility: () => getResolvedConfig: () => getResolvedConfig(),
configDerivedRuntime.shouldBindVisibleOverlayToMpvSubVisibility(), autoStartOverlay: appState.autoStartOverlay,
isVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), setOverlayVisible: (visible: boolean) => setOverlayVisible(visible),
getReconnectTimer: () => appState.reconnectTimer, shouldBindVisibleOverlayToMpvSubVisibility: () =>
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => { configDerivedRuntime.shouldBindVisibleOverlayToMpvSubVisibility(),
appState.reconnectTimer = timer; isVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getReconnectTimer: () => appState.reconnectTimer,
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => {
appState.reconnectTimer = timer;
},
}, },
}); bindEventHandlers: (client) => bindMpvClientEventHandlers(client),
bindMpvClientEventHandlers(mpvClient); })();
mpvClient.connect();
return mpvClient;
} }
const updateMpvSubtitleRenderMetricsRuntime = createUpdateMpvSubtitleRenderMetricsHandler({
getCurrentMetrics: () => appState.mpvSubtitleRenderMetrics,
setCurrentMetrics: (metrics) => {
appState.mpvSubtitleRenderMetrics = metrics;
},
applyPatch: (current, patch) => applyMpvSubtitleRenderMetricsPatch(current, patch),
broadcastMetrics: (metrics) => {
broadcastToOverlayWindows('mpv-subtitle-render-metrics:set', metrics);
},
});
function updateMpvSubtitleRenderMetrics(patch: Partial<MpvSubtitleRenderMetrics>): void { function updateMpvSubtitleRenderMetrics(patch: Partial<MpvSubtitleRenderMetrics>): void {
const { next, changed } = applyMpvSubtitleRenderMetricsPatch( updateMpvSubtitleRenderMetricsRuntime(patch);
appState.mpvSubtitleRenderMetrics,
patch,
);
if (!changed) return;
appState.mpvSubtitleRenderMetrics = next;
broadcastToOverlayWindows('mpv-subtitle-render-metrics:set', appState.mpvSubtitleRenderMetrics);
} }
async function tokenizeSubtitle(text: string): Promise<SubtitleData> { async function tokenizeSubtitle(text: string): Promise<SubtitleData> {
@@ -2101,41 +2126,25 @@ async function prewarmSubtitleDictionaries(): Promise<void> {
]); ]);
} }
function launchBackgroundWarmupTask(label: string, task: () => Promise<void>): void { const launchBackgroundWarmupTask = createLaunchBackgroundWarmupTaskHandler({
const startedAtMs = Date.now(); now: () => Date.now(),
void task() logDebug: (message) => logger.debug(message),
.then(() => { logWarn: (message) => logger.warn(message),
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 { const startBackgroundWarmups = createStartBackgroundWarmupsHandler({
if (backgroundWarmupsStarted) { getStarted: () => backgroundWarmupsStarted,
return; setStarted: (started) => {
} backgroundWarmupsStarted = started;
if (appState.texthookerOnlyMode) { },
return; isTexthookerOnlyMode: () => appState.texthookerOnlyMode,
} launchTask: (label, task) => launchBackgroundWarmupTask(label, task),
createMecabTokenizerAndCheck: () => createMecabTokenizerAndCheck(),
backgroundWarmupsStarted = true; ensureYomitanExtensionLoaded: () => ensureYomitanExtensionLoaded().then(() => {}),
launchBackgroundWarmupTask('mecab', async () => { prewarmSubtitleDictionaries: () => prewarmSubtitleDictionaries(),
await createMecabTokenizerAndCheck(); shouldAutoConnectJellyfinRemote: () => getResolvedConfig().jellyfin.remoteControlAutoConnect,
}); startJellyfinRemoteSession: () => startJellyfinRemoteSession(),
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);