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
@@ -5,6 +5,7 @@ import { qk } from "../../lib/queryKeys";
import { ListPlus } from "lucide-react";
import { YouTubeIcon } from "../../lib/brandIcons";
import { api, type Playlist } from "../../lib/api";
import { addPlaylistItem, removePlaylistItem } from "./videoWrites";
import { notifyActionError } from "../../lib/actionErrors";
import { playlistName } from "../../lib/playlistName";
import { PopoverSurface } from "../../components/ui/AnchoredPopover";
@@ -115,8 +116,8 @@ function MembershipPanel({
if (busy) return;
setBusy(true);
try {
if (pl.has_video) await api.removeFromPlaylist(pl.id, videoId);
else await api.addToPlaylist(pl.id, videoId);
if (pl.has_video) await removePlaylistItem(qc, pl, videoId);
else await addPlaylistItem(qc, pl, videoId);
qc.setQueryData<Playlist[]>(qk.playlistMembership(videoId), (old) =>
old?.map((p) => (p.id === pl.id ? { ...p, has_video: !p.has_video } : p)),
);
@@ -133,7 +134,7 @@ function MembershipPanel({
let made = false;
try {
const pl = await api.createPlaylist(name);
await api.addToPlaylist(pl.id, videoId);
await addPlaylistItem(qc, pl, videoId);
made = true;
} catch (e) {
notifyActionError(e, t("playlists.addFailed"));
@@ -16,19 +16,29 @@ const ALLOWED = new Set([
"features/video/videoWrites.ts",
]);
// A related list and a live search are never refetched, so a state or Watch-later write that does
// not reach them (`markVideo`) leaves them showing the old mark for the session. videoWrites makes
// those writes and the mark together; a direct api call skips the mark.
describe("a video's state and Watch-later writes", () => {
// A related list and a live search are never refetched, so a state, Watch-later or playlist-item
// write that does not reach them (`markVideo`) leaves them showing the old mark for the session —
// the built-in Watch later list's items ARE the mark. videoWrites makes those writes and the mark
// together; a direct api call skips the mark.
const WRITES =
/\bapi\s*\.\s*(setState|clearState|watchLaterAdd|watchLaterRemove|addToPlaylist|removeFromPlaylist|moveToPlaylist)\b/;
describe("a video's state, Watch-later and playlist-item writes", () => {
it("go through features/video/videoWrites, never the api directly", () => {
const offenders = sources()
.filter(({ f }) => !ALLOWED.has(f))
// Only a call on the api object counts (React's own `setState` is not ours); the dot may sit
// on the next line of a chain.
.filter(({ src }) =>
/\bapi\s*\.\s*(setState|clearState|watchLaterAdd|watchLaterRemove)\(/.test(src),
)
// Only the api object's members count (React's own `setState` is not ours) — named, not only
// called: `(saved ? api.watchLaterAdd : api.watchLaterRemove)(id)` makes the write too. The
// dot may sit on the next line of a chain.
.filter(({ src }) => WRITES.test(src))
.map(({ f }) => f);
expect(offenders).toEqual([]);
});
it("catches a write named without being called on the spot, and a playlist item's", () => {
expect(WRITES.test("(saved ? api.watchLaterAdd : api.watchLaterRemove)(id)")).toBe(true);
expect(WRITES.test("await api\n .removeFromPlaylist(listId, id)")).toBe(true);
expect(WRITES.test("api.moveToPlaylist(a, id, b)")).toBe(true);
expect(WRITES.test("this.setState(next); api.playlists()")).toBe(false);
});
});
@@ -7,11 +7,31 @@ import { QueryClient } from "@tanstack/react-query";
import { api } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
import { resetVideoState, writeVideoState, writeWatchLater } from "./videoWrites";
import {
addPlaylistItem,
movePlaylistItem,
removePlaylistItem,
resetVideoState,
writeVideoState,
writeWatchLater,
} from "./videoWrites";
afterEach(() => vi.restoreAllMocks());
const marks = (qc: QueryClient) => qc.getQueryData(qk.videoMarks());
// The marks' values, without when each was confirmed.
const marks = (qc: QueryClient) => {
const m = qc.getQueryData<Record<string, Record<string, { value: unknown }>>>(qk.videoMarks());
return m
? Object.fromEntries(
Object.entries(m).map(([id, f]) => [
id,
Object.fromEntries(Object.entries(f).map(([k, x]) => [k, x.value])),
]),
)
: undefined;
};
const WATCH_LATER = { id: 1, kind: "watch_later" as const };
const MINE = { id: 2, kind: "user" as const };
describe("videoWrites", () => {
it("marks a state written", async () => {
@@ -38,6 +58,34 @@ describe("videoWrites", () => {
expect(marks(qc)).toEqual({ X: { saved: false } });
});
it("marks an item of the Watch later list as the bookmark it is", async () => {
vi.spyOn(api, "addToPlaylist").mockResolvedValue({ ok: true } as never);
vi.spyOn(api, "removeFromPlaylist").mockResolvedValue({ ok: true } as never);
const qc = new QueryClient();
await addPlaylistItem(qc, WATCH_LATER, "X");
expect(marks(qc)).toEqual({ X: { saved: true } });
await removePlaylistItem(qc, WATCH_LATER, "X");
expect(marks(qc)).toEqual({ X: { saved: false } });
});
it("marks nothing for an item of any other list", async () => {
vi.spyOn(api, "addToPlaylist").mockResolvedValue({ ok: true } as never);
vi.spyOn(api, "removeFromPlaylist").mockResolvedValue({ ok: true } as never);
const qc = new QueryClient();
await addPlaylistItem(qc, MINE, "X");
await removePlaylistItem(qc, { id: 3, kind: undefined }, "X");
expect(marks(qc)).toBeUndefined();
});
it("marks a move into and out of the Watch later list", async () => {
vi.spyOn(api, "moveToPlaylist").mockResolvedValue({ ok: true } as never);
const qc = new QueryClient();
await movePlaylistItem(qc, MINE, "X", WATCH_LATER);
expect(marks(qc)).toEqual({ X: { saved: true } });
await movePlaylistItem(qc, WATCH_LATER, "X", MINE);
expect(marks(qc)).toEqual({ X: { saved: false } });
});
it("marks nothing the server refused", async () => {
vi.spyOn(api, "setState").mockRejectedValue(new Error("offline"));
const qc = new QueryClient();
+43 -8
View File
@@ -1,15 +1,17 @@
import type { QueryClient } from "@tanstack/react-query";
import { api, type VideoStatus } from "../../lib/api";
import { api, type Playlist, type VideoStatus } from "../../lib/api";
import { markVideo } from "../../lib/progressCache";
// A video's user state and its Watch-later mark, written to the server and — once it has the
// change — into the lists that are never refetched (`markVideo`: every related list, every live
// search). These are the ONE way to make those writes, and `videoWrites.contract.test.ts` holds it:
// four writes that called the api directly (the Notification Center's undo, the feed's reset, the
// feed's and Home's Watch-later toggles) left a related list hiding a video the user had un-hidden,
// or showing it unsaved after a save. Each returns the server's answer, so a caller keeps its own
// optimistic layer, notices and rollback.
// A video's user state, its Watch-later mark and its playlist items, written to the server and —
// once it has the change — into the lists that are never refetched (`markVideo`: every related
// list, every live search). These are the ONE way to make those writes, and
// `videoWrites.contract.test.ts` holds it: four writes that called the api directly (the
// Notification Center's undo, the feed's reset, the feed's and Home's Watch-later toggles) left a
// related list hiding a video the user had un-hidden, or showing it unsaved after a save. A
// playlist item is one of them because the built-in Watch later list IS the mark (`saved`): ticked
// in Add to playlist or taken out on the Playlists page, it left the bookmark as it was. Each
// returns the server's answer, so a caller keeps its own optimistic layer, notices and rollback.
export function writeVideoState(qc: QueryClient, id: string, status: VideoStatus) {
return api.setState(id, status).then((r) => {
@@ -32,3 +34,36 @@ export function writeWatchLater(qc: QueryClient, id: string, saved: boolean) {
return r;
});
}
/** The list a playlist item write goes to: on the built-in Watch later list (`kind`) the item IS
* the video's Watch-later mark. A kind the caller cannot tell (undefined) marks nothing. */
type ListRef = { id: number; kind: Playlist["kind"] | undefined };
const markIfWatchLater = (qc: QueryClient, list: ListRef, id: string, saved: boolean) => {
if (list.kind === "watch_later") markVideo(qc, id, { saved });
};
/** Add a video to a playlist; `position` (an index in the stored order) puts it back where a
* removal took it from. */
export function addPlaylistItem(qc: QueryClient, list: ListRef, id: string, position?: number) {
return api.addToPlaylist(list.id, id, position).then((r) => {
markIfWatchLater(qc, list, id, true);
return r;
});
}
export function removePlaylistItem(qc: QueryClient, list: ListRef, id: string) {
return api.removeFromPlaylist(list.id, id).then((r) => {
markIfWatchLater(qc, list, id, false);
return r;
});
}
/** Move a video from one playlist to another, in one request (see `api.moveToPlaylist`). */
export function movePlaylistItem(qc: QueryClient, from: ListRef, id: string, to: ListRef) {
return api.moveToPlaylist(from.id, id, to.id).then((r) => {
markIfWatchLater(qc, from, id, false);
markIfWatchLater(qc, to, id, true);
return r;
});
}