fix(plex): the player drops a session answer that lands late, and Next neither loses nor lends a position

Deep-review round 3 of bug hunt 2026-09.

- loadSession checks, after its await, that it is still the newest call and the player is still
  mounted. A late answer used to replace what was playing (Next twice quickly: the slower transcode
  landed last and played the middle item under the last one's title, recording its progress there)
  or attach a stream to the detached <video> after Back, never destroyed.
- The <video>'s clock is trusted only once the new session has answered. go()'s pause() fires a
  timeupdate AFTER the reset to 0 (measured in Chrome), so the previous item's position was sent as
  the next item's subtitle position and saved as its progress (final, pushed to Plex).
- Next settles the item being left (final checkpoint + reopen-cache patch) before switching: the
  progress cleanup ran with the position already reset and skipped, so every Next dropped the last
  stretch watched.
- A seek-restart puts its target into the position state too; the ref alone was re-synced back to
  the pre-seek spot by the restart's own render.
- The unmount teardown captures the <video> at mount: React detaches refs before an unmount's
  cleanups, so the stop/detach never ran.
- The tracks panel exposes the selected audio/subtitle track via aria-pressed.

Verified in real Chrome on localdev (Westworld S1E6 → Next): the item left was settled at 783 s, the
next asked for subtitles at its own start (0), and no progress was written for it.
This commit is contained in:
2026-09-11 18:04:41 +02:00
parent 4f37c41021
commit 5c5cabbe5a
3 changed files with 373 additions and 35 deletions
+69 -17
View File
@@ -109,6 +109,20 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
const aliveRef = useRef(true);
const wrapRef = useRef<HTMLDivElement>(null);
const sessionStartRef = useRef(0); // absolute offset the current HLS/direct session begins at
// Which loadSession call is the current one. Everything after its `await` checks it: a response
// that lands after a newer call, an item change or the player's unmount is dropped — it used to
// replace whatever was playing (Next twice quickly, the slower transcode answering last, played the
// middle item under the last one's title and recorded its progress there) or attach a stream to the
// detached <video> after Back, never destroyed, keeping the server session warm.
const sessGenRef = useRef(0);
// Whether the <video>'s clock belongs to the session `sessionStartRef` describes. False from the
// moment a session is asked for until it has answered: the media still attached meanwhile is the
// OLD one, so `sessionStartRef + currentTime` was the previous item's position (go()'s pause() fires
// a timeupdate AFTER the reset to 0) — sent as the new item's subtitle position and saved as its
// progress, or, during a seek-restart, the pre-seek spot overwriting the target.
const clockLiveRef = useRef(false);
// The item whose session is attached to the <video> right now (see loadSession's failure path).
const attachedIdRef = useRef<string | null>(null);
// Which encoder the current transcode session ran: true=VAAPI hardware, false=libx264 software,
// null=not a transcode. Feeds the stats overlay's mode row ("transcode · GPU" / "· CPU").
const hwRef = useRef<boolean | null>(null);
@@ -247,6 +261,9 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
async (startAt: number, resumePlay = true) => {
const video = videoRef.current;
if (!video) return;
const gen = ++sessGenRef.current;
const current = () => gen === sessGenRef.current && aliveRef.current;
clockLiveRef.current = false;
loadStartRef.current = startAt;
setReady(false);
setBuffering(false);
@@ -262,6 +279,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
const m = multiAudioRef.current;
sess = await api.plexSession(id, startAt, m ? null : audioRef.current, aoffRef.current, m);
} catch (e) {
if (!current()) return; // superseded or left: not this request's error to show
// A failed seek-restart leaves the old session attached and its clock still true; a failed
// move to another item leaves the previous item's media, which is not this item's clock.
clockLiveRef.current = attachedIdRef.current === id;
// Surface WHY playback can't start instead of an eternal spinner. 404 = the physical file
// isn't reachable (missing media bind-mount or a wrong plex_path_map); 501 = the codec
// needs full transcoding (a later phase).
@@ -275,6 +296,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
);
return;
}
if (!current()) return;
// Surface the active mode for the stats overlay + the honest cold-start label: a transcode session
// re-encodes the video (hw = which encoder), and its first segment is genuinely slower than remux.
const isTranscode = sess.mode === "hls" && !!sess.transcode;
@@ -288,6 +310,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
sess.mode === "hls" && sess.start_s > 0 ? sess.start_s - HLS_SUB_LAG : sess.start_s;
sessionStartRef.current = trueStart;
setSessionK(trueStart);
// From here the <video>'s clock is this session's: the old source is replaced just below, and a
// timeupdate its teardown queues already reads the new start.
clockLiveRef.current = true;
attachedIdRef.current = id;
// Belt-and-suspenders resume: the play() fired on MANIFEST_PARSED / loadedmetadata can be
// rejected when the media isn't ready yet (slow first segments over a proxy), which would leave
// the video paused after a seek even though the user was watching. Retry once the element can
@@ -416,6 +442,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
const resume = detail.status !== "watched" ? detail.position_seconds || 0 : 0;
loadSession(resume);
return () => {
// A session still in flight for this item must not land after it. The LIVE counter is the point
// (bumping it is what outdates the pending call), so the "ref may have changed" hint is moot.
// eslint-disable-next-line react-hooks/exhaustive-deps
sessGenRef.current++;
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
@@ -433,15 +463,21 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// (StrictMode does exactly that in development). With the flag only ever lowered, it stayed false
// for the life of the player — every play() is gated on it, so the player never started by itself,
// and a seek that restarts the session left it paused (bug hunt 2026-09, found on localdev).
//
// The element is captured HERE, not read in the cleanup: React detaches a ref before it runs an
// unmount's effect cleanups, so `videoRef.current` was already null there and the stop/detach below
// never ran (only StrictMode's simulated unmount, which keeps refs, ever reached it).
useEffect(() => {
aliveRef.current = true;
const v = videoRef.current;
return () => {
aliveRef.current = false;
// eslint-disable-next-line react-hooks/exhaustive-deps -- the live counter, as above
sessGenRef.current++;
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
}
const v = videoRef.current;
if (v) {
try {
v.pause();
@@ -459,6 +495,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
const video = videoRef.current;
if (!video) return;
const onTime = () => {
if (!clockLiveRef.current) return; // the old session's clock (see clockLiveRef)
const a = sessionStartRef.current + video.currentTime;
setAbs(a);
try {
@@ -511,7 +548,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
}, [id]);
// Periodic + on-unmount progress checkpoint (interval flush + pause/pagehide + reopen cache patch).
usePlexProgress({ id, qc, videoRef, absRef, durationRef });
const { settle: settleProgress } = usePlexProgress({ id, qc, videoRef, absRef, durationRef });
// --- seeking (native within the loaded region, else restart the session) ---------------------
const seekTo = useCallback(
@@ -521,7 +558,11 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
const wasPlaying = !video.paused; // preserve play/pause intent across the seek
const dur = durationRef.current || target;
const T = Math.max(0, Math.min(target, dur - 1));
absRef.current = T; // reflect the target immediately so a save/beacon right after a seek is accurate
// Reflect the target immediately so a save/beacon right after a seek is accurate — in the STATE
// too: `absRef` is re-synced from `abs` on every render, and the restart below renders at once,
// so the ref alone was back at the pre-seek spot before anything could read it.
absRef.current = T;
setAbs(T);
const rel = T - sessionStartRef.current;
if (rel >= 0 && rel <= (video.seekable.length ? video.seekable.end(0) : 0) + 0.5) {
video.currentTime = rel; // native seek within the loaded region keeps playing on its own
@@ -551,21 +592,29 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
helpOpenRef,
});
const go = useCallback((otherId: string | null | undefined) => {
if (otherId) {
// Stop the outgoing media BEFORE switching. If the next item fails to load (e.g. no local
// file), loadSession returns without replacing the <video> source, leaving the old episode
// still playing — it would then fire 'ended' against the NEW id and mark the wrong item
// watched (and scrobble it to Plex). Pausing here severs that stale-tail race.
try {
videoRef.current?.pause();
} catch {
/* ignore */
const go = useCallback(
(otherId: string | null | undefined) => {
if (otherId) {
// Stop the outgoing media BEFORE switching. If the next item fails to load (e.g. no local
// file), loadSession returns without replacing the <video> source, leaving the old episode
// still playing — it would then fire 'ended' against the NEW id and mark the wrong item
// watched (and scrobble it to Plex). Pausing here severs that stale-tail race.
try {
videoRef.current?.pause();
} catch {
/* ignore */
}
// Settle the item being left while the position still is its own (see usePlexProgress), and
// stop trusting the old media's clock: the timeupdate that pause() queues lands after the
// reset below and would otherwise hand the old position to the new item.
settleProgress();
clockLiveRef.current = false;
setAbs(0);
setId(otherId);
}
setAbs(0);
setId(otherId);
}
}, []);
},
[settleProgress],
);
// Prev/next: from the playlist queue when playing one, else the item's own episode neighbours.
const qIdx = queue ? queue.indexOf(id) : -1;
@@ -1250,6 +1299,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
<button
key={a.ord}
onClick={() => changeAudio(a.ord)}
aria-pressed={(audioOrd ?? 0) === a.ord}
className={`block w-full truncate rounded px-2 py-1 text-left hover:bg-white/10 ${
(audioOrd ?? 0) === a.ord ? "text-accent" : "text-white"
}`}
@@ -1264,6 +1314,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
<PanelHead>{t("plex.player.subtitles")}</PanelHead>
<button
onClick={() => changeSubtitle(null)}
aria-pressed={subOrd == null}
className={`block w-full rounded px-2 py-1 text-left hover:bg-white/10 ${
subOrd == null ? "text-accent" : "text-white"
}`}
@@ -1274,6 +1325,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
<button
key={s.ord}
onClick={() => changeSubtitle(s.ord)}
aria-pressed={subOrd === s.ord}
className={`block w-full truncate rounded px-2 py-1 text-left hover:bg-white/10 ${
subOrd === s.ord ? "text-accent" : "text-white"
}`}