From 7795cc3d69d0204833a3932633b41436504fb951 Mon Sep 17 00:00:00 2001 From: sudacode Date: Thu, 19 Feb 2026 17:21:26 -0800 Subject: [PATCH] fix(subtitle-ws): send tokenized payloads to texthooker --- .../subtitle-processing-controller.test.ts | 32 +- .../subtitle-processing-controller.ts | 41 +-- src/core/services/subtitle-ws.test.ts | 89 ++++++ src/core/services/subtitle-ws.ts | 106 ++++++- src/main.ts | 287 +++++++++--------- 5 files changed, 376 insertions(+), 179 deletions(-) create mode 100644 src/core/services/subtitle-ws.test.ts diff --git a/src/core/services/subtitle-processing-controller.test.ts b/src/core/services/subtitle-processing-controller.test.ts index 84720b6..f12afeb 100644 --- a/src/core/services/subtitle-processing-controller.test.ts +++ b/src/core/services/subtitle-processing-controller.test.ts @@ -7,7 +7,7 @@ function flushMicrotasks(): Promise { 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 controller = createSubtitleProcessingController({ tokenizeSubtitle: async (text) => ({ text, tokens: [] }), @@ -15,13 +15,11 @@ test('subtitle processing emits plain subtitle immediately before tokenized payl }); controller.onSubtitleChange('字幕'); - assert.deepEqual(emitted[0], { text: '字幕', tokens: null }); - 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[] = []; let firstResolve: ((value: SubtitleData | null) => void) | undefined; const controller = createSubtitleProcessingController({ @@ -43,14 +41,10 @@ test('subtitle processing drops stale tokenization and delivers latest subtitle await flushMicrotasks(); await flushMicrotasks(); - assert.deepEqual(emitted, [ - { text: 'first', tokens: null }, - { text: 'second', tokens: null }, - { text: 'second', tokens: [] }, - ]); + assert.deepEqual(emitted, [{ text: 'second', tokens: [] }]); }); -test('subtitle processing skips duplicate plain subtitle emission', async () => { +test('subtitle processing skips duplicate subtitle emission', async () => { const emitted: SubtitleData[] = []; let tokenizeCalls = 0; const controller = createSubtitleProcessingController({ @@ -66,7 +60,19 @@ test('subtitle processing skips duplicate plain subtitle emission', async () => controller.onSubtitleChange('same'); await flushMicrotasks(); - const plainEmits = emitted.filter((entry) => entry.tokens === null); - assert.equal(plainEmits.length, 1); + assert.equal(emitted.length, 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 }]); +}); diff --git a/src/core/services/subtitle-processing-controller.ts b/src/core/services/subtitle-processing-controller.ts index 8d82929..5d74e0a 100644 --- a/src/core/services/subtitle-processing-controller.ts +++ b/src/core/services/subtitle-processing-controller.ts @@ -15,19 +15,11 @@ export function createSubtitleProcessingController( deps: SubtitleProcessingControllerDeps, ): SubtitleProcessingController { let latestText = ''; - let lastPlainText = ''; + let lastEmittedText = ''; 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; @@ -38,14 +30,20 @@ export function createSubtitleProcessingController( void (async () => { while (true) { const text = latestText; + const startedAtMs = now(); + if (!text.trim()) { + deps.emitSubtitle({ text, tokens: null }); + lastEmittedText = text; break; } - const startedAtMs = now(); - let tokenized: SubtitleData | null = null; + let output: SubtitleData = { text, tokens: null }; try { - tokenized = await deps.tokenizeSubtitle(text); + const tokenized = await deps.tokenizeSubtitle(text); + if (tokenized) { + output = tokenized; + } } catch (error) { deps.logDebug?.(`Subtitle tokenization failed: ${(error as Error).message}`); } @@ -58,12 +56,11 @@ export function createSubtitleProcessingController( continue; } - if (tokenized) { - deps.emitSubtitle(tokenized); - deps.logDebug?.( - `Subtitle tokenization delivered; elapsed=${now() - startedAtMs}ms, staleDrops=${staleDropCount}`, - ); - } + deps.emitSubtitle(output); + lastEmittedText = text; + deps.logDebug?.( + `Subtitle tokenization delivered; elapsed=${now() - startedAtMs}ms, staleDrops=${staleDropCount}`, + ); break; } })() @@ -72,7 +69,7 @@ export function createSubtitleProcessingController( }) .finally(() => { processing = false; - if (latestText !== lastPlainText) { + if (latestText !== lastEmittedText) { processLatest(); } }); @@ -83,13 +80,7 @@ export function createSubtitleProcessingController( if (text === latestText) { return; } - const plainStartedAtMs = now(); latestText = text; - emitPlainSubtitle(text); - deps.logDebug?.(`Subtitle plain emit completed in ${now() - plainStartedAtMs}ms`); - if (!text.trim()) { - return; - } processLatest(); }, }; diff --git a/src/core/services/subtitle-ws.test.ts b/src/core/services/subtitle-ws.test.ts new file mode 100644 index 0000000..a4f6d2e --- /dev/null +++ b/src/core/services/subtitle-ws.test.ts @@ -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 < b
x & 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: '字幕' }); +}); diff --git a/src/core/services/subtitle-ws.ts b/src/core/services/subtitle-ws.ts index d047f07..869b59e 100644 --- a/src/core/services/subtitle-ws.ts +++ b/src/core/services/subtitle-ws.ts @@ -3,6 +3,7 @@ import * as os from 'os'; import * as path from 'path'; import WebSocket from 'ws'; import { createLogger } from '../../logger'; +import type { MergedToken, SubtitleData } from '../../types'; const logger = createLogger('main:subtitle-ws'); @@ -11,18 +12,117 @@ export function hasMpvWebsocketPlugin(): boolean { return fs.existsSync(mpvWebsocketPath); } +export type SubtitleWebsocketFrequencyOptions = { + enabled: boolean; + topX: number; + mode: 'single' | 'banded'; +}; + +function escapeHtml(text: string): string { + return text + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +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', '
'); + } + + 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(`${escapeHtml(parts[index])}`); + } + if (index < parts.length - 1) { + chunks.push('
'); + } + } + } + + return chunks.join(''); +} + +export function serializeSubtitleWebsocketMessage( + payload: SubtitleData, + options: SubtitleWebsocketFrequencyOptions, +): string { + return JSON.stringify({ sentence: serializeSubtitleMarkup(payload, options) }); +} + export class SubtitleWebSocket { private server: WebSocket.Server | null = null; + private latestMessage = ''; public isRunning(): boolean { return this.server !== null; } + public hasClients(): boolean { + return (this.server?.clients.size ?? 0) > 0; + } + public start(port: number, getCurrentSubtitleText: () => string): void { this.server = new WebSocket.Server({ port, host: '127.0.0.1' }); this.server.on('connection', (ws: WebSocket) => { logger.info('WebSocket client connected'); + if (this.latestMessage) { + ws.send(this.latestMessage); + return; + } + const currentText = getCurrentSubtitleText(); if (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}`); } - public broadcast(text: string): void { + public broadcast(payload: SubtitleData, options: SubtitleWebsocketFrequencyOptions): void { 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) { if (client.readyState === WebSocket.OPEN) { client.send(message); @@ -51,5 +152,6 @@ export class SubtitleWebSocket { this.server.close(); this.server = null; } + this.latestMessage = ''; } } diff --git a/src/main.ts b/src/main.ts index 043d239..6e18875 100644 --- a/src/main.ts +++ b/src/main.ts @@ -158,6 +158,18 @@ import { } from './main/runtime/jellyfin-remote-session-lifecycle'; import { createHandleInitialArgsHandler } from './main/runtime/initial-args-handler'; 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 { buildRestartRequiredConfigMessage, createConfigHotReloadAppliedHandler, @@ -460,13 +472,18 @@ const subsyncRuntime = createMainSubsyncRuntime({ let appTray: Tray | null = null; const subtitleProcessingController = createSubtitleProcessingController({ tokenizeSubtitle: async (text: string) => { - if (getOverlayWindows().length === 0) { + if (getOverlayWindows().length === 0 && !subtitleWsService.hasClients()) { return null; } return await tokenizeSubtitle(text); }, emitSubtitle: (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) => { logger.debug(`[subtitle-processing] ${message}`); @@ -1871,12 +1888,12 @@ function handleCliCommand(args: CliArgs, source: CliCommandSource = 'initial'): logInfo: (message) => logger.info(message), })(args); - handleCliCommandRuntimeServiceWithContext(args, source, { + const cliContext = createCliCommandContext({ getSocketPath: () => appState.mpvSocketPath, setSocketPath: (socketPath: string) => { appState.mpvSocketPath = socketPath; }, - getClient: () => appState.mpvClient, + getMpvClient: () => appState.mpvClient, showOsd: (text: string) => showMpvOsd(text), texthookerService, getTexthookerPort: () => appState.texthookerPort, @@ -1884,11 +1901,9 @@ function handleCliCommand(args: CliArgs, source: CliCommandSource = 'initial'): appState.texthookerPort = port; }, shouldOpenBrowser: () => getResolvedConfig().texthooker?.openBrowser !== false, - openInBrowser: (url: string) => { - void shell.openExternal(url).catch((error) => { - logger.error(`Failed to open browser for texthooker URL: ${url}`, error); - }); - }, + openExternal: (url: string) => shell.openExternal(url), + logBrowserOpenError: (url: string, error: unknown) => + logger.error(`Failed to open browser for texthooker URL: ${url}`, error), isOverlayInitialized: () => appState.overlayRuntimeInitialized, initializeOverlay: () => initializeOverlayRuntime(), toggleVisibleOverlay: () => toggleVisibleOverlay(), @@ -1920,16 +1935,11 @@ function handleCliCommand(args: CliArgs, source: CliCommandSource = 'initial'): hasMainWindow: () => Boolean(overlayManager.getMainWindow()), getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs, schedule: (fn: () => void, delayMs: number) => setTimeout(fn, delayMs), - log: (message: string) => { - logger.info(message); - }, - warn: (message: string) => { - logger.warn(message); - }, - error: (message: string, err: unknown) => { - logger.error(message, err); - }, + logInfo: (message: string) => logger.info(message), + logWarn: (message: string) => logger.warn(message), + logError: (message: string, err: unknown) => logger.error(message, err), }); + handleCliCommandRuntimeServiceWithContext(args, source, cliContext); } function handleInitialArgs(): void { @@ -1946,104 +1956,119 @@ function handleInitialArgs(): void { } function bindMpvClientEventHandlers(mpvClient: MpvIpcClient): void { - mpvClient.on('connection-change', ({ connected }) => { - if (connected) return; - 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) { + const handleMpvConnectionChange = createHandleMpvConnectionChangeHandler({ + reportJellyfinRemoteStopped: () => { void reportJellyfinRemoteStopped(); - } - const mediaKey = getCurrentAnilistMediaKey(); - resetAnilistMediaTracking(mediaKey); - if (mediaKey) { - void maybeProbeAnilistDuration(mediaKey); - void ensureAnilistMediaGuess(mediaKey); - } - immersionMediaRuntime.syncFromCurrentMediaState(); + }, + hasInitialJellyfinPlayArg: () => Boolean(appState.initialArgs?.jellyfinPlay), + isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, + isQuitOnDisconnectArmed: () => jellyfinPlayQuitOnDisconnectArmed, + scheduleQuitCheck: (callback) => { + setTimeout(callback, 500); + }, + isMpvConnected: () => Boolean(appState.mpvClient?.connected), + quitApp: () => app.quit(), }); - mpvClient.on('media-title-change', ({ title }) => { - mediaRuntime.updateCurrentMediaTitle(title); - anilistCurrentMediaGuess = null; - anilistCurrentMediaGuessPromise = null; - appState.immersionTracker?.handleMediaTitleUpdate(title); - immersionMediaRuntime.syncFromCurrentMediaState(); - }); - mpvClient.on('time-pos-change', ({ time }) => { - appState.immersionTracker?.recordPlaybackPosition(time); - void reportJellyfinRemoteProgress(false); - }); - 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; + const handleMpvSubtitleTiming = createHandleMpvSubtitleTimingHandler({ + recordImmersionSubtitleLine: (text, start, end) => { + appState.immersionTracker?.recordSubtitleLine(text, start, end); + }, + hasSubtitleTimingTracker: () => Boolean(appState.subtitleTimingTracker), + recordSubtitleTiming: (text, start, end) => { + appState.subtitleTimingTracker?.recordSubtitle(text, start, end); + }, + maybeRunAnilistPostWatchUpdate: () => maybeRunAnilistPostWatchUpdate(), + logError: (message, error) => logger.error(message, error), }); + 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); + }, + onSecondarySubtitleVisibility: ({ visible }) => { + appState.previousSecondarySubVisibility = visible; + }, + })(mpvClient); } function createMpvClientRuntimeService(): MpvIpcClient { - const mpvClient = new MpvIpcClient(appState.mpvSocketPath, { - getResolvedConfig: () => getResolvedConfig(), - autoStartOverlay: appState.autoStartOverlay, - setOverlayVisible: (visible: boolean) => setOverlayVisible(visible), - shouldBindVisibleOverlayToMpvSubVisibility: () => - configDerivedRuntime.shouldBindVisibleOverlayToMpvSubVisibility(), - isVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), - getReconnectTimer: () => appState.reconnectTimer, - setReconnectTimer: (timer: ReturnType | null) => { - appState.reconnectTimer = timer; + return createMpvClientRuntimeServiceFactory({ + createClient: MpvIpcClient, + socketPath: appState.mpvSocketPath, + options: { + getResolvedConfig: () => getResolvedConfig(), + autoStartOverlay: appState.autoStartOverlay, + setOverlayVisible: (visible: boolean) => setOverlayVisible(visible), + shouldBindVisibleOverlayToMpvSubVisibility: () => + configDerivedRuntime.shouldBindVisibleOverlayToMpvSubVisibility(), + isVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + getReconnectTimer: () => appState.reconnectTimer, + setReconnectTimer: (timer: ReturnType | null) => { + appState.reconnectTimer = timer; + }, }, - }); - bindMpvClientEventHandlers(mpvClient); - mpvClient.connect(); - return mpvClient; + bindEventHandlers: (client) => bindMpvClientEventHandlers(client), + })(); } +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): void { - const { next, changed } = applyMpvSubtitleRenderMetricsPatch( - appState.mpvSubtitleRenderMetrics, - patch, - ); - if (!changed) return; - appState.mpvSubtitleRenderMetrics = next; - broadcastToOverlayWindows('mpv-subtitle-render-metrics:set', appState.mpvSubtitleRenderMetrics); + updateMpvSubtitleRenderMetricsRuntime(patch); } async function tokenizeSubtitle(text: string): Promise { @@ -2101,41 +2126,25 @@ async function prewarmSubtitleDictionaries(): Promise { ]); } -function launchBackgroundWarmupTask(label: string, task: () => Promise): void { - const startedAtMs = Date.now(); - void task() - .then(() => { - logger.debug(`[startup-warmup] ${label} completed in ${Date.now() - startedAtMs}ms`); - }) - .catch((error) => { - logger.warn(`[startup-warmup] ${label} failed: ${(error as Error).message}`); - }); -} +const launchBackgroundWarmupTask = createLaunchBackgroundWarmupTaskHandler({ + now: () => Date.now(), + logDebug: (message) => logger.debug(message), + logWarn: (message) => logger.warn(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(); - }); - } -} +const startBackgroundWarmups = createStartBackgroundWarmupsHandler({ + getStarted: () => backgroundWarmupsStarted, + setStarted: (started) => { + backgroundWarmupsStarted = started; + }, + isTexthookerOnlyMode: () => appState.texthookerOnlyMode, + launchTask: (label, task) => launchBackgroundWarmupTask(label, task), + createMecabTokenizerAndCheck: () => createMecabTokenizerAndCheck(), + ensureYomitanExtensionLoaded: () => ensureYomitanExtensionLoaded().then(() => {}), + prewarmSubtitleDictionaries: () => prewarmSubtitleDictionaries(), + shouldAutoConnectJellyfinRemote: () => getResolvedConfig().jellyfin.remoteControlAutoConnect, + startJellyfinRemoteSession: () => startJellyfinRemoteSession(), +}); function updateVisibleOverlayBounds(geometry: WindowGeometry): void { overlayManager.setOverlayWindowBounds('visible', geometry);