refactor(main): extract autoplay subtitle priming runtime from main.ts

This commit is contained in:
2026-06-11 23:39:56 -07:00
parent eb1af727bb
commit 8f362063dd
3 changed files with 316 additions and 203 deletions
+36 -180
View File
@@ -103,7 +103,6 @@ import type {
RuntimeOptionState, RuntimeOptionState,
SessionActionDispatchRequest, SessionActionDispatchRequest,
SecondarySubMode, SecondarySubMode,
SubtitleCue,
SubtitleData, SubtitleData,
SubtitleMiningContext, SubtitleMiningContext,
SubtitlePosition, SubtitlePosition,
@@ -378,7 +377,7 @@ import {
createYoutubePrimarySubtitleNotificationRuntime, createYoutubePrimarySubtitleNotificationRuntime,
} from './main/runtime/youtube-primary-subtitle-notification'; } from './main/runtime/youtube-primary-subtitle-notification';
import { createAutoplayReadyGate } from './main/runtime/autoplay-ready-gate'; 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 { createAutoplayTokenizationWarmRelease } from './main/runtime/autoplay-tokenization-warm-release';
import { isVisibleOverlayAutoplayTargetReady } from './main/runtime/visible-overlay-autoplay-readiness'; import { isVisibleOverlayAutoplayTargetReady } from './main/runtime/visible-overlay-autoplay-readiness';
import { createManagedLocalSubtitleSelectionRuntime } from './main/runtime/local-subtitle-selection'; 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 { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications';
import { openCharacterDictionaryManagerWithConfigGate } from './main/runtime/character-dictionary-manager-gate'; import { openCharacterDictionaryManagerWithConfigGate } from './main/runtime/character-dictionary-manager-gate';
import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate'; import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate';
import { import { resolveCurrentSubtitleForRenderer } from './main/runtime/current-subtitle-snapshot';
primeVisibleOverlaySubtitleFromMpv,
resolveCurrentSubtitleForRenderer,
} from './main/runtime/current-subtitle-snapshot';
import { restoreLinuxOverlayWindowShape } from './main/runtime/linux-overlay-window-shape'; import { restoreLinuxOverlayWindowShape } from './main/runtime/linux-overlay-window-shape';
import { createOverlayGeometryRuntime } from './main/runtime/overlay-geometry-runtime'; import { createOverlayGeometryRuntime } from './main/runtime/overlay-geometry-runtime';
import { createJellyfinSubtitleCacheIo } from './main/runtime/jellyfin-subtitle-cache-io'; import { createJellyfinSubtitleCacheIo } from './main/runtime/jellyfin-subtitle-cache-io';
@@ -595,10 +591,7 @@ import {
createSubtitlePrefetchService, createSubtitlePrefetchService,
type SubtitlePrefetchService, type SubtitlePrefetchService,
} from './core/services/subtitle-prefetch'; } from './core/services/subtitle-prefetch';
import { import { buildSubtitleSidebarSourceKey } from './main/runtime/subtitle-prefetch-source';
buildSubtitleSidebarSourceKey,
resolveSubtitleSourcePath,
} from './main/runtime/subtitle-prefetch-source';
import { createSubtitlePrefetchInitController } from './main/runtime/subtitle-prefetch-init'; import { createSubtitlePrefetchInitController } from './main/runtime/subtitle-prefetch-init';
import { import {
loadSubtitleSourceText, loadSubtitleSourceText,
@@ -1773,7 +1766,6 @@ const subtitleProcessingController = createSubtitleProcessingController(
); );
let subtitlePrefetchService: SubtitlePrefetchService | null = null; let subtitlePrefetchService: SubtitlePrefetchService | null = null;
let subtitlePrefetchRefreshTimer: ReturnType<typeof setTimeout> | null = null;
let lastObservedTimePos = 0; let lastObservedTimePos = 0;
let lastObservedPrimarySubtitleTrackId: number | null = null; let lastObservedPrimarySubtitleTrackId: number | null = null;
let cancelLinuxMpvFullscreenOverlayRefreshBurst: CancelLinuxMpvFullscreenOverlayRefreshBurst | null = let cancelLinuxMpvFullscreenOverlayRefreshBurst: CancelLinuxMpvFullscreenOverlayRefreshBurst | null =
@@ -1786,166 +1778,48 @@ const linuxVisibleOverlayOwnerBindingQueues = new WeakMap<BrowserWindow, Promise
let linuxVisibleOverlayWindowModeSwitchToken = 0; let linuxVisibleOverlayWindowModeSwitchToken = 0;
let subtitleSidebarRequestedOpen = false; let subtitleSidebarRequestedOpen = false;
const SEEK_THRESHOLD_SECONDS = 3; const SEEK_THRESHOLD_SECONDS = 3;
const AUTOPLAY_SUBTITLE_PRIME_LOOKAHEAD_SECONDS = 2;
let autoplaySubtitlePrimedMediaPath: string | null = null;
let visibleOverlaySubtitleRefreshAfterFirstPaintTimer: ReturnType<typeof setTimeout> | null = null;
const VISIBLE_OVERLAY_SUBTITLE_REFRESH_AFTER_FIRST_PAINT_DELAY_MS = 100;
function getCurrentAutoplayMediaPath(): string | null { const autoplaySubtitlePrimingRuntime = createAutoplaySubtitlePrimingRuntime({
return appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null; getCurrentMediaPath: () => appState.currentMediaPath,
}
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<void> {
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<void> {
await primeVisibleOverlaySubtitleFromMpv({
getMpvClient: () => appState.mpvClient, getMpvClient: () => appState.mpvClient,
setCurrentSubText: (text) => { setCurrentSubText: (text) => {
appState.currentSubText = text; appState.currentSubText = text;
}, },
getCurrentSubText: () => appState.currentSubText,
getCurrentSubtitleData: () => appState.currentSubtitleData, getCurrentSubtitleData: () => appState.currentSubtitleData,
consumeCachedSubtitle: (text) => subtitleProcessingController.consumeCachedSubtitle(text), setActiveParsedSubtitleMediaPath: (mediaPath) => {
onSubtitleChange: (text) => { appState.activeParsedSubtitleMediaPath = mediaPath;
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;
}
}, },
subtitleProcessingController,
emitSubtitlePayload: (payload) => emitSubtitlePayload(payload),
getSubtitlePrefetchService: () => subtitlePrefetchService,
getLastObservedTimePos: () => lastObservedTimePos,
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
emitSecondarySubtitle: (text) => { emitSecondarySubtitle: (text) => {
broadcastToOverlayWindows('secondary-subtitle:set', text); broadcastToOverlayWindows('secondary-subtitle:set', text);
}, },
initSubtitlePrefetch: (sourcePath, currentTimePos, sourceKey) =>
subtitlePrefetchInitController.initSubtitlePrefetch(sourcePath, currentTimePos, sourceKey),
refreshSubtitlePrefetchFromActiveTrack: () => refreshSubtitlePrefetchFromActiveTrackHandler(),
logDebug: (message) => { logDebug: (message) => {
logger.debug(message); logger.debug(message);
}, },
}); });
function primeCurrentSubtitleForVisibleOverlay(): Promise<void> {
return autoplaySubtitlePrimingRuntime.primeCurrentSubtitleForVisibleOverlay();
} }
function cancelVisibleOverlaySubtitleRefreshAfterFirstPaint(): void { function cancelVisibleOverlaySubtitleRefreshAfterFirstPaint(): void {
if (!visibleOverlaySubtitleRefreshAfterFirstPaintTimer) { autoplaySubtitlePrimingRuntime.cancelVisibleOverlaySubtitleRefreshAfterFirstPaint();
return;
}
clearTimeout(visibleOverlaySubtitleRefreshAfterFirstPaintTimer);
visibleOverlaySubtitleRefreshAfterFirstPaintTimer = null;
} }
function scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint(): void { function scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint(): void {
if (visibleOverlaySubtitleRefreshAfterFirstPaintTimer) { autoplaySubtitlePrimingRuntime.scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint();
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?.();
} }
async function primeAutoplaySubtitleFromParsedCues( function scheduleSubtitlePrefetchRefresh(delayMs = 0): void {
mediaPath: string, autoplaySubtitlePrimingRuntime.scheduleSubtitlePrefetchRefresh(delayMs);
cues: SubtitleCue[],
): Promise<void> {
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 cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(): void { function cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(): void {
@@ -1976,9 +1850,11 @@ const subtitlePrefetchInitController = createSubtitlePrefetchInitController({
if (!cues?.length) { if (!cues?.length) {
appState.activeParsedSubtitleMediaPath = null; appState.activeParsedSubtitleMediaPath = null;
} }
const mediaPath = getCurrentAutoplayMediaPath(); const mediaPath = autoplaySubtitlePrimingRuntime.getCurrentAutoplayMediaPath();
if (mediaPath && cues?.length) { if (mediaPath && cues?.length) {
void primeAutoplaySubtitleFromParsedCues(mediaPath, cues).catch((error) => { void autoplaySubtitlePrimingRuntime
.primeAutoplaySubtitleFromParsedCues(mediaPath, cues)
.catch((error) => {
logger.debug( logger.debug(
`[autoplay-subtitle-prime] failed to prime from parsed cues: ${ `[autoplay-subtitle-prime] failed to prime from parsed cues: ${
error instanceof Error ? error.message : String(error) error instanceof Error ? error.message : String(error)
@@ -1994,22 +1870,6 @@ const resolveActiveSubtitleSidebarSourceHandler = createResolveActiveSubtitleSid
extractInternalSubtitleTrackToTempFile(ffmpegPath, videoPath, track), extractInternalSubtitleTrackToTempFile(ffmpegPath, videoPath, track),
}); });
async function refreshSubtitleSidebarFromSource(
sourcePath: string,
mediaPath?: string,
): Promise<void> {
const normalizedSourcePath = resolveSubtitleSourcePath(sourcePath.trim());
if (!normalizedSourcePath) {
return;
}
const nextMediaPath = mediaPath?.trim() || getCurrentAutoplayMediaPath();
await subtitlePrefetchInitController.initSubtitlePrefetch(
normalizedSourcePath,
lastObservedTimePos,
normalizedSourcePath,
);
appState.activeParsedSubtitleMediaPath = nextMediaPath;
}
const refreshSubtitlePrefetchFromActiveTrackHandler = const refreshSubtitlePrefetchFromActiveTrackHandler =
createRefreshSubtitlePrefetchFromActiveTrackHandler({ createRefreshSubtitlePrefetchFromActiveTrackHandler({
getMpvClient: () => appState.mpvClient, getMpvClient: () => appState.mpvClient,
@@ -2019,21 +1879,16 @@ const refreshSubtitlePrefetchFromActiveTrackHandler =
resolveActiveSubtitleSidebarSource: (input) => resolveActiveSubtitleSidebarSourceHandler(input), resolveActiveSubtitleSidebarSource: (input) => resolveActiveSubtitleSidebarSourceHandler(input),
}); });
function scheduleSubtitlePrefetchRefresh(delayMs = 0): void {
clearScheduledSubtitlePrefetchRefresh();
subtitlePrefetchRefreshTimer = setTimeout(() => {
subtitlePrefetchRefreshTimer = null;
void refreshSubtitlePrefetchFromActiveTrackHandler();
}, delayMs);
}
const subtitlePrefetchRuntime = { const subtitlePrefetchRuntime = {
cancelPendingInit: () => subtitlePrefetchInitController.cancelPendingInit(), cancelPendingInit: () => subtitlePrefetchInitController.cancelPendingInit(),
initSubtitlePrefetch: subtitlePrefetchInitController.initSubtitlePrefetch, initSubtitlePrefetch: subtitlePrefetchInitController.initSubtitlePrefetch,
refreshSubtitleSidebarFromSource: (sourcePath: string, mediaPath?: string) => refreshSubtitleSidebarFromSource: (sourcePath: string, mediaPath?: string) =>
refreshSubtitleSidebarFromSource(sourcePath, mediaPath), autoplaySubtitlePrimingRuntime.refreshSubtitleSidebarFromSource(sourcePath, mediaPath),
refreshSubtitlePrefetchFromActiveTrack: () => refreshSubtitlePrefetchFromActiveTrackHandler(), refreshSubtitlePrefetchFromActiveTrack: () => refreshSubtitlePrefetchFromActiveTrackHandler(),
scheduleSubtitlePrefetchRefresh: (delayMs?: number) => scheduleSubtitlePrefetchRefresh(delayMs), scheduleSubtitlePrefetchRefresh: (delayMs?: number) =>
clearScheduledSubtitlePrefetchRefresh: () => clearScheduledSubtitlePrefetchRefresh(), autoplaySubtitlePrimingRuntime.scheduleSubtitlePrefetchRefresh(delayMs),
clearScheduledSubtitlePrefetchRefresh: () =>
autoplaySubtitlePrimingRuntime.clearScheduledSubtitlePrefetchRefresh(),
} as const; } as const;
const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService( const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService(
@@ -4984,7 +4839,7 @@ const {
topX: frequencyDictionary.topX, topX: frequencyDictionary.topX,
mode: frequencyDictionary.mode, mode: frequencyDictionary.mode,
}; };
autoplaySubtitlePrimedMediaPath = null; autoplaySubtitlePrimingRuntime.resetAutoplaySubtitlePrime();
lastObservedTimePos = 0; lastObservedTimePos = 0;
appState.currentSubText = ''; appState.currentSubText = '';
appState.currentSubAssText = ''; appState.currentSubAssText = '';
@@ -5300,7 +5155,8 @@ signalAutoplayReadyFromWarmTokenization = createAutoplayTokenizationWarmRelease(
}, },
getCurrentMediaPath: () => getCurrentMediaPath: () =>
appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null, appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null,
primeCurrentSubtitle: (mediaPath) => primeCurrentSubtitleForAutoplay(mediaPath), primeCurrentSubtitle: (mediaPath) =>
autoplaySubtitlePrimingRuntime.primeCurrentSubtitleForAutoplay(mediaPath),
signalAutoplayReady: () => signalCurrentSubtitleAutoplayReady(), signalAutoplayReady: () => signalCurrentSubtitleAutoplayReady(),
warn: (message, error) => logger.warn(message, error), warn: (message, error) => logger.warn(message, error),
}); });
+6 -6
View File
@@ -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', () => { 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( const actionBlock = source.match(
/async function refreshSubtitleSidebarFromSource\([\s\S]*?\): Promise<void> \{(?<body>[\s\S]*?)\n\}/, /async function refreshSubtitleSidebarFromSource\([\s\S]*?\): Promise<void> \{(?<body>[\s\S]*?)\n \}/,
)?.groups?.body; )?.groups?.body;
assert.ok(actionBlock); assert.ok(actionBlock);
@@ -161,8 +161,8 @@ test('subtitle sidebar media path tag is assigned after prefetch succeeds', () =
/const nextMediaPath = mediaPath\?\.trim\(\) \|\| getCurrentAutoplayMediaPath\(\);/, /const nextMediaPath = mediaPath\?\.trim\(\) \|\| getCurrentAutoplayMediaPath\(\);/,
); );
assert.ok( assert.ok(
actionBlock.indexOf('subtitlePrefetchInitController.initSubtitlePrefetch') < actionBlock.indexOf('deps.initSubtitlePrefetch(') <
actionBlock.indexOf('appState.activeParsedSubtitleMediaPath = nextMediaPath;'), 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', () => { 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( const actionBlock = source.match(
/function emitAutoplayPrimedSubtitle\([\s\S]*?\): boolean \{(?<body>[\s\S]*?)\n\}/, /function emitAutoplayPrimedSubtitle\([\s\S]*?\): boolean \{(?<body>[\s\S]*?)\n \}/,
)?.groups?.body; )?.groups?.body;
assert.ok(actionBlock); assert.ok(actionBlock);
@@ -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<unknown>;
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<void>;
refreshSubtitlePrefetchFromActiveTrack: () => Promise<void>;
logDebug: (message: string) => void;
}
export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimingRuntimeDeps) {
const { subtitleProcessingController, emitSubtitlePayload } = deps;
let subtitlePrefetchRefreshTimer: ReturnType<typeof setTimeout> | null = null;
let autoplaySubtitlePrimedMediaPath: string | null = null;
let visibleOverlaySubtitleRefreshAfterFirstPaintTimer: ReturnType<typeof setTimeout> | 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<void> {
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<void> {
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<void> {
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<void> {
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,
};
}