feat: wire up subtitle prefetch service to MPV events

Initializes prefetch on external subtitle track activation, detects
seeks via time-pos delta threshold, pauses prefetch during live
subtitle processing, and restarts on cache invalidation.

- Extract loadSubtitleSourceText into reusable function
- Add prefetch service state and initSubtitlePrefetch helper
- Thread onTimePosUpdate through event actions/bindings/main-deps
- Pause prefetch on subtitle change, resume on emit
- Restart prefetch after tokenization cache invalidation
- Query track-list on media path change to find external subs
This commit is contained in:
2026-03-15 13:16:19 -07:00
parent 05fe9c8fdf
commit 5c31be99b5
4 changed files with 110 additions and 18 deletions

View File

@@ -418,6 +418,9 @@ import {
generateConfigTemplate, generateConfigTemplate,
} from './config'; } from './config';
import { resolveConfigDir } from './config/path-resolution'; 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') { if (process.platform === 'linux') {
app.commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal'); app.commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal');
@@ -1061,6 +1064,7 @@ const buildSubtitleProcessingControllerMainDepsHandler =
topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX, topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX,
mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode, mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode,
}); });
subtitlePrefetchService?.resume();
}, },
logDebug: (message) => { logDebug: (message) => {
logger.debug(`[subtitle-processing] ${message}`); logger.debug(`[subtitle-processing] ${message}`);
@@ -1071,6 +1075,42 @@ const subtitleProcessingControllerMainDeps = buildSubtitleProcessingControllerMa
const subtitleProcessingController = createSubtitleProcessingController( const subtitleProcessingController = createSubtitleProcessingController(
subtitleProcessingControllerMainDeps, subtitleProcessingControllerMainDeps,
); );
let subtitlePrefetchService: SubtitlePrefetchService | null = null;
let lastObservedTimePos = 0;
const SEEK_THRESHOLD_SECONDS = 3;
async function initSubtitlePrefetch(
externalFilename: string,
currentTimePos: number,
): Promise<void> {
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( const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService(
createBuildOverlayShortcutsRuntimeMainDepsHandler({ createBuildOverlayShortcutsRuntimeMainDepsHandler({
getConfiguredShortcuts: () => getConfiguredShortcuts(), getConfiguredShortcuts: () => getConfiguredShortcuts(),
@@ -1431,6 +1471,7 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
clearYomitanParserCachesForWindow(appState.yomitanParserWindow); clearYomitanParserCachesForWindow(appState.yomitanParserWindow);
} }
subtitleProcessingController.invalidateTokenizationCache(); subtitleProcessingController.invalidateTokenizationCache();
subtitlePrefetchService?.onSeek(lastObservedTimePos);
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText); subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
logger.info( logger.info(
`[dictionary:auto-sync] refreshed current subtitle after sync (AniList ${mediaId}, changed=${changed ? 'yes' : 'no'}, title=${mediaTitle})`, `[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, getSubtitleStyleConfig: () => configService.getConfig().subtitleStyle,
onOptionsChanged: () => { onOptionsChanged: () => {
subtitleProcessingController.invalidateTokenizationCache(); subtitleProcessingController.invalidateTokenizationCache();
subtitlePrefetchService?.onSeek(lastObservedTimePos);
broadcastRuntimeOptionsChanged(); broadcastRuntimeOptionsChanged();
refreshOverlayShortcuts(); refreshOverlayShortcuts();
}, },
@@ -2839,6 +2881,7 @@ const {
broadcastToOverlayWindows(channel, payload); broadcastToOverlayWindows(channel, payload);
}, },
onSubtitleChange: (text) => { onSubtitleChange: (text) => {
subtitlePrefetchService?.pause();
subtitleProcessingController.onSubtitleChange(text); subtitleProcessingController.onSubtitleChange(text);
}, },
refreshDiscordPresence: () => { refreshDiscordPresence: () => {
@@ -2853,8 +2896,42 @@ const {
autoPlayReadySignalMediaPath = null; autoPlayReadySignalMediaPath = null;
currentMediaTokenizationGate.updateCurrentMediaPath(path); currentMediaTokenizationGate.updateCurrentMediaPath(path);
startupOsdSequencer.reset(); startupOsdSequencer.reset();
subtitlePrefetchService?.stop();
subtitlePrefetchService = null;
if (path) { if (path) {
ensureImmersionTrackerStarted(); 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<string, unknown>;
return t.type === 'sub' && t.id === sid && t.external === true;
},
) as Record<string, unknown> | 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); mediaRuntime.updateCurrentMediaPath(path);
}, },
@@ -2898,6 +2975,13 @@ const {
reportJellyfinRemoteProgress: (forceImmediate) => { reportJellyfinRemoteProgress: (forceImmediate) => {
void 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) => { updateSubtitleRenderMetrics: (patch) => {
updateMpvSubtitleRenderMetrics(patch as Partial<MpvSubtitleRenderMetrics>); updateMpvSubtitleRenderMetrics(patch as Partial<MpvSubtitleRenderMetrics>);
}, },
@@ -3491,26 +3575,28 @@ const appendClipboardVideoToQueueHandler = createAppendClipboardVideoToQueueHand
appendClipboardVideoToQueueMainDeps, appendClipboardVideoToQueueMainDeps,
); );
async function loadSubtitleSourceText(source: string): Promise<string> {
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({ const shiftSubtitleDelayToAdjacentCueHandler = createShiftSubtitleDelayToAdjacentCueHandler({
getMpvClient: () => appState.mpvClient, getMpvClient: () => appState.mpvClient,
loadSubtitleSourceText: async (source) => { loadSubtitleSourceText,
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');
},
sendMpvCommand: (command) => sendMpvCommandRuntime(appState.mpvClient, command), sendMpvCommand: (command) => sendMpvCommandRuntime(appState.mpvClient, command),
showMpvOsd: (text) => showMpvOsd(text), showMpvOsd: (text) => showMpvOsd(text),
}); });

View File

@@ -90,11 +90,13 @@ export function createHandleMpvTimePosChangeHandler(deps: {
recordPlaybackPosition: (time: number) => void; recordPlaybackPosition: (time: number) => void;
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void; reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
refreshDiscordPresence: () => void; refreshDiscordPresence: () => void;
onTimePosUpdate?: (time: number) => void;
}) { }) {
return ({ time }: { time: number }): void => { return ({ time }: { time: number }): void => {
deps.recordPlaybackPosition(time); deps.recordPlaybackPosition(time);
deps.reportJellyfinRemoteProgress(false); deps.reportJellyfinRemoteProgress(false);
deps.refreshDiscordPresence(); deps.refreshDiscordPresence();
deps.onTimePosUpdate?.(time);
}; };
} }

View File

@@ -59,6 +59,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
recordPlaybackPosition: (time: number) => void; recordPlaybackPosition: (time: number) => void;
recordMediaDuration: (durationSec: number) => void; recordMediaDuration: (durationSec: number) => void;
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void; reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
onTimePosUpdate?: (time: number) => void;
recordPauseState: (paused: boolean) => void; recordPauseState: (paused: boolean) => void;
updateSubtitleRenderMetrics: (patch: Record<string, unknown>) => void; updateSubtitleRenderMetrics: (patch: Record<string, unknown>) => void;
@@ -124,6 +125,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
reportJellyfinRemoteProgress: (forceImmediate) => reportJellyfinRemoteProgress: (forceImmediate) =>
deps.reportJellyfinRemoteProgress(forceImmediate), deps.reportJellyfinRemoteProgress(forceImmediate),
refreshDiscordPresence: () => deps.refreshDiscordPresence(), refreshDiscordPresence: () => deps.refreshDiscordPresence(),
onTimePosUpdate: (time) => deps.onTimePosUpdate?.(time),
}); });
const handleMpvPauseChange = createHandleMpvPauseChangeHandler({ const handleMpvPauseChange = createHandleMpvPauseChangeHandler({
recordPauseState: (paused) => deps.recordPauseState(paused), recordPauseState: (paused) => deps.recordPauseState(paused),

View File

@@ -47,6 +47,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
updateCurrentMediaTitle: (title: string) => void; updateCurrentMediaTitle: (title: string) => void;
resetAnilistMediaGuessState: () => void; resetAnilistMediaGuessState: () => void;
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void; reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
onTimePosUpdate?: (time: number) => void;
updateSubtitleRenderMetrics: (patch: Record<string, unknown>) => void; updateSubtitleRenderMetrics: (patch: Record<string, unknown>) => void;
refreshDiscordPresence: () => void; refreshDiscordPresence: () => void;
ensureImmersionTrackerInitialized: () => void; ensureImmersionTrackerInitialized: () => void;
@@ -134,6 +135,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
}, },
reportJellyfinRemoteProgress: (forceImmediate: boolean) => reportJellyfinRemoteProgress: (forceImmediate: boolean) =>
deps.reportJellyfinRemoteProgress(forceImmediate), deps.reportJellyfinRemoteProgress(forceImmediate),
onTimePosUpdate: deps.onTimePosUpdate ? (time: number) => deps.onTimePosUpdate!(time) : undefined,
recordPauseState: (paused: boolean) => { recordPauseState: (paused: boolean) => {
deps.appState.playbackPaused = paused; deps.appState.playbackPaused = paused;
deps.ensureImmersionTrackerInitialized(); deps.ensureImmersionTrackerInitialized();