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.
This commit is contained in:
2026-10-25 04:01:43 +01:00
parent b25179c06a
commit 84db570951
31 changed files with 699 additions and 189 deletions
+53 -7
View File
@@ -63,18 +63,26 @@ export function markVideo(qc: QueryClient, id: string, change: Partial<Video>):
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>>;
// 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], ...change } }));
qc.setQueryData<Marks>(qk.videoMarks(), (m) => ({ ...m, [id]: { ...m?.[id], ...stamped } }));
}
/** This session's confirmed marks, per video id (see `markVideo`). */
@@ -90,6 +98,44 @@ export function useVideoMarks(): Marks {
}).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. */