fix(plex): mark the finished episode on the correct id when auto-advancing

The player's watched-mark on completion keyed off the reactive `id`, which can run ahead of the
media actually playing: when the next episode fails to load (e.g. no local file), loadSession
returns without replacing the <video> source, so the old episode keeps playing and later fires
'ended' — but by then an auto-skip-credits jump has already advanced `id` to the next episode.
The result was the WRONG episode marked watched (and scrobbled to Plex), while the finished one
got nothing. This was latent before, made visible/harmful by the Phase B Plex push.

- go(): pause the outgoing media before switching, so a failed next-episode load can't leave the
  old media running into a stray 'ended' against the new id.
- auto-skip-credits (doSkip): mark the CURRENT id watched (captured now) before advancing — binge
  advance is a finish, and previously marked nothing.
- onEnded: ignore a spurious 'ended' from media that never played (absRef 0), so a failed next
  episode can't be marked or cascade another advance.

Manual Next/Prev still never mark watched (not a finish). Verified live: E06→E07 with a missing
E07 marks E06 watched (+ Plex), E07 nothing.
This commit is contained in:
npeter83 2026-07-10 00:18:44 +02:00
parent 3a3ba17fb8
commit 363b4d17fc

View file

@ -566,6 +566,15 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
const go = useCallback( const go = useCallback(
(otherId: string | null | undefined) => { (otherId: string | null | undefined) => {
if (otherId) { 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 */
}
setAbs(0); setAbs(0);
setId(otherId); setId(otherId);
} }
@ -710,6 +719,9 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
const video = videoRef.current; const video = videoRef.current;
if (!video) return; if (!video) return;
const onEnded = () => { const onEnded = () => {
// Only a genuine play-to-the-end counts. A next episode that failed to load never played
// (absRef 0) — its stray 'ended' must not mark anything watched or cascade another advance.
if (absRef.current <= 0) return;
api.plexSetState(id, "watched").catch(() => {}); api.plexSetState(id, "watched").catch(() => {});
if (nextId) go(nextId); if (nextId) go(nextId);
}; };
@ -845,10 +857,14 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
const doSkip = useCallback( const doSkip = useCallback(
(m: PlexMarker) => { (m: PlexMarker) => {
setSkipProgress(null); setSkipProgress(null);
if (m.type === "credits" && nextId) go(nextId); if (m.type === "credits" && nextId) {
else seekTo(m.end_s); // Binge auto-advance: the episode we're leaving was watched through the credits, so mark IT
// (the current id, captured here — not the reactive id, which go() is about to change).
api.plexSetState(id, "watched").catch(() => {});
go(nextId);
} else seekTo(m.end_s);
}, },
[go, nextId, seekTo], [id, go, nextId, seekTo],
); );
const cancelAutoSkip = useCallback(() => { const cancelAutoSkip = useCallback(() => {
const m = activeMarkerRef.current; const m = activeMarkerRef.current;