fix(plex): a subtitle track's failed load is heard and retried with a pause, and the poll asks about the right item, position and failure

- The <track>'s `error` never reached `onSubTrackError`: it does not bubble, and React 18 attaches no
  listener of its own to a <track> (measured in Chrome and jsdom — `onError` 0 calls, `onErrorCapture`
  1). Round 1's remount-and-give-up was dead code behind it. The hook now hands the player
  `onErrorCapture`, and the test fires a real non-bubbling `error` at a rendered <track>.
- Once reachable, the retry burned its whole budget within milliseconds of an outage (a remount
  fetches at once) and the give-up outlived turning subtitles off and on. Retries now wait (2 s,
  doubling), running out is reported at once, and picking the track again starts a fresh budget.
- Until the first timeupdate the poll sent `sessionK` — still 0 while the session request was out,
  and the PREVIOUS item's start after a move to the next episode. It now sends where the session was
  asked to start, and the track choice belongs to the item it was made for, so the next item is not
  asked about the old ordinal at the old position.
- A failing poll over a stale `partial` answer was silent: react-query keeps the last good data, the
  track stayed "showable", and the notice only rendered for an unmounted track. The hook now decides
  what the player says (`subNotice`), independently of whether a window is mounted.
This commit is contained in:
2026-09-11 03:18:42 +02:00
parent 7360339039
commit 645d14f45d
3 changed files with 234 additions and 64 deletions
+84 -26
View File
@@ -349,10 +349,12 @@ export function useAutoSkip(opts: {
* fold happens here rather than multiplying near-identical translations. */
export type SubFailReason = "bitmap" | "missing" | "failed";
// The subtitle status poll's cadence after a FAILED request (see usePlexTracks), and how many times a
// <track> whose own VTT fetch failed is remounted before the track is reported as unavailable.
// The subtitle status poll's cadence after a FAILED request (see usePlexTracks), how many times a
// <track> whose own VTT fetch failed is remounted before the track is reported as unavailable, and
// the first of the (doubling) pauses before each remount.
const SUB_POLL_ERROR_MS = 10_000;
const SUB_TRACK_RETRIES = 2;
const SUB_TRACK_RETRY_MS = 2_000;
function subFailReason(code: string | null | undefined): SubFailReason {
if (code === "bitmap") return "bitmap";
@@ -375,6 +377,9 @@ export function usePlexTracks(opts: {
audioRef: MutableRefObject<number | null>;
aoffRef: MutableRefObject<number>;
absRef: MutableRefObject<number>;
/** The position the current (or still in-flight) session was ASKED to start at — written before
* the session request goes out, unlike `sessionK`, which only lands once it has answered. */
loadStartRef: MutableRefObject<number>;
}): {
audioOrd: number | null;
setAudioOrd: (ord: number | null) => void;
@@ -394,8 +399,10 @@ export function usePlexTracks(opts: {
/** The selected track has something to render (a window around the playhead, or the whole thing).
* False means "not yet" — the caller must NOT mount a <track>, it would just sit blank. */
subShowable: boolean;
/** The selected track can never be shown, bucketed for the message the player puts on screen. */
subFail: SubFailReason | null;
/** What the player must SAY about the selected track, if anything: still being prepared, or why it
* can't be shown (bucketed for the sentence the player has). Independent of `subShowable` — a
* failing status poll is worth saying even while an earlier window is still mounted. */
subNotice: "preparing" | SubFailReason | null;
/** The cached VTT's generation — it grows as the background extraction widens the track, so it
* belongs in the <track> key/URL or the browser keeps serving the first, short window. */
subGen: number;
@@ -404,14 +411,25 @@ export function usePlexTracks(opts: {
* change), the extraction generation (a widened track replaces the first short window), and the
* failed-load count (a <track> whose fetch failed is replaced rather than left dead). */
subTrackKey: string;
onSubTrackError: () => void;
/** Spread onto the <track>. Its `error` event does not bubble, and React 18 attaches no listener of
* its own to a <track> — a plain `onError` there is never called (measured in Chrome and jsdom:
* 0 calls, while `onErrorCapture` fires). The capture phase reaches the target regardless. */
subTrackListeners: { onErrorCapture: () => void };
} {
const { id, detail, sessionK, subOffset, subPos, loadSession, patchPrefs } = opts;
const { videoRef, hlsRef, multiAudioRef, audioRef, aoffRef, absRef } = opts;
const { videoRef, hlsRef, multiAudioRef, audioRef, aoffRef, absRef, loadStartRef } = opts;
const qc = useQueryClient();
// Selected audio / subtitle stream ordinals (null audio = default first; null subtitle = off).
const [audioOrd, setAudioOrd] = useState<number | null>(null);
const [subOrd, setSubOrd] = useState<number | null>(null);
// The subtitle choice belongs to the ITEM it was made for. Moving to the next episode used to
// carry the old ordinal over until the new item's detail re-derived it, so the status poll asked
// the new item about a track number that may be a different language there, at the old item's
// position. Stored with its id, it simply reads as "off" on any other item.
const [subSel, setSubSel] = useState<{ id: string; ord: number | null }>({ id, ord: null });
const subOrd = subSel.id === id ? subSel.ord : null;
const setSubOrd = useCallback((ord: number | null) => setSubSel({ id, ord }), [id]);
// Failed-<track>-load count for one track generation (see onSubTrackError below).
const [loadFails, setLoadFails] = useState({ key: "", n: 0 });
// Debounced user subtitle offset: avoids re-fetching the VTT on every slider step while dragging.
const [subOffApplied, setSubOffApplied] = useState(subOffset);
useEffect(() => {
@@ -448,12 +466,15 @@ export function usePlexTracks(opts: {
const changeSubtitle = useCallback(
(ord: number | null) => {
setSubOrd(ord);
// Picking a track (the same one included) is the viewer asking for it NOW: a track given up on
// after failed loads gets a fresh budget instead of repeating "couldn't be loaded" unasked.
setLoadFails({ key: "", n: 0 });
patchPrefs({
subLang:
ord == null ? "" : (detail?.subtitle_streams.find((s) => s.ord === ord)?.language ?? ""),
});
},
[detail, patchPrefs],
[detail, patchPrefs, setSubOrd],
);
// 'A' cycles audio tracks; 'S' cycles subtitle (off → each text sub → off).
const cycleAudio = useCallback(() => {
@@ -499,10 +520,16 @@ export function usePlexTracks(opts: {
// The position is only known once playback has ticked: until the first timeupdate `abs` is 0
// even though a resumed session starts minutes in — and every poll said `at=0`, so the first
// window was cut around the head of the film instead of where the viewer is (measured on
// localdev: a resume at 20:00 polled `at=0` for as long as it stayed paused). The session's own
// start is the best answer until then.
// localdev: a resume at 20:00 polled `at=0` for as long as it stayed paused). Until then the
// answer is where the session was asked to start. Not `sessionK`: that only lands once the
// session request has answered, so the first poll of a fresh open read 0 from it, and after a
// move to the next item it still held the PREVIOUS item's start.
queryFn: () =>
api.plexSubtitleStatus(id, subOrd as number, absRef.current > 0 ? absRef.current : sessionK),
api.plexSubtitleStatus(
id,
subOrd as number,
absRef.current > 0 ? absRef.current : loadStartRef.current,
),
enabled: subOrd != null,
// Keep polling until the track is COMPLETE, not just showable: each poll carries the current
// position, so seeking past the extracted window is what asks for the next one. A settled
@@ -531,27 +558,58 @@ export function usePlexTracks(opts: {
// keeps the dead element and the subtitles stay silently blank. So a failed load bumps a counter
// that is part of the key — a fresh element, a fresh fetch — and after SUB_TRACK_RETRIES the track
// is reported as unavailable instead of left blank. The count belongs to one track generation: a
// different track, item or `gen` starts from zero.
// different track, item or `gen` starts from zero, and so does picking the track again.
const loadKey = `${id}:${subOrd}:${subStatus?.gen ?? 0}`;
const [loadFails, setLoadFails] = useState({ key: "", n: 0 });
const subRetry = loadFails.key === loadKey ? loadFails.n : 0;
const subGaveUp = subRetry > SUB_TRACK_RETRIES;
// Mount the <track> for a `partial` track too: a window around the playhead is exactly what the
// viewer needs right now, and the poll widens it underneath them.
const subShowable =
(subStatus?.state === "ready" || subStatus?.state === "partial") && !subGaveUp;
const subFail: SubFailReason | null =
(subStatusQ.isError || subGaveUp) && subOrd != null
? "failed"
: subStatus?.state === "failed"
? subFailReason(subStatus.code)
: null;
// A failing poll is said even while an earlier window stays mounted: react-query keeps the last
// good `data` through a failed refetch, so a `partial` answer kept the track "showable" and the
// failure had nowhere to appear — blank subtitles past the window, no spinner, no message.
const subNotice: "preparing" | SubFailReason | null =
subOrd == null
? null
: subStatusQ.isError || subGaveUp
? "failed"
: subStatus?.state === "failed"
? subFailReason(subStatus.code)
: subShowable
? null
: "preparing";
// A remount fetches at once, so remounting straight away spent the whole budget inside a
// one-second outage (an API restart): three failures in a few milliseconds, then "unavailable"
// for a track that would have loaded a moment later. Each retry waits, doubling; running out is
// reported at once. A pending retry dies with the generation it was for.
const retryTimer = useRef<number | null>(null);
useEffect(
() => () => {
if (retryTimer.current != null) window.clearTimeout(retryTimer.current);
retryTimer.current = null;
},
[loadKey],
);
const onSubTrackError = useCallback(() => {
setLoadFails((f) => ({ key: loadKey, n: (f.key === loadKey ? f.n : 0) + 1 }));
// …and ask again: if the cached VTT was evicted between the poll and the fetch, the answer drops
// back to `preparing` and the poll takes over until it has been re-extracted.
if (subOrd != null) qc.invalidateQueries({ queryKey: qk.plexSubtitle(id, subOrd) });
}, [qc, id, subOrd, loadKey]);
const bump = () =>
setLoadFails((f) => ({ key: loadKey, n: (f.key === loadKey ? f.n : 0) + 1 }));
if (subRetry >= SUB_TRACK_RETRIES) {
bump();
return;
}
if (retryTimer.current != null) window.clearTimeout(retryTimer.current);
retryTimer.current = window.setTimeout(
() => {
retryTimer.current = null;
bump();
// …and ask again: if the cached VTT was evicted between the poll and the fetch, the answer
// drops back to `preparing` and the poll takes over until it has been re-extracted.
if (subOrd != null) qc.invalidateQueries({ queryKey: qk.plexSubtitle(id, subOrd) });
},
SUB_TRACK_RETRY_MS * 2 ** subRetry,
);
}, [qc, id, subOrd, loadKey, subRetry]);
// Text subtitles offered in the picker; image subs (PGS/VobSub) can't be shown as text.
const textSubs = (detail?.subtitle_streams ?? []).filter((s) => s.text);
@@ -620,9 +678,9 @@ export function usePlexTracks(opts: {
selectedSub,
subTrackOffset,
subShowable,
subFail,
subNotice,
subGen: subStatus?.gen ?? 0,
subTrackKey: `${id}-${subOrd}-${subTrackOffset.toFixed(2)}-${subStatus?.gen ?? 0}-${subRetry}`,
onSubTrackError,
subTrackListeners: { onErrorCapture: onSubTrackError },
};
}