From 8f362063dd0915a875c972351f33421a21026245 Mon Sep 17 00:00:00 2001 From: sudacode Date: Thu, 11 Jun 2026 23:39:56 -0700 Subject: [PATCH] refactor(main): extract autoplay subtitle priming runtime from main.ts --- src/main.ts | 250 ++++------------- src/main/main-wiring.test.ts | 12 +- .../autoplay-subtitle-priming-runtime.ts | 257 ++++++++++++++++++ 3 files changed, 316 insertions(+), 203 deletions(-) create mode 100644 src/main/runtime/autoplay-subtitle-priming-runtime.ts diff --git a/src/main.ts b/src/main.ts index af79f94e..8df9695a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -103,7 +103,6 @@ import type { RuntimeOptionState, SessionActionDispatchRequest, SecondarySubMode, - SubtitleCue, SubtitleData, SubtitleMiningContext, SubtitlePosition, @@ -378,7 +377,7 @@ import { createYoutubePrimarySubtitleNotificationRuntime, } from './main/runtime/youtube-primary-subtitle-notification'; import { createAutoplayReadyGate } from './main/runtime/autoplay-ready-gate'; -import { selectAutoplayStartupCue } from './main/runtime/autoplay-subtitle-primer'; +import { createAutoplaySubtitlePrimingRuntime } from './main/runtime/autoplay-subtitle-priming-runtime'; import { createAutoplayTokenizationWarmRelease } from './main/runtime/autoplay-tokenization-warm-release'; import { isVisibleOverlayAutoplayTargetReady } from './main/runtime/visible-overlay-autoplay-readiness'; import { createManagedLocalSubtitleSelectionRuntime } from './main/runtime/local-subtitle-selection'; @@ -519,10 +518,7 @@ import { handleCharacterDictionaryAutoSyncComplete } from './main/runtime/charac import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications'; import { openCharacterDictionaryManagerWithConfigGate } from './main/runtime/character-dictionary-manager-gate'; import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate'; -import { - primeVisibleOverlaySubtitleFromMpv, - resolveCurrentSubtitleForRenderer, -} from './main/runtime/current-subtitle-snapshot'; +import { resolveCurrentSubtitleForRenderer } from './main/runtime/current-subtitle-snapshot'; import { restoreLinuxOverlayWindowShape } from './main/runtime/linux-overlay-window-shape'; import { createOverlayGeometryRuntime } from './main/runtime/overlay-geometry-runtime'; import { createJellyfinSubtitleCacheIo } from './main/runtime/jellyfin-subtitle-cache-io'; @@ -595,10 +591,7 @@ import { createSubtitlePrefetchService, type SubtitlePrefetchService, } from './core/services/subtitle-prefetch'; -import { - buildSubtitleSidebarSourceKey, - resolveSubtitleSourcePath, -} from './main/runtime/subtitle-prefetch-source'; +import { buildSubtitleSidebarSourceKey } from './main/runtime/subtitle-prefetch-source'; import { createSubtitlePrefetchInitController } from './main/runtime/subtitle-prefetch-init'; import { loadSubtitleSourceText, @@ -1773,7 +1766,6 @@ const subtitleProcessingController = createSubtitleProcessingController( ); let subtitlePrefetchService: SubtitlePrefetchService | null = null; -let subtitlePrefetchRefreshTimer: ReturnType | null = null; let lastObservedTimePos = 0; let lastObservedPrimarySubtitleTrackId: number | null = null; let cancelLinuxMpvFullscreenOverlayRefreshBurst: CancelLinuxMpvFullscreenOverlayRefreshBurst | null = @@ -1786,166 +1778,48 @@ const linuxVisibleOverlayOwnerBindingQueues = new WeakMap | null = null; -const VISIBLE_OVERLAY_SUBTITLE_REFRESH_AFTER_FIRST_PAINT_DELAY_MS = 100; -function getCurrentAutoplayMediaPath(): string | null { - return appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null; -} +const autoplaySubtitlePrimingRuntime = createAutoplaySubtitlePrimingRuntime({ + getCurrentMediaPath: () => appState.currentMediaPath, + getMpvClient: () => appState.mpvClient, + setCurrentSubText: (text) => { + appState.currentSubText = text; + }, + getCurrentSubText: () => appState.currentSubText, + getCurrentSubtitleData: () => appState.currentSubtitleData, + setActiveParsedSubtitleMediaPath: (mediaPath) => { + appState.activeParsedSubtitleMediaPath = mediaPath; + }, + subtitleProcessingController, + emitSubtitlePayload: (payload) => emitSubtitlePayload(payload), + getSubtitlePrefetchService: () => subtitlePrefetchService, + getLastObservedTimePos: () => lastObservedTimePos, + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + emitSecondarySubtitle: (text) => { + broadcastToOverlayWindows('secondary-subtitle:set', text); + }, + initSubtitlePrefetch: (sourcePath, currentTimePos, sourceKey) => + subtitlePrefetchInitController.initSubtitlePrefetch(sourcePath, currentTimePos, sourceKey), + refreshSubtitlePrefetchFromActiveTrack: () => refreshSubtitlePrefetchFromActiveTrackHandler(), + logDebug: (message) => { + logger.debug(message); + }, +}); -function isCurrentAutoplayMediaPath(mediaPath: string): boolean { - return getCurrentAutoplayMediaPath() === mediaPath; -} - -function markAutoplaySubtitlePrimeConsumed(mediaPath: string): boolean { - if (autoplaySubtitlePrimedMediaPath === mediaPath) { - return false; - } - autoplaySubtitlePrimedMediaPath = mediaPath; - return true; -} - -function emitAutoplayPrimedSubtitle(mediaPath: string, text: string): boolean { - if (!text.trim() || !isCurrentAutoplayMediaPath(mediaPath)) { - return false; - } - if (!markAutoplaySubtitlePrimeConsumed(mediaPath)) { - return false; - } - - appState.currentSubText = text; - subtitlePrefetchService?.pause(); - const cachedPayload = subtitleProcessingController.consumeCachedSubtitle(text); - if (cachedPayload) { - subtitleProcessingController.onSubtitleChange(text); - emitSubtitlePayload(cachedPayload); - return true; - } - - subtitleProcessingController.onSubtitleChange(text); - return true; -} - -async function primeCurrentSubtitleForAutoplay(mediaPath: string): Promise { - const client = appState.mpvClient; - if (!client?.connected || !isCurrentAutoplayMediaPath(mediaPath)) { - return; - } - - const subTextRaw = await client.requestProperty('sub-text').catch((error) => { - logger.debug( - `[autoplay-subtitle-prime] failed to read sub-text: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - return null; - }); - const text = typeof subTextRaw === 'string' ? subTextRaw : ''; - emitAutoplayPrimedSubtitle(mediaPath, text); -} - -async function primeCurrentSubtitleForVisibleOverlay(): Promise { - await primeVisibleOverlaySubtitleFromMpv({ - getMpvClient: () => appState.mpvClient, - setCurrentSubText: (text) => { - appState.currentSubText = text; - }, - getCurrentSubtitleData: () => appState.currentSubtitleData, - consumeCachedSubtitle: (text) => subtitleProcessingController.consumeCachedSubtitle(text), - onSubtitleChange: (text) => { - subtitlePrefetchService?.pause(); - subtitlePrefetchService?.onSeek(lastObservedTimePos); - subtitleProcessingController.onSubtitleChange(text); - }, - refreshCurrentSubtitle: (text) => { - subtitlePrefetchService?.pause(); - subtitlePrefetchService?.onSeek(lastObservedTimePos); - subtitleProcessingController.refreshCurrentSubtitle(text); - }, - deferUncachedRefresh: true, - emitSubtitle: (payload) => emitSubtitlePayload(payload), - setCurrentSecondarySubText: (text) => { - if (appState.mpvClient) { - appState.mpvClient.currentSecondarySubText = text; - } - }, - emitSecondarySubtitle: (text) => { - broadcastToOverlayWindows('secondary-subtitle:set', text); - }, - logDebug: (message) => { - logger.debug(message); - }, - }); +function primeCurrentSubtitleForVisibleOverlay(): Promise { + return autoplaySubtitlePrimingRuntime.primeCurrentSubtitleForVisibleOverlay(); } function cancelVisibleOverlaySubtitleRefreshAfterFirstPaint(): void { - if (!visibleOverlaySubtitleRefreshAfterFirstPaintTimer) { - return; - } - clearTimeout(visibleOverlaySubtitleRefreshAfterFirstPaintTimer); - visibleOverlaySubtitleRefreshAfterFirstPaintTimer = null; + autoplaySubtitlePrimingRuntime.cancelVisibleOverlaySubtitleRefreshAfterFirstPaint(); } function scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint(): void { - if (visibleOverlaySubtitleRefreshAfterFirstPaintTimer) { - return; - } - if (!overlayManager.getVisibleOverlayVisible() || !appState.currentSubText.trim()) { - return; - } - - visibleOverlaySubtitleRefreshAfterFirstPaintTimer = setTimeout(() => { - visibleOverlaySubtitleRefreshAfterFirstPaintTimer = null; - if (!overlayManager.getVisibleOverlayVisible()) { - return; - } - const text = appState.currentSubText; - if (!text.trim()) { - return; - } - subtitlePrefetchService?.pause(); - subtitlePrefetchService?.onSeek(lastObservedTimePos); - subtitleProcessingController.refreshCurrentSubtitle(text); - }, VISIBLE_OVERLAY_SUBTITLE_REFRESH_AFTER_FIRST_PAINT_DELAY_MS); - visibleOverlaySubtitleRefreshAfterFirstPaintTimer.unref?.(); + autoplaySubtitlePrimingRuntime.scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint(); } -async function primeAutoplaySubtitleFromParsedCues( - mediaPath: string, - cues: SubtitleCue[], -): Promise { - if ( - cues.length === 0 || - autoplaySubtitlePrimedMediaPath === mediaPath || - !isCurrentAutoplayMediaPath(mediaPath) - ) { - return; - } - - const client = appState.mpvClient; - const timePosRaw = await client?.requestProperty('time-pos').catch(() => null); - const currentTimeSeconds = Number( - timePosRaw ?? client?.currentTimePos ?? lastObservedTimePos ?? 0, - ); - const cue = selectAutoplayStartupCue( - cues, - Number.isFinite(currentTimeSeconds) ? currentTimeSeconds : 0, - AUTOPLAY_SUBTITLE_PRIME_LOOKAHEAD_SECONDS, - ); - if (!cue) { - return; - } - - emitAutoplayPrimedSubtitle(mediaPath, cue.text); -} - -function clearScheduledSubtitlePrefetchRefresh(): void { - if (subtitlePrefetchRefreshTimer) { - clearTimeout(subtitlePrefetchRefreshTimer); - subtitlePrefetchRefreshTimer = null; - } +function scheduleSubtitlePrefetchRefresh(delayMs = 0): void { + autoplaySubtitlePrimingRuntime.scheduleSubtitlePrefetchRefresh(delayMs); } function cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(): void { @@ -1976,15 +1850,17 @@ const subtitlePrefetchInitController = createSubtitlePrefetchInitController({ if (!cues?.length) { appState.activeParsedSubtitleMediaPath = null; } - const mediaPath = getCurrentAutoplayMediaPath(); + const mediaPath = autoplaySubtitlePrimingRuntime.getCurrentAutoplayMediaPath(); if (mediaPath && cues?.length) { - void primeAutoplaySubtitleFromParsedCues(mediaPath, cues).catch((error) => { - logger.debug( - `[autoplay-subtitle-prime] failed to prime from parsed cues: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - }); + void autoplaySubtitlePrimingRuntime + .primeAutoplaySubtitleFromParsedCues(mediaPath, cues) + .catch((error) => { + logger.debug( + `[autoplay-subtitle-prime] failed to prime from parsed cues: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + }); } }, }); @@ -1994,22 +1870,6 @@ const resolveActiveSubtitleSidebarSourceHandler = createResolveActiveSubtitleSid extractInternalSubtitleTrackToTempFile(ffmpegPath, videoPath, track), }); -async function refreshSubtitleSidebarFromSource( - sourcePath: string, - mediaPath?: string, -): Promise { - const normalizedSourcePath = resolveSubtitleSourcePath(sourcePath.trim()); - if (!normalizedSourcePath) { - return; - } - const nextMediaPath = mediaPath?.trim() || getCurrentAutoplayMediaPath(); - await subtitlePrefetchInitController.initSubtitlePrefetch( - normalizedSourcePath, - lastObservedTimePos, - normalizedSourcePath, - ); - appState.activeParsedSubtitleMediaPath = nextMediaPath; -} const refreshSubtitlePrefetchFromActiveTrackHandler = createRefreshSubtitlePrefetchFromActiveTrackHandler({ getMpvClient: () => appState.mpvClient, @@ -2019,21 +1879,16 @@ const refreshSubtitlePrefetchFromActiveTrackHandler = resolveActiveSubtitleSidebarSource: (input) => resolveActiveSubtitleSidebarSourceHandler(input), }); -function scheduleSubtitlePrefetchRefresh(delayMs = 0): void { - clearScheduledSubtitlePrefetchRefresh(); - subtitlePrefetchRefreshTimer = setTimeout(() => { - subtitlePrefetchRefreshTimer = null; - void refreshSubtitlePrefetchFromActiveTrackHandler(); - }, delayMs); -} const subtitlePrefetchRuntime = { cancelPendingInit: () => subtitlePrefetchInitController.cancelPendingInit(), initSubtitlePrefetch: subtitlePrefetchInitController.initSubtitlePrefetch, refreshSubtitleSidebarFromSource: (sourcePath: string, mediaPath?: string) => - refreshSubtitleSidebarFromSource(sourcePath, mediaPath), + autoplaySubtitlePrimingRuntime.refreshSubtitleSidebarFromSource(sourcePath, mediaPath), refreshSubtitlePrefetchFromActiveTrack: () => refreshSubtitlePrefetchFromActiveTrackHandler(), - scheduleSubtitlePrefetchRefresh: (delayMs?: number) => scheduleSubtitlePrefetchRefresh(delayMs), - clearScheduledSubtitlePrefetchRefresh: () => clearScheduledSubtitlePrefetchRefresh(), + scheduleSubtitlePrefetchRefresh: (delayMs?: number) => + autoplaySubtitlePrimingRuntime.scheduleSubtitlePrefetchRefresh(delayMs), + clearScheduledSubtitlePrefetchRefresh: () => + autoplaySubtitlePrimingRuntime.clearScheduledSubtitlePrefetchRefresh(), } as const; const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService( @@ -4984,7 +4839,7 @@ const { topX: frequencyDictionary.topX, mode: frequencyDictionary.mode, }; - autoplaySubtitlePrimedMediaPath = null; + autoplaySubtitlePrimingRuntime.resetAutoplaySubtitlePrime(); lastObservedTimePos = 0; appState.currentSubText = ''; appState.currentSubAssText = ''; @@ -5300,7 +5155,8 @@ signalAutoplayReadyFromWarmTokenization = createAutoplayTokenizationWarmRelease( }, getCurrentMediaPath: () => appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null, - primeCurrentSubtitle: (mediaPath) => primeCurrentSubtitleForAutoplay(mediaPath), + primeCurrentSubtitle: (mediaPath) => + autoplaySubtitlePrimingRuntime.primeCurrentSubtitleForAutoplay(mediaPath), signalAutoplayReady: () => signalCurrentSubtitleAutoplayReady(), warn: (message, error) => logger.warn(message, error), }); diff --git a/src/main/main-wiring.test.ts b/src/main/main-wiring.test.ts index 3f7644b5..d3fdba1c 100644 --- a/src/main/main-wiring.test.ts +++ b/src/main/main-wiring.test.ts @@ -150,9 +150,9 @@ test('all visible overlay hide paths clear stale overlay input state', () => { }); test('subtitle sidebar media path tag is assigned after prefetch succeeds', () => { - const source = readMainSource(); + const source = readSource('src/main/runtime/autoplay-subtitle-priming-runtime.ts'); const actionBlock = source.match( - /async function refreshSubtitleSidebarFromSource\([\s\S]*?\): Promise \{(?[\s\S]*?)\n\}/, + /async function refreshSubtitleSidebarFromSource\([\s\S]*?\): Promise \{(?[\s\S]*?)\n \}/, )?.groups?.body; assert.ok(actionBlock); @@ -161,8 +161,8 @@ test('subtitle sidebar media path tag is assigned after prefetch succeeds', () = /const nextMediaPath = mediaPath\?\.trim\(\) \|\| getCurrentAutoplayMediaPath\(\);/, ); assert.ok( - actionBlock.indexOf('subtitlePrefetchInitController.initSubtitlePrefetch') < - actionBlock.indexOf('appState.activeParsedSubtitleMediaPath = nextMediaPath;'), + actionBlock.indexOf('deps.initSubtitlePrefetch(') < + actionBlock.indexOf('deps.setActiveParsedSubtitleMediaPath(nextMediaPath);'), ); }); @@ -211,9 +211,9 @@ test('subtitle change re-prioritizes prefetch around live playback before tokeni }); test('autoplay subtitle prime emits cached annotations and avoids raw fallback overlay flashes', () => { - const source = readMainSource(); + const source = readSource('src/main/runtime/autoplay-subtitle-priming-runtime.ts'); const actionBlock = source.match( - /function emitAutoplayPrimedSubtitle\([\s\S]*?\): boolean \{(?[\s\S]*?)\n\}/, + /function emitAutoplayPrimedSubtitle\([\s\S]*?\): boolean \{(?[\s\S]*?)\n \}/, )?.groups?.body; assert.ok(actionBlock); diff --git a/src/main/runtime/autoplay-subtitle-priming-runtime.ts b/src/main/runtime/autoplay-subtitle-priming-runtime.ts new file mode 100644 index 00000000..3a08c931 --- /dev/null +++ b/src/main/runtime/autoplay-subtitle-priming-runtime.ts @@ -0,0 +1,257 @@ +import type { SubtitleCue, SubtitleData } from '../../types'; +import { selectAutoplayStartupCue } from './autoplay-subtitle-primer'; +import { primeVisibleOverlaySubtitleFromMpv } from './current-subtitle-snapshot'; +import { resolveSubtitleSourcePath } from './subtitle-prefetch-source'; + +const AUTOPLAY_SUBTITLE_PRIME_LOOKAHEAD_SECONDS = 2; +const VISIBLE_OVERLAY_SUBTITLE_REFRESH_AFTER_FIRST_PAINT_DELAY_MS = 100; + +type AutoplaySubtitlePrimingMpvClient = { + connected?: boolean; + requestProperty: (name: string) => Promise; + currentVideoPath?: string; + currentTimePos?: number; + currentSecondarySubText?: string; +}; + +type AutoplaySubtitlePrimingPrefetchService = { + pause: () => void; + onSeek: (timePos: number) => void; +}; + +export interface AutoplaySubtitlePrimingRuntimeDeps { + getCurrentMediaPath: () => string | null | undefined; + getMpvClient: () => AutoplaySubtitlePrimingMpvClient | null; + setCurrentSubText: (text: string) => void; + getCurrentSubText: () => string; + getCurrentSubtitleData: () => SubtitleData | null; + setActiveParsedSubtitleMediaPath: (mediaPath: string | null) => void; + subtitleProcessingController: { + consumeCachedSubtitle: (text: string) => SubtitleData | null; + onSubtitleChange: (text: string) => void; + refreshCurrentSubtitle: (text: string) => void; + }; + emitSubtitlePayload: (payload: SubtitleData) => void; + getSubtitlePrefetchService: () => AutoplaySubtitlePrimingPrefetchService | null; + getLastObservedTimePos: () => number; + getVisibleOverlayVisible: () => boolean; + emitSecondarySubtitle: (text: string) => void; + initSubtitlePrefetch: ( + sourcePath: string, + currentTimePos: number, + sourceKey?: string, + ) => Promise; + refreshSubtitlePrefetchFromActiveTrack: () => Promise; + logDebug: (message: string) => void; +} + +export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimingRuntimeDeps) { + const { subtitleProcessingController, emitSubtitlePayload } = deps; + + let subtitlePrefetchRefreshTimer: ReturnType | null = null; + let autoplaySubtitlePrimedMediaPath: string | null = null; + let visibleOverlaySubtitleRefreshAfterFirstPaintTimer: ReturnType | null = + null; + + function getCurrentAutoplayMediaPath(): string | null { + return ( + deps.getCurrentMediaPath()?.trim() || deps.getMpvClient()?.currentVideoPath?.trim() || null + ); + } + + function isCurrentAutoplayMediaPath(mediaPath: string): boolean { + return getCurrentAutoplayMediaPath() === mediaPath; + } + + function markAutoplaySubtitlePrimeConsumed(mediaPath: string): boolean { + if (autoplaySubtitlePrimedMediaPath === mediaPath) { + return false; + } + autoplaySubtitlePrimedMediaPath = mediaPath; + return true; + } + + function resetAutoplaySubtitlePrime(): void { + autoplaySubtitlePrimedMediaPath = null; + } + + function emitAutoplayPrimedSubtitle(mediaPath: string, text: string): boolean { + if (!text.trim() || !isCurrentAutoplayMediaPath(mediaPath)) { + return false; + } + if (!markAutoplaySubtitlePrimeConsumed(mediaPath)) { + return false; + } + + deps.setCurrentSubText(text); + deps.getSubtitlePrefetchService()?.pause(); + const cachedPayload = subtitleProcessingController.consumeCachedSubtitle(text); + if (cachedPayload) { + subtitleProcessingController.onSubtitleChange(text); + emitSubtitlePayload(cachedPayload); + return true; + } + + subtitleProcessingController.onSubtitleChange(text); + return true; + } + + async function primeCurrentSubtitleForAutoplay(mediaPath: string): Promise { + const client = deps.getMpvClient(); + if (!client?.connected || !isCurrentAutoplayMediaPath(mediaPath)) { + return; + } + + const subTextRaw = await client.requestProperty('sub-text').catch((error) => { + deps.logDebug( + `[autoplay-subtitle-prime] failed to read sub-text: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return null; + }); + const text = typeof subTextRaw === 'string' ? subTextRaw : ''; + emitAutoplayPrimedSubtitle(mediaPath, text); + } + + async function primeCurrentSubtitleForVisibleOverlay(): Promise { + await primeVisibleOverlaySubtitleFromMpv({ + getMpvClient: () => deps.getMpvClient(), + setCurrentSubText: (text) => { + deps.setCurrentSubText(text); + }, + getCurrentSubtitleData: () => deps.getCurrentSubtitleData(), + consumeCachedSubtitle: (text) => subtitleProcessingController.consumeCachedSubtitle(text), + onSubtitleChange: (text) => { + deps.getSubtitlePrefetchService()?.pause(); + deps.getSubtitlePrefetchService()?.onSeek(deps.getLastObservedTimePos()); + subtitleProcessingController.onSubtitleChange(text); + }, + refreshCurrentSubtitle: (text) => { + deps.getSubtitlePrefetchService()?.pause(); + deps.getSubtitlePrefetchService()?.onSeek(deps.getLastObservedTimePos()); + subtitleProcessingController.refreshCurrentSubtitle(text); + }, + deferUncachedRefresh: true, + emitSubtitle: (payload) => emitSubtitlePayload(payload), + setCurrentSecondarySubText: (text) => { + const client = deps.getMpvClient(); + if (client) { + client.currentSecondarySubText = text; + } + }, + emitSecondarySubtitle: (text) => { + deps.emitSecondarySubtitle(text); + }, + logDebug: (message) => { + deps.logDebug(message); + }, + }); + } + + function cancelVisibleOverlaySubtitleRefreshAfterFirstPaint(): void { + if (!visibleOverlaySubtitleRefreshAfterFirstPaintTimer) { + return; + } + clearTimeout(visibleOverlaySubtitleRefreshAfterFirstPaintTimer); + visibleOverlaySubtitleRefreshAfterFirstPaintTimer = null; + } + + function scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint(): void { + if (visibleOverlaySubtitleRefreshAfterFirstPaintTimer) { + return; + } + if (!deps.getVisibleOverlayVisible() || !deps.getCurrentSubText().trim()) { + return; + } + + visibleOverlaySubtitleRefreshAfterFirstPaintTimer = setTimeout(() => { + visibleOverlaySubtitleRefreshAfterFirstPaintTimer = null; + if (!deps.getVisibleOverlayVisible()) { + return; + } + const text = deps.getCurrentSubText(); + if (!text.trim()) { + return; + } + deps.getSubtitlePrefetchService()?.pause(); + deps.getSubtitlePrefetchService()?.onSeek(deps.getLastObservedTimePos()); + subtitleProcessingController.refreshCurrentSubtitle(text); + }, VISIBLE_OVERLAY_SUBTITLE_REFRESH_AFTER_FIRST_PAINT_DELAY_MS); + visibleOverlaySubtitleRefreshAfterFirstPaintTimer.unref?.(); + } + + async function primeAutoplaySubtitleFromParsedCues( + mediaPath: string, + cues: SubtitleCue[], + ): Promise { + if ( + cues.length === 0 || + autoplaySubtitlePrimedMediaPath === mediaPath || + !isCurrentAutoplayMediaPath(mediaPath) + ) { + return; + } + + const client = deps.getMpvClient(); + const timePosRaw = await client?.requestProperty('time-pos').catch(() => null); + const currentTimeSeconds = Number( + timePosRaw ?? client?.currentTimePos ?? deps.getLastObservedTimePos() ?? 0, + ); + const cue = selectAutoplayStartupCue( + cues, + Number.isFinite(currentTimeSeconds) ? currentTimeSeconds : 0, + AUTOPLAY_SUBTITLE_PRIME_LOOKAHEAD_SECONDS, + ); + if (!cue) { + return; + } + + emitAutoplayPrimedSubtitle(mediaPath, cue.text); + } + + function clearScheduledSubtitlePrefetchRefresh(): void { + if (subtitlePrefetchRefreshTimer) { + clearTimeout(subtitlePrefetchRefreshTimer); + subtitlePrefetchRefreshTimer = null; + } + } + + async function refreshSubtitleSidebarFromSource( + sourcePath: string, + mediaPath?: string, + ): Promise { + const normalizedSourcePath = resolveSubtitleSourcePath(sourcePath.trim()); + if (!normalizedSourcePath) { + return; + } + const nextMediaPath = mediaPath?.trim() || getCurrentAutoplayMediaPath(); + await deps.initSubtitlePrefetch( + normalizedSourcePath, + deps.getLastObservedTimePos(), + normalizedSourcePath, + ); + deps.setActiveParsedSubtitleMediaPath(nextMediaPath); + } + + function scheduleSubtitlePrefetchRefresh(delayMs = 0): void { + clearScheduledSubtitlePrefetchRefresh(); + subtitlePrefetchRefreshTimer = setTimeout(() => { + subtitlePrefetchRefreshTimer = null; + void deps.refreshSubtitlePrefetchFromActiveTrack(); + }, delayMs); + } + + return { + getCurrentAutoplayMediaPath, + resetAutoplaySubtitlePrime, + primeCurrentSubtitleForAutoplay, + primeCurrentSubtitleForVisibleOverlay, + cancelVisibleOverlaySubtitleRefreshAfterFirstPaint, + scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint, + primeAutoplaySubtitleFromParsedCues, + clearScheduledSubtitlePrefetchRefresh, + refreshSubtitleSidebarFromSource, + scheduleSubtitlePrefetchRefresh, + }; +}