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:
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user