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