Send order let a refetch read before an unanswered mark committed and drop it. A reset's position 0 is written again after a refetch that set out before the server took the reset. The player-session refetch also waits for the player's closing checkpoint, which the player now tracks (progressFlushes); refetched first, the list put the older position back.
85 lines
3.9 KiB
TypeScript
85 lines
3.9 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.
|
|
*/
|
|
// The player's closing flushes still on their way: its last checkpoint and the patch above. A list
|
|
// refetched before one lands read the older checkpoint and, landing after the patch, put it back.
|
|
const flushes = new Set<Promise<unknown>>();
|
|
|
|
/** Track a closing flush until it settles (`progressFlushes` hands out the ones still running). */
|
|
export function trackProgressFlush(flush: Promise<unknown>): void {
|
|
flushes.add(flush);
|
|
const settled = () => void flushes.delete(flush);
|
|
flush.then(settled, settled);
|
|
}
|
|
|
|
export const progressFlushes = (): Promise<unknown>[] => [...flushes];
|
|
|
|
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);
|
|
}
|