diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index e7f3242d..e3cecf74 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -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 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. */}
- {selectedSub && !subShowable && ( + {selectedSub && subNotice && (
- {subFail ? ( - {t(`plex.player.subUnavailable.${subFail}`)} + {subNotice !== "preparing" ? ( + {t(`plex.player.subUnavailable.${subNotice}`)} ) : ( <> diff --git a/frontend/src/lib/plexPlayerHooks.subtitle.dom.test.tsx b/frontend/src/lib/plexPlayerHooks.subtitle.dom.test.tsx index 3bb046da..462fde58 100644 --- a/frontend/src/lib/plexPlayerHooks.subtitle.dom.test.tsx +++ b/frontend/src/lib/plexPlayerHooks.subtitle.dom.test.tsx @@ -10,7 +10,7 @@ // window around the playhead first, and this hook's poll is what decides when there is something to // mount and what to say while there isn't. import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; -import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; +import { act, cleanup, render, renderHook, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { createRef, type ReactNode, type MutableRefObject } from "react"; import { usePlexTracks } from "./plexPlayerHooks"; @@ -32,14 +32,14 @@ const detail = (): PlexItemDetail => markers: [], }) as unknown as PlexItemDetail; -function mount(qc: QueryClient, { sessionK = 0, abs = 3600 } = {}) { +function mount(qc: QueryClient, { sessionK = 0, abs = 3600, loadStart = 0 } = {}) { const wrapper = ({ children }: { children: ReactNode }) => ( {children} ); return renderHook( - () => + ({ id }: { id: string }) => usePlexTracks({ - id: RK, + id, detail: detail(), sessionK, subOffset: 0, @@ -52,8 +52,9 @@ function mount(qc: QueryClient, { sessionK = 0, abs = 3600 } = {}) { audioRef: { current: 0 } as MutableRefObject, aoffRef: { current: 0 }, absRef: { current: abs }, + loadStartRef: { current: loadStart }, }), - { wrapper }, + { wrapper, initialProps: { id: RK } }, ); } @@ -80,7 +81,7 @@ describe("usePlexTracks — the waits for the extraction", () => { // The selected track exists and is offered in the picker — it just cannot be rendered yet, and // the caller must be told so rather than mounting an empty . expect(result.current.subShowable).toBe(false); - expect(result.current.subFail).toBeNull(); + expect(result.current.subNotice).toBe("preparing"); }); it("sends the viewer's position, so the extraction windows around where they are", async () => { @@ -92,18 +93,35 @@ describe("usePlexTracks — the waits for the extraction", () => { await waitFor(() => expect(status).toHaveBeenCalledWith(RK, 1, 3600)); }); - it("sends the session's start before playback has ticked, not the head of the film", async () => { + it("sends where the session was asked to start before playback has ticked, not the head of the film", async () => { // A resumed session starts minutes in, but `abs` stays 0 until the first timeupdate — and every // poll used to say at=0, so the first window was cut around the head of the film (measured on - // localdev: a resume at 20:00 polled at=0 for as long as it stayed paused). + // localdev: a resume at 20:00 polled at=0 for as long as it stayed paused). `sessionK` is no + // substitute: it is still 0 here, because the session request has not answered yet. const status = vi .spyOn(api, "plexSubtitleStatus") .mockResolvedValue({ state: "preparing", code: null, gen: 0 }); - const { result } = mount(client(), { sessionK: 1198, abs: 0 }); + const { result } = mount(client(), { sessionK: 0, abs: 0, loadStart: 1198 }); result.current.setSubOrd(1); await waitFor(() => expect(status).toHaveBeenCalledWith(RK, 1, 1198)); }); + it("does not carry the track choice to the next item — that item's detail decides it", async () => { + // Carried over, the old ordinal polled the NEW item (where it may be another language) at the + // OLD item's position, queueing an extraction of the wrong stretch of the next file. + const status = vi + .spyOn(api, "plexSubtitleStatus") + .mockResolvedValue({ state: "preparing", code: null, gen: 0 }); + const { result, rerender } = mount(client()); + result.current.setSubOrd(1); + await waitFor(() => expect(status).toHaveBeenCalledWith(RK, 1, 3600)); + rerender({ id: "NEXT" }); + expect(result.current.subOrd).toBeNull(); + expect(result.current.subNotice).toBeNull(); + await act(async () => {}); + expect(status.mock.calls.some(([id]) => id === "NEXT")).toBe(false); + }); + it("becomes showable on a PARTIAL track — a window around the playhead is what is needed now", async () => { vi.spyOn(api, "plexSubtitleStatus").mockResolvedValue({ state: "partial", @@ -126,7 +144,7 @@ describe("usePlexTracks — the waits for the extraction", () => { }); const { result } = mount(client()); result.current.setSubOrd(0); - await waitFor(() => expect(result.current.subFail).toBe("bitmap")); + await waitFor(() => expect(result.current.subNotice).toBe("bitmap")); expect(result.current.subShowable).toBe(false); }); @@ -138,7 +156,7 @@ describe("usePlexTracks — the waits for the extraction", () => { }); const { result } = mount(client()); result.current.setSubOrd(0); - await waitFor(() => expect(result.current.subFail).toBe("missing")); + await waitFor(() => expect(result.current.subNotice).toBe("missing")); }); it("asks nothing at all while subtitles are off", async () => { @@ -196,7 +214,7 @@ describe("usePlexTracks — when the status poll asks again, and when it stops", const status = vi.spyOn(api, "plexSubtitleStatus").mockRejectedValue(new Error("offline")); const { result } = mount(client()); result.current.setSubOrd(0); - await waitFor(() => expect(result.current.subFail).toBe("failed")); + await waitFor(() => expect(result.current.subNotice).toBe("failed")); expect(status).toHaveBeenCalledTimes(1); await vi.advanceTimersByTimeAsync(5000); expect(status).toHaveBeenCalledTimes(1); @@ -215,29 +233,117 @@ describe("usePlexTracks — when the status poll asks again, and when it stops", await expect(api.plexSubtitleStatus(RK, 0, 0)).rejects.toThrow(); expect(getError()).toBeNull(); }); -}); -describe("usePlexTracks — a whose own VTT fetch failed", () => { - afterEach(() => { - cleanup(); - vi.restoreAllMocks(); - }); - - it("is replaced by a fresh element a couple of times, then reported instead of left blank", async () => { - // A failed never fetches again, and while the server's answer is unchanged its key was - // unchanged too — so React kept the dead element and the subtitles stayed silently blank. - vi.spyOn(api, "plexSubtitleStatus").mockResolvedValue({ state: "partial", code: null, gen: 5 }); + it("says a failing poll even while an earlier window is still on screen", async () => { + // react-query keeps the last good answer through a failed refetch, so a `partial` track stayed + // "showable" and the failure had nowhere to appear: past the window, blank subtitles and nothing + // on screen saying why — with the request `quiet`, not even the global dialog. + vi.spyOn(api, "plexSubtitleStatus") + .mockResolvedValueOnce({ state: "partial", code: null, gen: 5 }) + .mockRejectedValue(new Error("offline")); const { result } = mount(client()); result.current.setSubOrd(0); await waitFor(() => expect(result.current.subShowable).toBe(true)); - for (let i = 0; i < 2; i++) { - const before = result.current.subTrackKey; - act(() => result.current.onSubTrackError()); - await waitFor(() => expect(result.current.subTrackKey).not.toBe(before)); - expect(result.current.subShowable).toBe(true); + expect(result.current.subNotice).toBeNull(); + await vi.advanceTimersByTimeAsync(2100); + await waitFor(() => expect(result.current.subNotice).toBe("failed")); + expect(result.current.subShowable).toBe(true); // the window it has is still worth showing + }); +}); + +// Rendered for real, not by calling the hook's callback: the failure arrives as the 's own +// `error` event, which does not bubble — and React 18 attaches no listener of its own to a , +// so the `onError` this used to be wired through was never called in a browser at all. +function TrackHarness() { + const tracks = usePlexTracks({ + id: RK, + detail: detail(), + sessionK: 0, + subOffset: 0, + subPos: 88, + loadSession: async () => {}, + patchPrefs: () => {}, + videoRef: createRef(), + hlsRef: createRef(), + multiAudioRef: { current: false }, + audioRef: { current: 0 } as MutableRefObject, + aoffRef: { current: 0 }, + absRef: { current: 3600 }, + loadStartRef: { current: 0 }, + }); + harness.current = tracks; + return ( + + ); +} +const harness: { current: ReturnType | null } = { current: null }; + +function renderTrack(qc: QueryClient) { + const view = render( + + + , + ); + const track = () => view.container.querySelector("track"); + // What the browser does when the VTT fetch fails: a plain, NON-bubbling `error` on the element. + const fail = () => act(() => void track()!.dispatchEvent(new Event("error"))); + return { track, fail }; +} + +describe("usePlexTracks — a whose own VTT fetch failed", () => { + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.spyOn(api, "plexSubtitleStatus").mockResolvedValue({ state: "ready", code: null, gen: 5 }); + }); + afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("is replaced by a fresh element, after a pause, a couple of times — then reported instead of left blank", async () => { + // A failed never fetches again, and while the server's answer is unchanged its key was + // unchanged too — so React kept the dead element and the subtitles stayed silently blank. The + // pause matters as much: remounting at once spent the whole budget inside a one-second outage. + const { track, fail } = renderTrack(client()); + act(() => harness.current!.setSubOrd(0)); + await waitFor(() => expect(track()).not.toBeNull()); + for (const pause of [2000, 4000]) { + const before = track()!.dataset.key; + fail(); + await vi.advanceTimersByTimeAsync(pause - 100); + expect(track()!.dataset.key).toBe(before); // not yet: still waiting out the pause + await vi.advanceTimersByTimeAsync(200); + await waitFor(() => expect(track()!.dataset.key).not.toBe(before)); } - act(() => result.current.onSubTrackError()); - await waitFor(() => expect(result.current.subShowable).toBe(false)); - expect(result.current.subFail).toBe("failed"); + fail(); + await waitFor(() => expect(track()).toBeNull()); + expect(harness.current!.subNotice).toBe("failed"); + }); + + it("gets a fresh budget when the viewer picks the track again", async () => { + // The give-up belongs to one track generation, and a `ready` track is no longer polled, so no new + // generation would ever come: turning subtitles off and on used to leave "couldn't be loaded". + const { track, fail } = renderTrack(client()); + act(() => harness.current!.setSubOrd(0)); + await waitFor(() => expect(track()).not.toBeNull()); + for (let i = 0; i < 3; i++) { + fail(); + await vi.advanceTimersByTimeAsync(4100); + } + await waitFor(() => expect(track()).toBeNull()); + act(() => harness.current!.changeSubtitle(null)); + act(() => harness.current!.changeSubtitle(0)); + await waitFor(() => expect(track()).not.toBeNull()); + expect(harness.current!.subNotice).toBeNull(); }); }); diff --git a/frontend/src/lib/plexPlayerHooks.ts b/frontend/src/lib/plexPlayerHooks.ts index 4ccadaa6..051d790f 100644 --- a/frontend/src/lib/plexPlayerHooks.ts +++ b/frontend/src/lib/plexPlayerHooks.ts @@ -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 -// 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 +// 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; aoffRef: MutableRefObject; absRef: MutableRefObject; + /** 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; }): { 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 , 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 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 whose fetch failed is replaced rather than left dead). */ subTrackKey: string; - onSubTrackError: () => void; + /** Spread onto the . Its `error` event does not bubble, and React 18 attaches no listener of + * its own to a — 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(null); - const [subOrd, setSubOrd] = useState(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--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 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(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 }, }; }