fix(plex): the player restarts a reaped session, finishes a played-through item as watched, and starts each item once

Deep-review round 4, the Plex player.

- A 404 on an HLS session that WAS playing means the server reaped it (a long pause, a deploy);
  hls.js never retries a 4xx, so playback ended with "couldn't play". A new session at the playhead
  now takes its place (the frontend half of the paused-viewer reap fix). A session that 404s before
  its manifest is still a plain failure, so it cannot restart itself in a loop.
- Leaving an item that ended, or that the credits auto-skip left, sent a final checkpoint at the
  credits (racing the watched mark, and pushing a resume point to Plex after the scrobble) and
  patched "new, at 49:55" into the cache — Prev reopened it at its last seconds. `finish` settles it
  as watched at 0, said before the pause whose own checkpoint could land first.
- An audio switch, an A/V offset change and every seek control read `abs`, which is 0 until
  playback ticks: a resume at 20:00 restarted from 0:00. They use the shared `playhead`.
- A seek while a restart is pending always restarts (the old media's `seekable` described the old
  session, and the pending answer overrode the newer seek).
- A superseded direct-play session's loadedmetadata/canplay are generation-gated and detached (they
  seeked the next item to the previous one's resume point).
- The session starts once per item: a reconnect refetch, the info panel's watched toggle or a
  language switch no longer restarts the film at the last checkpoint.
- Tests for the session generation's failure paths and the item-change bump, which had none.
This commit is contained in:
2026-09-11 23:18:33 +02:00
parent 31dc4184f5
commit 6a92585098
4 changed files with 552 additions and 54 deletions
+106 -31
View File
@@ -45,6 +45,7 @@ import { createFragMeter } from "../lib/plexStats";
import { UpNextCard } from "./UpNextCard";
import Toaster from "./ui/Toaster";
import {
playhead,
useAutoSkip,
useControlsVisibility,
useFullscreen,
@@ -115,6 +116,12 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// 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);
// Removes the media listeners the current session put on the <video> (its canplay / loadedmetadata
// handlers). A newer session, an item change and the unmount all call it: the generation check
// guards the `await`, and these fire LATER — a superseded direct-play item's loadedmetadata used to
// run on the next item's media and seek it to the old item's resume point (whose first checkpoint
// then saved it), or start the old media playing under the new title.
const detachSessionRef = useRef<() => void>(() => {});
// 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
@@ -263,6 +270,19 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
if (!video) return;
const gen = ++sessGenRef.current;
const current = () => gen === sessGenRef.current && aliveRef.current;
detachSessionRef.current();
detachSessionRef.current = () => {};
const listen = (type: string, fn: () => void, once = false) => {
const guarded = () => {
if (current()) fn();
};
video.addEventListener(type, guarded, { once });
const prev = detachSessionRef.current;
detachSessionRef.current = () => {
prev();
video.removeEventListener(type, guarded);
};
};
clockLiveRef.current = false;
loadStartRef.current = startAt;
setReady(false);
@@ -319,10 +339,13 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// the video paused after a seek even though the user was watching. Retry once the element can
// actually play. Idempotent — only acts if still paused.
if (resumePlay) {
const onCanPlay = () => {
if (aliveRef.current && videoRef.current?.paused) videoRef.current.play().catch(() => {});
};
video.addEventListener("canplay", onCanPlay, { once: true });
listen(
"canplay",
() => {
if (videoRef.current?.paused) videoRef.current.play().catch(() => {});
},
true,
);
}
if (hlsRef.current) {
hlsRef.current.destroy();
@@ -353,7 +376,11 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
hls.loadSource(`${sess.url}?t=${Math.floor(startAt)}`);
hls.attachMedia(video);
// Subtitles are native <track>s (not muxed into the HLS), so nothing subtitle-related here.
// Whether this session has ever played. Only such a session can be "lost" (below): one that
// fails before its manifest is simply a failure, and must not restart itself in a loop.
let parsed = false;
hls.on(Hls.Events.MANIFEST_PARSED, () => {
parsed = true;
setReady(true);
if (aliveRef.current && resumePlay) video.play().catch(() => {});
});
@@ -374,20 +401,32 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
});
// A fatal HLS error (e.g. the remux session died / the file became unreadable) would
// otherwise leave the spinner up forever — surface it instead.
//
// Except a 404 on a session that WAS playing: the server has reaped it (a pause longer than
// its idle limit, a deploy) and the stream is simply gone, not broken. hls.js never retries a
// 4xx, so this used to end playback with "couldn't play" after the buffer ran out; a new
// session at the playhead is what closing and reopening the player did by hand.
hls.on(Hls.Events.ERROR, (_e, data) => {
if (data.fatal) setLoadError(tRef.current("plex.player.errGeneric"));
if (!data.fatal || !current()) return;
if (parsed && data.type === Hls.ErrorTypes.NETWORK_ERROR && data.response?.code === 404) {
loadSession(playhead(absRef, loadStartRef), !video.paused);
return;
}
setLoadError(tRef.current("plex.player.errGeneric"));
});
} else {
// direct file (or Safari native HLS)
video.src = sess.url;
video.load();
const onMeta = () => {
if (sess.mode === "direct" && startAt > 0) video.currentTime = startAt;
setReady(true);
if (aliveRef.current && resumePlay) video.play().catch(() => {});
video.removeEventListener("loadedmetadata", onMeta);
};
video.addEventListener("loadedmetadata", onMeta);
listen(
"loadedmetadata",
() => {
if (sess.mode === "direct" && startAt > 0) video.currentTime = startAt;
setReady(true);
if (resumePlay) video.play().catch(() => {});
},
true,
);
}
},
[id], // NOT `t` — see tRef above (a language switch must not rebuild this / reload the session).
@@ -430,9 +469,18 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
loadStartRef,
});
// Start playback when the detail arrives (resume from the saved position unless already watched).
// Start playback when the detail arrives (resume from the saved position unless already watched) —
// ONCE per item. The detail object changes under a playing item too: react-query refetches it on
// reconnect (Wi-Fi blips, the query is stale after 30 s and every checkpoint moved the position),
// the in-player info panel invalidates it after a watched toggle, a language switch fetches it
// again. Keyed on its identity, each of those restarted the film at the last saved checkpoint — or
// at 0 after marking it watched (deep-review round 4). The trade-off: a detail that was stale in
// the cache when the player opened is not corrected by the refetch that follows; the player's own
// leave-patch (usePlexProgress) keeps the cache honest for everything played here.
const startedForRef = useRef<string | null>(null);
useEffect(() => {
if (!detail) return;
if (!detail || detail.id !== id || startedForRef.current === id) return;
startedForRef.current = id;
// Restore the per-account audio/subtitle language choice, resolved to THIS item's tracks. Set the
// audio ref synchronously so the loadSession below picks the right track on the first request.
const aOrd = audioOrdForLang(detail.audio_streams ?? [], prefs.audioLang);
@@ -441,20 +489,24 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
setSubOrd(subOrdForLang(detail.subtitle_streams ?? [], prefs.subLang));
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
// Prefs are applied at item load only; changing one via the picker must NOT re-run this (it would
// double-load the session — the picker handlers already reload/toggle).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [detail, id, loadSession]);
// Leaving the item: a session still in flight for it must not land after it, and its stream goes.
useEffect(
() => () => {
sessGenRef.current++;
detachSessionRef.current();
detachSessionRef.current = () => {};
startedForRef.current = null;
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
}
};
// Prefs are applied at item load only; changing one via the picker must NOT re-run this (it would
// double-load the session — the picker handlers already reload/toggle).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [detail, loadSession]);
},
[id],
);
// Full media teardown on unmount (Back): stop + detach the <video> and destroy hls, so a late
// play() can't leave audio playing on a detached element after leaving the player.
@@ -548,7 +600,13 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
}, [id]);
// Periodic + on-unmount progress checkpoint (interval flush + pause/pagehide + reopen cache patch).
const { settle: settleProgress } = usePlexProgress({ id, qc, videoRef, absRef, durationRef });
const { settle: settleProgress, finish: finishProgress } = usePlexProgress({
id,
qc,
videoRef,
absRef,
durationRef,
});
// --- seeking (native within the loaded region, else restart the session) ---------------------
const seekTo = useCallback(
@@ -564,7 +622,14 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
absRef.current = T;
setAbs(T);
const rel = T - sessionStartRef.current;
if (rel >= 0 && rel <= (video.seekable.length ? video.seekable.end(0) : 0) + 0.5) {
// Native only when the attached media IS the current session. While one is still being asked
// for, `sessionStartRef` and `seekable` describe the OLD one: a native seek there left the
// pending restart current, and its answer then jumped to the earlier target.
if (
clockLiveRef.current &&
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
} else {
loadSession(T, wasPlaying); // session restart: only auto-resume if we were playing
@@ -593,8 +658,12 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
});
const go = useCallback(
(otherId: string | null | undefined) => {
(otherId: string | null | undefined, finished = false) => {
if (otherId) {
// A played-through item settles as watched, with no checkpoint at the credits (see
// usePlexProgress's `finish`) — said BEFORE the pause below, whose own checkpoint on the
// 'pause' event can land before the player has re-rendered for the next item.
if (finished) finishProgress();
// 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
@@ -613,7 +682,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
setId(otherId);
}
},
[settleProgress],
[settleProgress, finishProgress],
);
// Prev/next: from the playlist queue when playing one, else the item's own episode neighbours.
@@ -669,6 +738,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// (absRef 0) — its stray 'ended' must not mark anything watched or cascade another advance.
if (absRef.current <= 0) return;
api.plexSetState(id, "watched").catch(() => {});
// Whatever follows (the up-next card, the next item, closing) leaves a FINISHED item.
finishProgress();
// With a next item and the up-next pref on, surface the end-card (poster + countdown + Cancel)
// and let IT advance — binge stays in the viewer's control (S4/D6-A). The card only appears here,
// on play-to-the-end; credits-auto-skip advances earlier and this handler never fires for it.
@@ -680,7 +751,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
};
video.addEventListener("ended", onEnded);
return () => video.removeEventListener("ended", onEnded);
}, [id, nextId, go, onClose]);
}, [id, nextId, go, onClose, finishProgress]);
// Up-next card actions: advance = go to the next item (it was already marked watched on 'ended');
// cancel = dismiss and stay on the finished frame. Loading a new item closes any lingering card.
@@ -738,7 +809,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
useEffect(() => {
const seekBy = (e: KeyboardEvent, dir: -1 | 1, fine: boolean) => {
e.preventDefault();
seekTo(absRef.current + dir * (fine ? fineSeekStepRef.current : seekStepRef.current));
const step = fine ? fineSeekStepRef.current : seekStepRef.current;
seekTo(playhead(absRef, loadStartRef) + dir * step);
};
const step = (e: KeyboardEvent, id: string | null | undefined) => {
e.preventDefault();
@@ -1176,7 +1248,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
<div className="flex items-center gap-3 text-white">
<Ctrl
label={t("plex.player.rewind10")}
onClick={() => seekTo(absRef.current - 10)}
onClick={() => seekTo(playhead(absRef, loadStartRef) - 10)}
align="start"
>
<Skip10 dir="back" />
@@ -1184,7 +1256,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
<Ctrl label={t("plex.player.playPause")} onClick={togglePlay}>
{playing ? <Pause className="w-6 h-6" /> : <Play className="w-6 h-6" />}
</Ctrl>
<Ctrl label={t("plex.player.forward10")} onClick={() => seekTo(absRef.current + 10)}>
<Ctrl
label={t("plex.player.forward10")}
onClick={() => seekTo(playhead(absRef, loadStartRef) + 10)}
>
<Skip10 dir="forward" />
</Ctrl>
<Ctrl label={t("plex.player.restart")} onClick={() => seekTo(0)}>