fix(player): the YT progress checkpoint reaches the cache, so a reopen cannot rewind it

The player checkpointed every 5s and the server stored it, but nothing wrote the new
position back into react-query: the close path only marked qk.feed() stale with
refetchType "none", and nothing refetches the feed until it remounts (staleTime 30s,
refetchOnWindowFocus off). So the resume read the position the cache had been FETCHED
with, and the next checkpoint then persisted that older value over the newer one.

Measured on localdev: played to 830s, closed (server had 830), reopened the same card
without a reload -> the row was back to 751s. 79 seconds of progress destroyed by a
reopen, and the card's progress bar never moved either.

The Plex player already solved this for its detail query, with a comment describing the
identical failure; this is the feed-side twin that was missed in that sweep. patchCachedProgress
writes the position the SERVER stored (the endpoint clears trivially-early and near-finished
checkpoints to 0, so mirroring our own reading would disagree with the row) into every cached
feed page and playlist detail, keeping array identity where nothing changed. A session map
covers the second half: the launch queue is frozen at mount, so stepping next->prev back to an
item played earlier in the same session resumed from that snapshot.

Verified in the browser: 811 -> close 816 -> reopen 825, and the card now shows Continue/Restart.
This commit is contained in:
2026-09-09 23:55:51 +02:00
parent 14b5fe2e31
commit 1630a3df3c
5 changed files with 277 additions and 5 deletions
+19 -1
View File
@@ -19,6 +19,7 @@ import {
import { useBackToExit } from "./history";
import { api, type Video } from "./api";
import { qk } from "./queryKeys";
import { patchCachedProgress } from "./progressCache";
import type { YTNamespace, YTPlayer, YTPlayerEvent } from "./youtube";
import type { AutoMode, LoopMode } from "../components/playerModalControls";
@@ -308,6 +309,7 @@ export function useYtProgress({
reapplyStored,
focusModal,
advanceOnEnd,
sessionPosRef,
qc,
}: {
activeId: string;
@@ -323,6 +325,10 @@ export function useYtProgress({
reapplyStored: () => void;
focusModal: () => void;
advanceOnEnd: () => void;
// Positions this player session has actually STORED, per video id. Written from the server's
// answer, read back by the modal's resume so stepping next→prev inside one session does not
// rewind to the value the frozen launch queue was snapshotted with.
sessionPosRef: MutableRefObject<Map<string, number>>;
qc: QueryClient;
}): void {
const autoMarkedRef = useRef(false);
@@ -359,7 +365,14 @@ export function useYtProgress({
// Only checkpoint the library item we opened with — a navigated-to video may not be in this
// user's library, so the server would 404 on it.
if (currentIdRef.current === id) {
return api.saveProgress(id, cur, dur).catch(() => {});
return (
api
.saveProgress(id, cur, dur)
// Record what the server STORED, not `cur`: a near-end or under-5s checkpoint is
// cleared to 0 there, and the resume must agree with the row, not with our reading.
.then((res) => void sessionPosRef.current.set(id, res.position_seconds))
.catch(() => {})
);
}
} catch {
/* player may be tearing down */
@@ -445,6 +458,11 @@ export function useYtProgress({
// Final flush, then mark the feed + playlists stale so resume bars reflect this session on the next
// natural refetch — WITHOUT refetching every loaded feed page on each player close (PERF-2).
Promise.resolve(persist()).finally(() => {
// Write this session's stored positions INTO the cached lists first. Marking them stale is
// not enough on its own: nothing refetches the feed until it remounts (staleTime 30s,
// refetchOnWindowFocus off), so a reopen used to resume from the position the cache was
// fetched with — and the next checkpoint then persisted that older value over this one.
patchCachedProgress(qc, sessionPosRef.current);
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
qc.invalidateQueries({ queryKey: qk.playlist(), refetchType: "none" });
});