fix(subtitles): release prefetch pause across all priming paths

The repeated-subtitle pause leak was only fixed for ordinary subtitle
changes. Startup autoplay priming and visible-overlay priming pause the same
way and also ignored whether any tokenization was scheduled, so a cache miss
on text the controller already holds (mining a card while the line is on
screen) left prefetching idle for the rest of the cue.

Pause and release are now one operation via pausePrefetchUntilEmit, and both
controller entry points report whether an emit is expected. A repeat arriving
while a run is already in flight keeps the pause, since that run still emits.

Also invalidate the character dictionary lookups centrally from the sync
completion handler instead of at three manager call sites. Ordinary selection
sync never invalidated them, so a stale non-null name candidate list could
skip a newly added name for up to five seconds; a missing list falls back to
the exhaustive scan, but a stale one does not. Ordering matters: the
invalidation runs before the subtitle refreshes so they re-tokenize against
the new dictionary content.

Docs: subtitle-overlay-priming no longer claims every subtitle change calls
onSeek().
This commit is contained in:
2026-08-04 00:01:14 -07:00
parent f43674cc39
commit 2003efa235
8 changed files with 200 additions and 37 deletions
@@ -17,6 +17,7 @@ type AutoplaySubtitlePrimingMpvClient = {
type AutoplaySubtitlePrimingPrefetchService = {
pause: () => void;
resume: () => void;
};
export interface AutoplaySubtitlePrimingRuntimeDeps {
@@ -29,8 +30,9 @@ export interface AutoplaySubtitlePrimingRuntimeDeps {
setActiveParsedSubtitleMediaPath: (mediaPath: string | null) => void;
subtitleProcessingController: {
consumeCachedSubtitle: (text: string) => SubtitleData | null;
onSubtitleChange: (text: string) => void;
refreshCurrentSubtitle: (text: string) => void;
// Both report whether an emit is expected; see pausePrefetchUntilEmit.
onSubtitleChange: (text: string) => boolean;
refreshCurrentSubtitle: (text: string) => boolean;
};
emitSubtitlePayload: (payload: SubtitleData, options?: { resumePrefetch?: boolean }) => void;
getSubtitlePrefetchService: () => AutoplaySubtitlePrimingPrefetchService | null;
@@ -63,6 +65,18 @@ export function setMpvCurrentSecondarySubText(
export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimingRuntimeDeps) {
const { subtitleProcessingController, emitSubtitlePayload } = deps;
// Prefetching is paused so the on-screen line gets the parser to itself, and
// the resume rides on the tokenized emit. When the controller reports that no
// emit is coming (repeat text with nothing scheduled), release it here or
// prefetching idles until some later line happens to complete.
function pausePrefetchUntilEmit(scheduleTokenization: () => boolean): void {
const prefetch = deps.getSubtitlePrefetchService();
prefetch?.pause();
if (!scheduleTokenization()) {
prefetch?.resume();
}
}
let subtitlePrefetchRefreshTimer: ReturnType<typeof setTimeout> | null = null;
let autoplaySubtitlePrimedMediaPath: string | null = null;
let visibleOverlaySubtitleRefreshAfterFirstPaintTimer: ReturnType<typeof setTimeout> | null =
@@ -103,6 +117,7 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
const cachedPayload = subtitleProcessingController.consumeCachedSubtitle(text);
if (cachedPayload) {
subtitleProcessingController.onSubtitleChange(text);
// This emit resumes prefetching, so no pause is left outstanding.
emitSubtitlePayload(cachedPayload);
return true;
}
@@ -110,7 +125,11 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
// Provisional raw emit: keep prefetch paused until the tokenized payload
// for this line is delivered by the processing controller.
emitSubtitlePayload({ text, tokens: null }, { resumePrefetch: false });
subtitleProcessingController.onSubtitleChange(text);
if (!subtitleProcessingController.onSubtitleChange(text)) {
// Cache miss on text the controller already holds (it was invalidated
// under us): nothing will be tokenized, so no emit is coming.
deps.getSubtitlePrefetchService()?.resume();
}
return true;
}
@@ -154,12 +173,10 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
getCurrentSubtitleData: () => deps.getCurrentSubtitleData(),
consumeCachedSubtitle: (text) => subtitleProcessingController.consumeCachedSubtitle(text),
onSubtitleChange: (text) => {
deps.getSubtitlePrefetchService()?.pause();
subtitleProcessingController.onSubtitleChange(text);
pausePrefetchUntilEmit(() => subtitleProcessingController.onSubtitleChange(text));
},
refreshCurrentSubtitle: (text) => {
deps.getSubtitlePrefetchService()?.pause();
subtitleProcessingController.refreshCurrentSubtitle(text);
pausePrefetchUntilEmit(() => subtitleProcessingController.refreshCurrentSubtitle(text));
},
deferUncachedRefresh: true,
emitSubtitle: (payload) => emitSubtitlePayload(payload),
@@ -203,8 +220,7 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
if (!text.trim()) {
return;
}
deps.getSubtitlePrefetchService()?.pause();
subtitleProcessingController.refreshCurrentSubtitle(text);
pausePrefetchUntilEmit(() => subtitleProcessingController.refreshCurrentSubtitle(text));
}, VISIBLE_OVERLAY_SUBTITLE_REFRESH_AFTER_FIRST_PAINT_DELAY_MS);
visibleOverlaySubtitleRefreshAfterFirstPaintTimer.unref?.();
}