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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user