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
+13 -7
View File
@@ -129,6 +129,9 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// Live mirror of `abs` for stable callbacks + the progress checkpoint (seek/skip/ended write it too).
const absRef = useRef(0);
absRef.current = abs;
// Where the current session was ASKED to start — set before the request goes out, so the subtitle
// poll has a position before `sessionK` (the session's answer) and before the first timeupdate.
const loadStartRef = useRef(0);
const [seekableEnd, setSeekableEnd] = useState(0); // absolute end of the loaded region
const { fs, toggleFs } = useFullscreen(wrapRef);
const [ready, setReady] = useState(false);
@@ -243,6 +246,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
async (startAt: number, resumePlay = true) => {
const video = videoRef.current;
if (!video) return;
loadStartRef.current = startAt;
setReady(false);
setBuffering(false);
setLoadError(null);
@@ -378,10 +382,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
selectedSub,
subTrackOffset,
subShowable,
subFail,
subNotice,
subGen,
subTrackKey,
onSubTrackError,
subTrackListeners,
} = usePlexTracks({
id,
detail,
@@ -396,6 +400,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
audioRef,
aoffRef,
absRef,
loadStartRef,
});
// Start playback when the detail arrives (resume from the saved position unless already watched).
@@ -882,7 +887,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
src={api.plexSubtitleUrl(id, selectedSub.ord, subTrackOffset, subGen)}
srcLang={selectedSub.language ?? undefined}
label={selectedSub.label}
onError={onSubTrackError}
{...subTrackListeners}
default
/>
)}
@@ -921,15 +926,16 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
(154 s for a 17 GB film, measured), so it happens in the background — around the playhead
first. Until then the <track> is deliberately NOT mounted, and this says why instead of
leaving the viewer to conclude that switching subtitles "does nothing" (bug hunt 2026-09,
item 8). It sits one rung above the track-switch OSD so the two never overlap. */}
item 8). A failure is said even over a mounted window (see `subNotice`). It sits one rung
above the track-switch OSD so the two never overlap. */}
<div
className="pointer-events-none absolute inset-x-0 bottom-36 z-menu flex justify-center px-4"
aria-live="polite"
>
{selectedSub && !subShowable && (
{selectedSub && subNotice && (
<div className="flex max-w-full items-center gap-2 truncate rounded-full border border-white/15 bg-black/75 px-4 py-2 text-sm text-white shadow-2xl">
{subFail ? (
<span className="text-danger">{t(`plex.player.subUnavailable.${subFail}`)}</span>
{subNotice !== "preparing" ? (
<span className="text-danger">{t(`plex.player.subUnavailable.${subNotice}`)}</span>
) : (
<>
<Loader2 className="h-4 w-4 shrink-0 animate-spin" aria-hidden />