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:
@@ -84,20 +84,31 @@ export function useControlsVisibility(opts: {
|
||||
// calls it itself BEFORE it moves to another item: by the time this effect's cleanup runs for the
|
||||
// outgoing id, the position has already been reset for the incoming one — so the cleanup found 0 and
|
||||
// skipped, and every Next dropped the last stretch watched of the episode being left.
|
||||
//
|
||||
// `finish` says the item was played THROUGH (it ended, or the credits auto-skip left it) and the
|
||||
// caller has marked it watched. From then on leaving it records no position: a checkpoint at the
|
||||
// credits raced the watched mark (landing second, it left a watched row with a resume point and put
|
||||
// that point back on Plex after the scrobble), and the reopen patch wrote "new, at 49:55" into the
|
||||
// cache — Prev within the next 30 s reopened the episode at its last seconds (or at the credits,
|
||||
// which auto-skipped straight back). The server stores a watched item at position 0, so the patch
|
||||
// says exactly that. Playing on from the finished frame takes the finish back.
|
||||
export function usePlexProgress(opts: {
|
||||
id: string;
|
||||
qc: QueryClient;
|
||||
videoRef: RefObject<HTMLVideoElement | null>;
|
||||
absRef: MutableRefObject<number>;
|
||||
durationRef: MutableRefObject<number>;
|
||||
}): { settle: () => void } {
|
||||
}): { settle: () => void; finish: () => void } {
|
||||
const { id, qc, videoRef, absRef, durationRef } = opts;
|
||||
const settleRef = useRef<() => void>(() => {});
|
||||
const finishRef = useRef<() => void>(() => {});
|
||||
useEffect(() => {
|
||||
let finished = false;
|
||||
// `final` = a settled checkpoint (pause / page-hide / leaving the player) vs. the periodic tick.
|
||||
// Only final checkpoints get mirrored to a linked Plex account (backend-side debounce), so Plex
|
||||
// sees the resume position when the user actually stops — not a timeline call every 10 seconds.
|
||||
const flush = (final = false) => {
|
||||
if (finished) return;
|
||||
if (absRef.current > 0 && durationRef.current > 0)
|
||||
api
|
||||
.plexProgress(id, Math.floor(absRef.current), durationRef.current, final)
|
||||
@@ -122,43 +133,72 @@ export function usePlexProgress(opts: {
|
||||
// so without this the resume point lags up to 10s — e.g. a seek right before F5 was lost. Uses a
|
||||
// keepalive beacon so the POST survives the unload (a final checkpoint — the user is leaving).
|
||||
const onHide = () => {
|
||||
if (absRef.current > 0 && durationRef.current > 0)
|
||||
if (!finished && absRef.current > 0 && durationRef.current > 0)
|
||||
api.plexProgressBeacon(id, Math.floor(absRef.current), durationRef.current, true);
|
||||
};
|
||||
window.addEventListener("pagehide", onHide);
|
||||
const onPlayAgain = () => {
|
||||
finished = false;
|
||||
};
|
||||
video?.addEventListener("play", onPlayAgain);
|
||||
// Back (unmount) persists the position server-side via flush(), but reopening the same item in
|
||||
// the SAME session read react-query's CACHED detail — whose position_seconds predated this watch —
|
||||
// so the resume landed at the old spot (the progress GET could also race the POST). So leaving
|
||||
// also patches the cached detail, and a reopen resumes from where we left off. (F5 was always fine:
|
||||
// a reload starts with an empty cache and fetches the fresh position.) Safe to do only when leaving
|
||||
// — patching while mounted would retrigger the loadSession effect and reseek mid-play. Reading the
|
||||
// LATEST abs/duration is the whole point of the checkpoint (the resume point when leaving).
|
||||
// a reload starts with an empty cache and fetches the fresh position.) Done when leaving, because
|
||||
// that is when the position is final. Reading the LATEST abs/duration is the whole point of the
|
||||
// checkpoint (the resume point when leaving).
|
||||
const settle = () => {
|
||||
// Match the language-keyed detail query (E5 S6) so the optimistic resume patch lands.
|
||||
const key = qk.plexItem(id, plexLang());
|
||||
if (finished) {
|
||||
qc.setQueryData(key, (old) =>
|
||||
old ? { ...old, status: "watched", position_seconds: 0 } : old,
|
||||
);
|
||||
return;
|
||||
}
|
||||
flush(true);
|
||||
if (absRef.current > 0 && durationRef.current > 0) {
|
||||
const pos = Math.floor(absRef.current);
|
||||
// Match the language-keyed detail query (E5 S6) so the optimistic resume patch lands.
|
||||
qc.setQueryData(qk.plexItem(id, plexLang()), (old) =>
|
||||
old ? { ...old, position_seconds: pos } : old,
|
||||
);
|
||||
qc.setQueryData(key, (old) => (old ? { ...old, position_seconds: pos } : old));
|
||||
}
|
||||
};
|
||||
settleRef.current = settle;
|
||||
finishRef.current = () => {
|
||||
finished = true;
|
||||
};
|
||||
return () => {
|
||||
window.clearInterval(iv);
|
||||
window.clearTimeout(seekTimer);
|
||||
window.removeEventListener("pagehide", onHide);
|
||||
video?.removeEventListener("pause", onPauseFlush);
|
||||
video?.removeEventListener("seeked", onSeekFlush);
|
||||
video?.removeEventListener("play", onPlayAgain);
|
||||
settle();
|
||||
settleRef.current = () => {};
|
||||
finishRef.current = () => {};
|
||||
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||
qc.invalidateQueries({ queryKey: qk.plexShow() });
|
||||
};
|
||||
// absRef/durationRef/videoRef are stable ref objects — listed to satisfy exhaustive-deps without
|
||||
// changing when the effect re-runs (id/qc only), matching the original in-component effect.
|
||||
}, [id, qc, absRef, durationRef, videoRef]);
|
||||
return { settle: useCallback(() => settleRef.current(), []) };
|
||||
return {
|
||||
settle: useCallback(() => settleRef.current(), []),
|
||||
finish: useCallback(() => finishRef.current(), []),
|
||||
};
|
||||
}
|
||||
|
||||
/** Where the viewer is. `abs` only moves once playback has ticked — until then (a session still
|
||||
* loading, or a resumed one that has stayed paused) it reads 0 while the film is minutes in, so the
|
||||
* answer is where the session was ASKED to start. Anything that restarts the session "here" — an
|
||||
* audio switch, an A/V offset, a keyboard seek — must use this: reading `abs` restarted a resume at
|
||||
* 20:00 from 0:00, and the next checkpoint saved that (deep-review round 4). */
|
||||
export function playhead(
|
||||
absRef: MutableRefObject<number>,
|
||||
loadStartRef: MutableRefObject<number>,
|
||||
): number {
|
||||
return absRef.current > 0 ? absRef.current : loadStartRef.current;
|
||||
}
|
||||
|
||||
// Volume/mute for the Plex player's HTML5 <video> (0..1 float, independent .muted flag). Persists to
|
||||
@@ -242,7 +282,8 @@ export function useAutoSkip(opts: {
|
||||
autoSkipCredits: boolean;
|
||||
autoSkipDelay: number;
|
||||
nextId: string | null | undefined;
|
||||
go: (id: string | null | undefined) => void;
|
||||
/** `finished` = the item being left was played through (see usePlexProgress's `finish`). */
|
||||
go: (id: string | null | undefined, finished?: boolean) => void;
|
||||
seekTo: (t: number) => void;
|
||||
videoRef: RefObject<HTMLVideoElement | null>;
|
||||
}): {
|
||||
@@ -292,7 +333,7 @@ export function useAutoSkip(opts: {
|
||||
// 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);
|
||||
go(nextId, true);
|
||||
} else seekTo(m.end_s);
|
||||
},
|
||||
[id, go, nextId, seekTo],
|
||||
@@ -468,10 +509,11 @@ export function usePlexTracks(opts: {
|
||||
/* ignore */
|
||||
}
|
||||
} else {
|
||||
loadSession(absRef.current); // single-audio: re-map the track server-side (session restart)
|
||||
// Single audio (or the session has not answered yet): re-map the track server-side.
|
||||
loadSession(playhead(absRef, loadStartRef));
|
||||
}
|
||||
},
|
||||
[loadSession, detail, patchPrefs, audioRef, multiAudioRef, hlsRef, absRef],
|
||||
[loadSession, detail, patchPrefs, audioRef, multiAudioRef, hlsRef, absRef, loadStartRef],
|
||||
);
|
||||
// Subtitles are native <track>s overlaid by the browser — switching one just flips TextTrack modes
|
||||
// (applied by the effect below), NO session restart. Off = null.
|
||||
@@ -520,9 +562,9 @@ export function usePlexTracks(opts: {
|
||||
patchPrefs({ audioOffset: v });
|
||||
aoffRef.current = v;
|
||||
if (aoffTimer.current) window.clearTimeout(aoffTimer.current);
|
||||
aoffTimer.current = window.setTimeout(() => loadSession(absRef.current), 500);
|
||||
aoffTimer.current = window.setTimeout(() => loadSession(playhead(absRef, loadStartRef)), 500);
|
||||
},
|
||||
[patchPrefs, loadSession, aoffRef, absRef],
|
||||
[patchPrefs, loadSession, aoffRef, absRef, loadStartRef],
|
||||
);
|
||||
|
||||
// --- is the selected track actually extractable yet? -------------------------------------------
|
||||
@@ -541,12 +583,7 @@ export function usePlexTracks(opts: {
|
||||
// answer is where the session was asked to start. Not `sessionK`: that only lands once the
|
||||
// session request has answered, so the first poll of a fresh open read 0 from it, and after a
|
||||
// move to the next item it still held the PREVIOUS item's start.
|
||||
queryFn: () =>
|
||||
api.plexSubtitleStatus(
|
||||
id,
|
||||
subOrd as number,
|
||||
absRef.current > 0 ? absRef.current : loadStartRef.current,
|
||||
),
|
||||
queryFn: () => api.plexSubtitleStatus(id, subOrd as number, playhead(absRef, loadStartRef)),
|
||||
enabled: subOrd != null,
|
||||
// Keep polling until the track is COMPLETE, not just showable: each poll carries the current
|
||||
// position, so seeking past the extracted window is what asks for the next one. A settled
|
||||
|
||||
Reference in New Issue
Block a user