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