fix(player): related marks survive, Restart restarts, and refused pages offer nothing to click

- Every state and Watch-later write goes through features/video/videoWrites,
  which marks the never-refetched lists (related lists, live searches) once
  the server has it; a contract test holds it. The Notification Center's
  undo, the feed's reset and the Watch-later toggles skipped it before.
- Confirmed marks are also kept apart (qk.videoMarks) and laid over the
  related rows: a Show more page landing later rebuilt them from the pages
  it read when it started, and dropped a mark made meanwhile.
- Restart on a related video starts at 0 (the session's stored position
  won before), and on the one already playing it seeks.
- The watched toggle remembers each video's mark: Back showed the opened
  video's launch-time state after a related one was marked.
- The help sheet leaves out the player's keys while a layer above it has
  them waiting.
- After a refusal a retry cannot answer, neither Show more (related) nor
  Load more (live search) is offered; a failed live-search page keeps the
  results already read and says why under them.
- The related cap message counts requests, as the cap does.
This commit is contained in:
2026-10-25 02:50:34 +02:00
parent ed51f5be7e
commit 4b5b66343d
26 changed files with 667 additions and 93 deletions
+58 -34
View File
@@ -1,4 +1,4 @@
import type { QueryClient } from "@tanstack/react-query";
import { useQuery, useQueryClient, type QueryClient, type QueryKey } from "@tanstack/react-query";
import { qk } from "./queryKeys";
import type { Video } from "./api";
@@ -40,17 +40,67 @@ 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. 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
// related list's Show more was on its way vanished when the page landed — for the session, since
// those lists are never refetched. The related list lays these over its rows (`useVideoMarks`).
type Marks = Record<string, Partial<Video>>;
function remember(qc: QueryClient, id: string, change: Partial<Video>): void {
// 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], ...change } }));
}
/** 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;
}
/** 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 pos = positions.get(v.id);
if (pos === undefined || v.position_seconds === pos) return v;
changed = true;
return { ...v, position_seconds: pos };
const out = edit(v);
if (out !== v) changed = true;
return out;
});
// 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;
};
@@ -74,31 +124,5 @@ export function patchCachedProgress(qc: QueryClient, positions: Map<string, numb
return old;
};
// 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.)
qc.setQueriesData<Cached>({ queryKey: qk.feed() }, patch);
qc.setQueriesData<Cached>({ queryKey: qk.playlist() }, patch);
qc.setQueriesData<Cached>({ queryKey: qk.ytSearch() }, patch);
qc.setQueriesData<Cached>({ queryKey: qk.related() }, patch);
}
/** Write one video's change (its status, its Watch-later mark) into every cached related list —
* both regions', every opened video's. Those lists are never refetched (another scrape and a slot
* of the day's cap), and they leave out what the user has seen: a video marked anywhere else — a
* feed card, a playlist, the player itself — still showed in one opened again later. So every
* state write calls it once the server has the change. */
export function patchRelated(qc: QueryClient, id: string, change: Partial<Video>): void {
qc.setQueriesData<Cached>({ queryKey: qk.related() }, (old) => {
if (!old?.pages) return old;
let changed = false;
const pages = old.pages.map((page) => {
if (!isItemList(page) || !page.items.some((v) => v.id === id)) return page;
changed = true;
return { ...page, items: page.items.map((v) => (v.id === id ? { ...v, ...change } : v)) };
});
return changed ? { ...old, pages } : old;
});
for (const queryKey of keys) qc.setQueriesData<Cached>({ queryKey }, patch);
}