mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
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:
122
src/main.ts
122
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<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(
|
||||
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<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);
|
||||
},
|
||||
@@ -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<MpvSubtitleRenderMetrics>);
|
||||
},
|
||||
@@ -3491,26 +3575,28 @@ const appendClipboardVideoToQueueHandler = createAppendClipboardVideoToQueueHand
|
||||
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({
|
||||
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),
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown>) => 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),
|
||||
|
||||
@@ -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<string, unknown>) => 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();
|
||||
|
||||
Reference in New Issue
Block a user