Files
siftlode/frontend/src/lib/progressCache.ts
T
peter 84db570951 fix(player): marks give way to newer lists, Watch later items mark, and auto-advance has its own glyphs
A session's marks are weighed per field against when each page of a never-refetched list was read:
a page read before the mark (a Show more / Load more on its way) takes it, one read after keeps the
server's word. The live search lays them over its rows too, not only the related list.

Items of the built-in Watch later list ARE the bookmark, so adding, removing and moving them go
through features/video/videoWrites and mark the video; the contract test covers those writes and
catches a write that is named without being called on the spot.

The player's watched toggle follows a mark confirmed elsewhere since it opened (the related row's
menu, a notice's Undo); Restart on the related row after a description link goes back to it.
The file viewer tells the help sheet its keys wait under a layer; refusalReason replaces the last
two open-coded reads.

Auto-advance wears list glyphs of its own (the skip arrows are Previous / Next beside it), and the
auto-advance and loop cycles are `lit`, not toggles: no aria-pressed, the name says the mode. Both
places render from one list of controls, so the docked tooltips name the mode too. The English cap
message and admin hint quote "Show more"; the hint says refunds are limited.
2026-10-25 04:01:43 +01:00

175 lines
8.2 KiB
TypeScript

import { useQuery, useQueryClient, type QueryClient, type QueryKey } 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;
// Prefix keys: every filter combination of the feed, every playlist detail, every live YouTube
// search and every related list — their rows come from the same projection, carry the same
// per-user position, and open the same player, so leaving one out left the rewind in place on that
// surface. (Patched, never invalidated: a refetch of an API-sourced search page spends 100 quota
// units, a related list's a scrape and a slot of the day's cap.)
patchLists(qc, [qk.feed(), qk.playlist(), qk.ytSearch(), qk.related()], (v) => {
const pos = positions.get(v.id);
return pos === undefined || v.position_seconds === pos ? v : { ...v, position_seconds: pos };
});
for (const [id, pos] of positions) remember(qc, id, { position_seconds: pos });
}
/** Write one video's confirmed change (its status, its Watch-later mark) into the lists that are
* never refetched: every related list (both regions', every opened video's) and every live
* YouTube search — another scrape and a slot of the day's cap, or 100 quota units, each. A video
* marked anywhere else (a feed card, a playlist, the player, the Notification Center's undo) still
* showed there unmarked. Called by `features/video/videoWrites`, which every such write goes
* through (its contract test holds that). */
export function markVideo(qc: QueryClient, id: string, change: Partial<Video>): void {
patchLists(qc, [qk.ytSearch(), qk.related()], (v) => (v.id === id ? { ...v, ...change } : v));
remember(qc, id, change);
}
// What this session confirmed about each video, kept APART from the lists that show it, field by
// field with WHEN. A list's own cache cannot hold it alone: an infinite query builds its next page's
// data from the pages it read when that fetch STARTED (react-query's infiniteQueryBehavior), so a
// mark confirmed while a Show more / Load more was on its way vanished when the page landed — for
// the session, since those lists are never refetched. Laid over a page only where the mark is
// newer than the page's fetch (`markedRows`): a page read later carries the server's own newer
// word (a Watch-later change made on the Playlists page, a position saved on another device),
// and a session-long mark painted over it would show the old one until a reload.
type Mark = { [K in keyof Video]?: { value: Video[K]; at: number } };
type Marks = Record<string, Mark>;
function remember(qc: QueryClient, id: string, change: Partial<Video>): void {
const at = Date.now();
const stamped = Object.fromEntries(
Object.entries(change).map(([k, value]) => [k, { value, at }]),
) as Mark;
// Kept while the app is open, observed or not: a mark made before any related list was opened
// must still be there when one is.
qc.setQueryDefaults(qk.videoMarks(), { staleTime: Infinity, gcTime: Infinity });
qc.setQueryData<Marks>(qk.videoMarks(), (m) => ({ ...m, [id]: { ...m?.[id], ...stamped } }));
}
/** This session's confirmed marks, per video id (see `markVideo`). */
export function useVideoMarks(): Marks {
const qc = useQueryClient();
return useQuery({
queryKey: qk.videoMarks(),
// Nothing to fetch: the marks are what this session wrote, and the cache is where they live.
queryFn: () => qc.getQueryData<Marks>(qk.videoMarks()) ?? {},
initialData: {},
staleTime: Infinity,
gcTime: Infinity,
}).data;
}
/** The newest confirmed value of one field of a video, or undefined when this session set none. */
export const markedValue = <K extends keyof Video>(
marks: Marks,
id: string,
field: K,
): { value: Video[K]; at: number } | undefined => marks[id]?.[field];
/** A page of a never-refetched list, with when its fetch STARTED (`stampFetch`). */
export type Stamped = { fetchedAt?: number };
/** Run a never-refetched list's page fetch, stamping the page with when it started — what
* `markedRows` weighs a mark against. */
export async function stampFetch<T extends object>(
fetch: () => Promise<T>,
): Promise<T & { fetchedAt: number }> {
const fetchedAt = Date.now();
return { ...(await fetch()), fetchedAt };
}
/** A never-refetched list's rows, each with this session's marks laid over it where the mark is
* newer than the page it came in (a page with no stamp takes every mark). */
export function markedRows(
pages: readonly ({ items: Video[] } & Stamped)[] | undefined,
marks: Marks,
): Video[] {
return (pages ?? []).flatMap((page) =>
page.items.map((v) => {
const mark = marks[v.id];
if (!mark) return v;
const since = page.fetchedAt ?? -Infinity;
const newer = Object.entries(mark).filter(([, m]) => m.at >= since);
return newer.length
? { ...v, ...Object.fromEntries(newer.map(([k, m]) => [k, m.value])) }
: v;
}),
);
}
/** Apply `edit` to every row of every cached list under `keys` (prefix keys), keeping each untouched
* page's and list's identity — so an unchanged list does not re-render every card behind the
* open player. */
function patchLists(qc: QueryClient, keys: readonly QueryKey[], edit: (v: Video) => Video): void {
const patchList = (items: Video[]): Video[] => {
let changed = false;
const next = items.map((v) => {
const out = edit(v);
if (out !== v) changed = true;
return out;
});
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;
};
for (const queryKey of keys) qc.setQueriesData<Cached>({ queryKey }, patch);
}