diff --git a/src/main.ts b/src/main.ts index 2efa556..fc3bc88 100644 --- a/src/main.ts +++ b/src/main.ts @@ -418,6 +418,9 @@ import { generateConfigTemplate, } from './config'; import { resolveConfigDir } from './config/path-resolution'; +import { parseSubtitleCues } from './core/services/subtitle-cue-parser'; +import { createSubtitlePrefetchService } from './core/services/subtitle-prefetch'; +import type { SubtitlePrefetchService } from './core/services/subtitle-prefetch'; if (process.platform === 'linux') { app.commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal'); @@ -1061,6 +1064,7 @@ const buildSubtitleProcessingControllerMainDepsHandler = topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX, mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode, }); + subtitlePrefetchService?.resume(); }, logDebug: (message) => { logger.debug(`[subtitle-processing] ${message}`); @@ -1071,6 +1075,42 @@ const subtitleProcessingControllerMainDeps = buildSubtitleProcessingControllerMa const subtitleProcessingController = createSubtitleProcessingController( subtitleProcessingControllerMainDeps, ); + +let subtitlePrefetchService: SubtitlePrefetchService | null = null; +let lastObservedTimePos = 0; +const SEEK_THRESHOLD_SECONDS = 3; + +async function initSubtitlePrefetch( + externalFilename: string, + currentTimePos: number, +): Promise { + subtitlePrefetchService?.stop(); + subtitlePrefetchService = null; + + try { + const content = await loadSubtitleSourceText(externalFilename); + const cues = parseSubtitleCues(content, externalFilename); + if (cues.length === 0) { + return; + } + + subtitlePrefetchService = createSubtitlePrefetchService({ + cues, + tokenizeSubtitle: async (text) => + tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : null, + preCacheTokenization: (text, data) => { + subtitleProcessingController.preCacheTokenization(text, data); + }, + isCacheFull: () => subtitleProcessingController.isCacheFull(), + }); + + subtitlePrefetchService.start(currentTimePos); + logger.info(`[subtitle-prefetch] started prefetching ${cues.length} cues from ${externalFilename}`); + } catch (error) { + logger.warn('[subtitle-prefetch] failed to initialize:', (error as Error).message); + } +} + const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService( createBuildOverlayShortcutsRuntimeMainDepsHandler({ getConfiguredShortcuts: () => getConfiguredShortcuts(), @@ -1431,6 +1471,7 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt clearYomitanParserCachesForWindow(appState.yomitanParserWindow); } subtitleProcessingController.invalidateTokenizationCache(); + subtitlePrefetchService?.onSeek(lastObservedTimePos); subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText); logger.info( `[dictionary:auto-sync] refreshed current subtitle after sync (AniList ${mediaId}, changed=${changed ? 'yes' : 'no'}, title=${mediaTitle})`, @@ -2598,6 +2639,7 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({ getSubtitleStyleConfig: () => configService.getConfig().subtitleStyle, onOptionsChanged: () => { subtitleProcessingController.invalidateTokenizationCache(); + subtitlePrefetchService?.onSeek(lastObservedTimePos); broadcastRuntimeOptionsChanged(); refreshOverlayShortcuts(); }, @@ -2839,6 +2881,7 @@ const { broadcastToOverlayWindows(channel, payload); }, onSubtitleChange: (text) => { + subtitlePrefetchService?.pause(); subtitleProcessingController.onSubtitleChange(text); }, refreshDiscordPresence: () => { @@ -2853,8 +2896,42 @@ const { autoPlayReadySignalMediaPath = null; currentMediaTokenizationGate.updateCurrentMediaPath(path); startupOsdSequencer.reset(); + subtitlePrefetchService?.stop(); + subtitlePrefetchService = null; if (path) { ensureImmersionTrackerStarted(); + // Attempt to initialize subtitle prefetch for external subtitle tracks. + // Delay slightly to allow MPV's track-list to be populated. + setTimeout(() => { + const client = appState.mpvClient; + if (!client?.connected) return; + void (async () => { + try { + const [trackListRaw, sidRaw] = await Promise.all([ + client.requestProperty('track-list'), + client.requestProperty('sid'), + ]); + if (!Array.isArray(trackListRaw) || sidRaw == null) return; + const sid = typeof sidRaw === 'number' ? sidRaw : typeof sidRaw === 'string' ? Number(sidRaw) : null; + if (sid == null || !Number.isFinite(sid)) return; + const activeTrack = trackListRaw.find( + (entry: unknown) => { + if (!entry || typeof entry !== 'object') return false; + const t = entry as Record; + return t.type === 'sub' && t.id === sid && t.external === true; + }, + ) as Record | undefined; + if (!activeTrack) return; + const externalFilename = typeof activeTrack['external-filename'] === 'string' + ? (activeTrack['external-filename'] as string).trim() + : ''; + if (!externalFilename) return; + void initSubtitlePrefetch(externalFilename, lastObservedTimePos); + } catch { + // Track list query failed — not critical, skip prefetch. + } + })(); + }, 500); } mediaRuntime.updateCurrentMediaPath(path); }, @@ -2898,6 +2975,13 @@ const { reportJellyfinRemoteProgress: (forceImmediate) => { void reportJellyfinRemoteProgress(forceImmediate); }, + onTimePosUpdate: (time) => { + const delta = time - lastObservedTimePos; + if (subtitlePrefetchService && (delta > SEEK_THRESHOLD_SECONDS || delta < 0)) { + subtitlePrefetchService.onSeek(time); + } + lastObservedTimePos = time; + }, updateSubtitleRenderMetrics: (patch) => { updateMpvSubtitleRenderMetrics(patch as Partial); }, @@ -3491,26 +3575,28 @@ const appendClipboardVideoToQueueHandler = createAppendClipboardVideoToQueueHand appendClipboardVideoToQueueMainDeps, ); +async function loadSubtitleSourceText(source: string): Promise { + if (/^https?:\/\//i.test(source)) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 4000); + try { + const response = await fetch(source, { signal: controller.signal }); + if (!response.ok) { + throw new Error(`Failed to download subtitle source (${response.status})`); + } + return await response.text(); + } finally { + clearTimeout(timeoutId); + } + } + + const filePath = source.startsWith('file://') ? decodeURI(new URL(source).pathname) : source; + return fs.promises.readFile(filePath, 'utf8'); +} + const shiftSubtitleDelayToAdjacentCueHandler = createShiftSubtitleDelayToAdjacentCueHandler({ getMpvClient: () => appState.mpvClient, - loadSubtitleSourceText: async (source) => { - if (/^https?:\/\//i.test(source)) { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 4000); - try { - const response = await fetch(source, { signal: controller.signal }); - if (!response.ok) { - throw new Error(`Failed to download subtitle source (${response.status})`); - } - return await response.text(); - } finally { - clearTimeout(timeoutId); - } - } - - const filePath = source.startsWith('file://') ? decodeURI(new URL(source).pathname) : source; - return fs.promises.readFile(filePath, 'utf8'); - }, + loadSubtitleSourceText, sendMpvCommand: (command) => sendMpvCommandRuntime(appState.mpvClient, command), showMpvOsd: (text) => showMpvOsd(text), }); diff --git a/src/main/runtime/mpv-main-event-actions.ts b/src/main/runtime/mpv-main-event-actions.ts index 14cf793..7e09051 100644 --- a/src/main/runtime/mpv-main-event-actions.ts +++ b/src/main/runtime/mpv-main-event-actions.ts @@ -90,11 +90,13 @@ export function createHandleMpvTimePosChangeHandler(deps: { recordPlaybackPosition: (time: number) => void; reportJellyfinRemoteProgress: (forceImmediate: boolean) => void; refreshDiscordPresence: () => void; + onTimePosUpdate?: (time: number) => void; }) { return ({ time }: { time: number }): void => { deps.recordPlaybackPosition(time); deps.reportJellyfinRemoteProgress(false); deps.refreshDiscordPresence(); + deps.onTimePosUpdate?.(time); }; } diff --git a/src/main/runtime/mpv-main-event-bindings.ts b/src/main/runtime/mpv-main-event-bindings.ts index 63d7220..b27ffed 100644 --- a/src/main/runtime/mpv-main-event-bindings.ts +++ b/src/main/runtime/mpv-main-event-bindings.ts @@ -59,6 +59,7 @@ export function createBindMpvMainEventHandlersHandler(deps: { recordPlaybackPosition: (time: number) => void; recordMediaDuration: (durationSec: number) => void; reportJellyfinRemoteProgress: (forceImmediate: boolean) => void; + onTimePosUpdate?: (time: number) => void; recordPauseState: (paused: boolean) => void; updateSubtitleRenderMetrics: (patch: Record) => void; @@ -124,6 +125,7 @@ export function createBindMpvMainEventHandlersHandler(deps: { reportJellyfinRemoteProgress: (forceImmediate) => deps.reportJellyfinRemoteProgress(forceImmediate), refreshDiscordPresence: () => deps.refreshDiscordPresence(), + onTimePosUpdate: (time) => deps.onTimePosUpdate?.(time), }); const handleMpvPauseChange = createHandleMpvPauseChangeHandler({ recordPauseState: (paused) => deps.recordPauseState(paused), diff --git a/src/main/runtime/mpv-main-event-main-deps.ts b/src/main/runtime/mpv-main-event-main-deps.ts index 914b8be..86fb919 100644 --- a/src/main/runtime/mpv-main-event-main-deps.ts +++ b/src/main/runtime/mpv-main-event-main-deps.ts @@ -47,6 +47,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { updateCurrentMediaTitle: (title: string) => void; resetAnilistMediaGuessState: () => void; reportJellyfinRemoteProgress: (forceImmediate: boolean) => void; + onTimePosUpdate?: (time: number) => void; updateSubtitleRenderMetrics: (patch: Record) => void; refreshDiscordPresence: () => void; ensureImmersionTrackerInitialized: () => void; @@ -134,6 +135,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { }, reportJellyfinRemoteProgress: (forceImmediate: boolean) => deps.reportJellyfinRemoteProgress(forceImmediate), + onTimePosUpdate: deps.onTimePosUpdate ? (time: number) => deps.onTimePosUpdate!(time) : undefined, recordPauseState: (paused: boolean) => { deps.appState.playbackPaused = paused; deps.ensureImmersionTrackerInitialized();