fix(subtitles): guard track-list effect against stale async runs

The track-building effect in VideoContext reruns once api?.basePath and
isCurrentSubImageBased settle. An earlier async run could resolve after a
rerun and overwrite subtitleTracks/audioTracks with setTrack callbacks bound
to a stale `api`, breaking external-subtitle identity matching.

Add a cancellation token and route every state commit through guarded
committers so all six commit points (offline-transcoded audio/subs,
burned-in, and the online audio/subs paths) drop writes from a dead run,
plus bail out right after the awaited getAudioTracks when cancelled.
This commit is contained in:
Gauvain
2026-06-30 01:56:58 +02:00
parent c02baf2831
commit a58a4da4f3

View File

@@ -144,6 +144,19 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
useEffect(() => { useEffect(() => {
if (!tracksReady) return; if (!tracksReady) return;
// Guard every state commit against stale runs: api?.basePath /
// isCurrentSubImageBased can flip mid-run and restart this effect, and an
// earlier async run (which captured an old `api`) must not finish later and
// overwrite the fresh track list with callbacks bound to stale closures.
// The cleanup flips `cancelled`, so any late commit from a dead run is dropped.
let cancelled = false;
const commitSubtitleTracks = (next: Track[]) => {
if (!cancelled) setSubtitleTracks(next);
};
const commitAudioTracks = (next: Track[]) => {
if (!cancelled) setAudioTracks(next);
};
const fetchTracks = async () => { const fetchTracks = async () => {
// Check if this is offline transcoded content // Check if this is offline transcoded content
// For transcoded offline content, only ONE audio track exists in the file // For transcoded offline content, only ONE audio track exists in the file
@@ -169,10 +182,10 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
}, },
}, },
]; ];
setAudioTracks(audio); commitAudioTracks(audio);
} else { } else {
// Fallback: show no audio tracks if the stored track wasn't found // Fallback: show no audio tracks if the stored track wasn't found
setAudioTracks([]); commitAudioTracks([]);
} }
// For subtitles in transcoded offline content: // For subtitles in transcoded offline content:
@@ -189,7 +202,7 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
(s) => s.Index === downloadedSubtitleIndex, (s) => s.Index === downloadedSubtitleIndex,
); );
if (burnedInSub && isImageBasedSubtitle(burnedInSub)) { if (burnedInSub && isImageBasedSubtitle(burnedInSub)) {
setSubtitleTracks([ commitSubtitleTracks([
{ {
name: `${burnedInSub.DisplayTitle || "Unknown"} (burned in)`, name: `${burnedInSub.DisplayTitle || "Unknown"} (burned in)`,
index: burnedInSub.Index ?? -1, index: burnedInSub.Index ?? -1,
@@ -239,12 +252,13 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
} }
} }
setSubtitleTracks(subs); commitSubtitleTracks(subs);
return; return;
} }
// MPV track handling // MPV track handling
const audioData = await playerControls.getAudioTracks().catch(() => null); const audioData = await playerControls.getAudioTracks().catch(() => null);
if (cancelled) return;
const playerAudio = (audioData as MpvAudioTrack[]) ?? []; const playerAudio = (audioData as MpvAudioTrack[]) ?? [];
const subs: Track[] = []; const subs: Track[] = [];
@@ -355,11 +369,14 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
// Already in jellyfin-web order (sorted iteration above); "Disable" stays // Already in jellyfin-web order (sorted iteration above); "Disable" stays
// at the front (unshifted), local downloaded subs at the end. // at the front (unshifted), local downloaded subs at the end.
setSubtitleTracks(subs); commitSubtitleTracks(subs);
setAudioTracks(audio); commitAudioTracks(audio);
}; };
fetchTracks(); fetchTracks();
return () => {
cancelled = true;
};
// api?.basePath: setTrack builds external-sub URLs from it — rebuild once the // api?.basePath: setTrack builds external-sub URLs from it — rebuild once the
// API is ready so online externals don't resolve with undefined. // API is ready so online externals don't resolve with undefined.
// isCurrentSubImageBased: setTrack closes over it for the transcode replacePlayer // isCurrentSubImageBased: setTrack closes over it for the transcode replacePlayer