Deep-review R4. 1630a3df wrote the YT player's stored position into the react-query cache so a
reopen could not rewind it — but only the feed actually read it back:
- Playlists renders from its own undoable copy of the list, re-seeded only when the SET of ids
changes, so the patched position never reached it: close a video at 830 s, reopen it from the
playlist, and it resumed at the old spot and the next checkpoint wrote that over the 830. A detail
with the same membership now rebases its fresh rows into every snapshot, keeping order and undo
history. (The ids-only guard is older than this round; the write that depended on it was new.)
- Live YouTube search results carry the same per-user position and open the same player, but their
cache was never patched. It is now — patched only, never invalidated, since refetching an
API-sourced search page spends 100 quota units.
Tests for what had none: a Playlists harness (PlayerModal stubbed to record its launch props) and a
PlayerModal harness with a fake IFrame API that records where each item is started — the latter pins
that stepping back to an item watched earlier in the same session resumes at THIS session's position.
3 mutations, each red on its assertion.
72 lines
3.3 KiB
TypeScript
72 lines
3.3 KiB
TypeScript
import type { QueryClient } from "@tanstack/react-query";
|
|
import { qk } from "./queryKeys";
|
|
import type { Video } from "./api";
|
|
|
|
// Every cached list that carries feed videos is one of two shapes: a single object with `items`
|
|
// (a playlist detail) or an infinite query's `{ pages: [...] }` over those objects. Narrow
|
|
// structurally rather than by type name so a new list surface is covered the day it is added.
|
|
type ItemList = { items: Video[] };
|
|
type Cached = Partial<ItemList> & { pages?: unknown[] };
|
|
|
|
const isItemList = (v: unknown): v is ItemList =>
|
|
!!v && typeof v === "object" && Array.isArray((v as ItemList).items);
|
|
|
|
/**
|
|
* Write server-stored resume positions into every cached list holding those videos.
|
|
*
|
|
* The player checkpoints progress every few seconds, but until this ran, nothing wrote the new
|
|
* position back into react-query — so the feed cache kept the value it was fetched with, a reopen
|
|
* of the same card resumed from THAT stale value, and the next checkpoint then persisted the older
|
|
* position over the newer one (measured: 830s → reopen → 751s). The Plex player already solved the
|
|
* same problem for its detail query (`plexPlayerHooks`, "reopening the same item in the SAME
|
|
* session read react-query's CACHED detail"); this is the feed-side twin.
|
|
*
|
|
* `positions` must hold what the SERVER stored (`api.saveProgress`'s answer), not what the player
|
|
* read: the endpoint clears trivially-early and near-finished checkpoints to 0, and mirroring our
|
|
* own reading would re-introduce that disagreement on the client.
|
|
*/
|
|
export function patchCachedProgress(qc: QueryClient, positions: Map<string, number>): void {
|
|
if (positions.size === 0) return;
|
|
|
|
const patchList = (items: Video[]): Video[] => {
|
|
let changed = false;
|
|
const next = items.map((v) => {
|
|
const pos = positions.get(v.id);
|
|
if (pos === undefined || v.position_seconds === pos) return v;
|
|
changed = true;
|
|
return { ...v, position_seconds: pos };
|
|
});
|
|
// Keep the previous array identity when nothing moved, so an untouched list does not
|
|
// re-render every card behind the open player.
|
|
return changed ? next : items;
|
|
};
|
|
|
|
const patch = (old: Cached | undefined): Cached | undefined => {
|
|
if (!old) return old;
|
|
if (Array.isArray(old.pages)) {
|
|
let changed = false;
|
|
const pages = old.pages.map((page) => {
|
|
if (!isItemList(page)) return page;
|
|
const items = patchList(page.items);
|
|
if (items === page.items) return page;
|
|
changed = true;
|
|
return { ...page, items };
|
|
});
|
|
return changed ? { ...old, pages } : old;
|
|
}
|
|
if (isItemList(old)) {
|
|
const items = patchList(old.items);
|
|
return items === old.items ? old : { ...old, items };
|
|
}
|
|
return old;
|
|
};
|
|
|
|
// Prefix keys: every filter combination of the feed, every playlist detail, and every live YouTube
|
|
// search — its rows come from the same projection, carry the same per-user position, and open the
|
|
// same player, so leaving it out left the rewind in place on that surface. (Patched, never
|
|
// invalidated: a refetch of an API-sourced search page spends 100 quota units.)
|
|
qc.setQueriesData<Cached>({ queryKey: qk.feed() }, patch);
|
|
qc.setQueriesData<Cached>({ queryKey: qk.playlist() }, patch);
|
|
qc.setQueriesData<Cached>({ queryKey: qk.ytSearch() }, patch);
|
|
}
|