fix(player): marks are weighed when answered, lit and pressed hold under the pointer, and Restart is one glyph

The watched toggle weighed its own click at click time against marks stamped when the server answered, so the late answer of its previous click overruled the newer one; it now holds its word while any of its writes is on the way, is stamped at the answer, and drops a refused write. The hosts return the write. In a live search the feed's own answered writes stand down over the rows, which carry the marks, so a newer mark from the related list reaches a card the feed touched earlier.

A lit mode cycle got the hover layer, and a variant's hover colour outranked the pressed and lit colours: the state layer stands down on [data-lit] and those colours are important. The menus' Play from the beginning wears RotateCcw like the card's Restart, Reset wears Eraser and Redownload the retry arrow (DESIGN decision log). Tests pin the toggle's orders, the Watch later mark at its call sites, the live-search overrides, the hover exemptions and the menu glyphs.
This commit is contained in:
2026-10-25 13:51:26 +01:00
parent 13f65b4844
commit 0bc1024287
18 changed files with 629 additions and 103 deletions
+20 -6
View File
@@ -127,10 +127,17 @@ function matchesView(status: VideoStatus, show: string): boolean {
// the writes it ANSWERED before that refetch set out: a mark made while it was in flight is newer
// than its answer, and so is one sent just before it whose answer had not come back (the server may
// read the list first). Dropping either put a card marked watched back into an Unwatched view.
type Stamped<T> = { value: T; seq: number };
// `answered`: the server has taken the write — the live search then reads it from the marks
// (`markedRows`), where a newer word from elsewhere can follow it; the feed holds it until a refetch.
type Stamped<T> = { value: T; seq: number; answered?: boolean };
type Overrides<T> = Record<string, Stamped<T>>;
const unconfirmed = <T,>(o: Overrides<T>, answered: ReadonlySet<number>): Overrides<T> =>
Object.fromEntries(Object.entries(o).filter(([, s]) => !answered.has(s.seq)));
// The server took a write: say so on its own value only — a later write for the same video stands.
const withAnswer = <T,>(o: Overrides<T>, id: string, seq: number): Overrides<T> => {
const own = o[id];
return own?.seq === seq ? { ...o, [id]: { ...own, answered: true } } : o;
};
// A failed write takes back only its OWN value: a later write for the same video stands.
const withoutWrite = <T,>(o: Overrides<T>, id: string, seq: number): Overrides<T> => {
if (o[id]?.seq !== seq) return o;
@@ -400,6 +407,7 @@ export default function Feed({
// every loaded infinite-query page here was pure waste — 10 pages x rapid marks = a refetch
// storm (PERF-2). The next filter change / remount refetches fresh from the server.
answered.current.add(seq);
setOverrides((o) => withAnswer(o, id, seq));
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
// The video the player is on may be none of this list's (a related one): it names itself.
const v = video ?? loadedRef.current.find((x) => x.id === id);
@@ -412,6 +420,7 @@ export default function Feed({
notifyActionError(e, i18n.t("errors.writeFailed.videoState"));
setOverrides((o) => withoutWrite(o, id, seq));
});
return write;
},
[qc, trackSessionWrite],
);
@@ -432,6 +441,7 @@ export default function Feed({
// Stale-mark only (see setState above): the optimistic override already reset the card.
.then(() => {
answered.current.add(seq);
setOverrides((o) => withAnswer(o, id, seq));
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
})
.catch((e: unknown) => {
@@ -452,6 +462,7 @@ export default function Feed({
writeWatchLater(qc, id, saved)
.then(() => {
answered.current.add(seq);
setSavedOverrides((o) => withAnswer(o, id, seq));
qc.invalidateQueries({ queryKey: qk.playlists() });
qc.invalidateQueries({ queryKey: qk.playlist() });
})
@@ -471,22 +482,25 @@ export default function Feed({
const loaded: Video[] = (query.data?.pages ?? []).flatMap((p) => p.items);
// A mark made anywhere else (the player's related list, the Notification Center's undo) since a
// page was read is laid over it: a Load more on its way when the mark landed rebuilt the list
// from the pages it started from, without the mark.
// from the pages it started from, without the mark. The feed's own writes reach the marks too,
// once answered: over these rows only an unanswered one of them is laid (`withOverrides`), or an
// earlier click of the feed's hid a newer mark from elsewhere for the rest of the search.
const ytLoaded: Video[] = markedRows(ytQuery.data?.pages, videoMarks);
// onState's refs follow each commit: itself, and what the cards show (live search results if on).
useLayoutEffect(() => {
onStateRef.current = onState;
loadedRef.current = ytActive ? ytLoaded : loaded;
});
const withOverrides = (list: Video[]): Video[] =>
// `live`: the live search's rows, which carry the marks — there an answered write stands down.
const withOverrides = (list: Video[], live = false): Video[] =>
list
.map((v) => {
const status = overrides[v.id]; // read once so the narrowing sticks
return status ? { ...v, status: status.value } : v;
return status && !(live && status.answered) ? { ...v, status: status.value } : v;
})
.map((v) => {
const saved = savedOverrides[v.id];
return saved ? { ...v, saved: saved.value } : v;
return saved && !(live && saved.answered) ? { ...v, saved: saved.value } : v;
});
const merged: Video[] = withOverrides(loaded);
const items: Video[] = merged.filter((v) => matchesView(v.status, filters.show));
@@ -508,7 +522,7 @@ export default function Feed({
// user explicitly searched, so show everything) and no auto-pagination (each page costs quota).
if (ytActive) {
const ytItems: Video[] = sortYtResults(
withOverrides(ytLoaded),
withOverrides(ytLoaded, true),
ytSort.key as SortKey,
ytSort.dir,
lang,