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:
94
src/main.ts
94
src/main.ts
@@ -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,9 +3575,7 @@ const appendClipboardVideoToQueueHandler = createAppendClipboardVideoToQueueHand
|
|||||||
appendClipboardVideoToQueueMainDeps,
|
appendClipboardVideoToQueueMainDeps,
|
||||||
);
|
);
|
||||||
|
|
||||||
const shiftSubtitleDelayToAdjacentCueHandler = createShiftSubtitleDelayToAdjacentCueHandler({
|
async function loadSubtitleSourceText(source: string): Promise<string> {
|
||||||
getMpvClient: () => appState.mpvClient,
|
|
||||||
loadSubtitleSourceText: async (source) => {
|
|
||||||
if (/^https?:\/\//i.test(source)) {
|
if (/^https?:\/\//i.test(source)) {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 4000);
|
const timeoutId = setTimeout(() => controller.abort(), 4000);
|
||||||
@@ -3510,7 +3592,11 @@ const shiftSubtitleDelayToAdjacentCueHandler = createShiftSubtitleDelayToAdjacen
|
|||||||
|
|
||||||
const filePath = source.startsWith('file://') ? decodeURI(new URL(source).pathname) : source;
|
const filePath = source.startsWith('file://') ? decodeURI(new URL(source).pathname) : source;
|
||||||
return fs.promises.readFile(filePath, 'utf8');
|
return fs.promises.readFile(filePath, 'utf8');
|
||||||
},
|
}
|
||||||
|
|
||||||
|
const shiftSubtitleDelayToAdjacentCueHandler = createShiftSubtitleDelayToAdjacentCueHandler({
|
||||||
|
getMpvClient: () => appState.mpvClient,
|
||||||
|
loadSubtitleSourceText,
|
||||||
sendMpvCommand: (command) => sendMpvCommandRuntime(appState.mpvClient, command),
|
sendMpvCommand: (command) => sendMpvCommandRuntime(appState.mpvClient, command),
|
||||||
showMpvOsd: (text) => showMpvOsd(text),
|
showMpvOsd: (text) => showMpvOsd(text),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user