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
@@ -35,6 +35,8 @@ vi.mock("../downloads/DownloadButton", () => ({
}));
import PlayerModal from "./PlayerModal";
import KeyboardHelp from "../../components/ui/KeyboardHelp";
import { closeKeyboardHelp, toggleKeyboardHelp } from "../../lib/keymap";
import { ConfirmProvider } from "../../components/ui/ConfirmProvider";
import { useConfirm } from "../../components/ui/confirmContext";
import { api, type Me, type Video } from "../../lib/api";
@@ -837,6 +839,7 @@ describe("PlayerModal — the side panel on a wide window", () => {
const playRel2 = async (
onState: (id: string, status: string, v?: Video) => void,
queue?: Video[],
opts: { onRemoveFromList?: (v: Video) => Promise<boolean>; before?: () => void } = {},
) => {
wideWindow(true);
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
@@ -853,9 +856,11 @@ describe("PlayerModal — the side panel on a wide window", () => {
startIndex={0}
onClose={() => {}}
onState={onState}
onRemoveFromList={opts.onRemoveFromList}
/>,
);
act(() => playerEvents.onReady?.());
opts.before?.();
fireEvent.click(screen.getByRole("tab", { name: "player.related.title" }));
fireEvent.click(titleOf(await screen.findAllByRole("link", { name: "Video rel2" })));
await waitFor(() => expect(lastStart()?.videoId).toBe("rel2"));
@@ -898,6 +903,77 @@ describe("PlayerModal — the side panel on a wide window", () => {
expect(lastStart()?.videoId).toBe("rel2");
});
// Restart is where the user asked to start: a position this session stored for the video (which a
// plain pick resumes from) must not win over it.
it("wide: Restart on a related video played this session starts it at 0", async () => {
vi.spyOn(api, "saveProgress").mockImplementation(async (id: string) => ({
video_id: id,
position_seconds: id === "rel2" ? 500 : 77,
}));
clock.current = 77;
await playRel2(() => {});
clock.current = 500;
fireEvent.click(screen.getByRole("button", { name: "player.back" }));
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
await settle();
const list = screen.getByTestId("player-related");
const row = within(list)
.getAllByRole("link", { name: "Video rel2" })[0]!
.closest("[data-nav-index]")!;
fireEvent.click(within(row as HTMLElement).getAllByTitle("card.restartTitle")[0]!);
await waitFor(() => expect(lastStart()?.videoId).toBe("rel2"));
// A start of 0 is no `start` at all (the player starts at the top).
expect(lastStart()?.start ?? 0).toBe(0);
});
// Its row stays in the list while it plays; a new player for the same video is never built (one
// per video), so Restart there is a seek.
it("wide: Restart on the related video already playing seeks it to 0", async () => {
await playRel2(() => {});
const before = started.length;
const list = screen.getByTestId("player-related");
const row = within(list)
.getAllByRole("link", { name: "Video rel2" })[0]!
.closest("[data-nav-index]")!;
fireEvent.click(within(row as HTMLElement).getAllByTitle("card.restartTitle")[0]!);
expect(seeks).toContain(0);
expect(started.length).toBe(before);
});
// The toggle remembers each video's mark: a related one's did not overwrite the opened one's.
it("wide: Back shows the opened video's own mark, not the one it was opened with", async () => {
const onState = vi.fn();
await playRel2(onState, undefined, {
before: () => fireEvent.click(screen.getAllByTitle("player.markWatched")[0]!),
});
fireEvent.click(screen.getAllByTitle("player.markWatched")[0]!); // rel2's
expect(onState.mock.calls.map((c) => c.slice(0, 2))).toEqual([
["A", "watched"],
["rel2", "watched"],
]);
fireEvent.click(screen.getByRole("button", { name: "player.back" }));
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
expect(screen.getAllByTitle("player.watchedUnmark").length).toBeGreaterThan(0);
expect(screen.queryAllByTitle("player.markWatched")).toHaveLength(0);
});
it("wide: a queue step leaves the related video for the queue's next item", async () => {
await playRel2(() => {}, [vid("A", 40), vid("B", 0)]);
next();
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
expect(screen.getByRole("heading", { name: "Video B" })).toBeTruthy();
expect(screen.queryByRole("button", { name: "player.back" })).toBeNull();
});
// Removing from the list removes the queue's item — not the related video on screen.
it("wide: offers no removal from the list while a related video plays", async () => {
await playRel2(() => {}, undefined, { onRemoveFromList: async () => true });
expect(screen.queryByRole("button", { name: /playlists\.removeItemOf/ })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "player.back" }));
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
expect(trash("A")).toBeTruthy();
});
it("leaves the related videos out when the instance turned them off", () => {
me = { related_enabled: false } as Me;
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
@@ -1037,6 +1113,23 @@ describe("PlayerModal — the side panel on a wide window", () => {
expect(seeks).toEqual([]);
});
// So the help sheet opened over the drawer does not promise them; over the player itself, it does.
it("stacked: the help sheet over the drawer leaves the video's keys out", async () => {
wideWindow(false);
const { drawer } = await openDrawer();
render(<KeyboardHelp />);
act(() => toggleKeyboardHelp());
expect(screen.queryByText("keys.playPause")).toBeNull();
act(() => closeKeyboardHelp());
fireEvent.click(within(drawer).getByRole("button", { name: "common.close" }));
await waitFor(() =>
expect(screen.queryByRole("dialog", { name: "player.related.title" })).toBeNull(),
);
act(() => toggleKeyboardHelp());
expect(screen.getByText("keys.playPause")).toBeTruthy();
act(() => closeKeyboardHelp());
});
it("stacked: the window regaining focus leaves it in the drawer", async () => {
wideWindow(false);
const { drawer } = await openDrawer();
+27 -10
View File
@@ -65,7 +65,7 @@ import {
relativeTime,
} from "../../lib/format";
import { canFullscreenElements, fullscreenElement } from "../../lib/fullscreen";
import { dispatch, isActivationTarget, useKeyScope } from "../../lib/keymap";
import { dispatch, isActivationTarget, setScopeCoveredProbe, useKeyScope } from "../../lib/keymap";
import { useBackToClose } from "../../lib/history";
import ChannelNameLink from "../video/ChannelNameLink";
import Toaster from "../../components/ui/Toaster";
@@ -248,14 +248,18 @@ export default function PlayerModal({
// controls show — while the queue stays where it was, and Back returns to the opened item. It is
// the SUBJECT of the player from here down; `active` stays the queue's item, which the related
// list, the counter and prev/next belong to. A queue step leaves it.
const [related, setRelated] = useState<{ video: Video; startAt: number } | null>(null);
// `startAt` null resumes it where it was left (this session's stored position first); a number is
// where the user asked it to start — Restart's 0 — and beats that position.
const [related, setRelated] = useState<{ video: Video; startAt: number | null } | null>(null);
const [relatedOf, setRelatedOf] = useState(active.id);
if (relatedOf !== active.id) {
setRelatedOf(active.id);
setRelated(null);
}
const subject = related?.video ?? active;
const subjectResume = related ? related.startAt : resumeAt;
const subjectResume = related
? (related.startAt ?? related.video.position_seconds ?? 0)
: resumeAt;
const mountRef = useRef<HTMLDivElement | null>(null);
const playerRef = useRef<YTPlayer | null>(null);
// Keyboard/wheel controls. We keep focus on the modal card (not the cross-origin player
@@ -547,14 +551,11 @@ export default function PlayerModal({
// item's, so the toggle falls back to `subject.status` and no ordering has to be argued about.
// Unkeyed it landed last and painted the NEXT video as watched; a corrective click then sent
// onState(next, "new") for an item that was already new.
const [status, setStatus] = useState<{ id: string; value: VideoStatus }>({
id: active.id,
value: active.status,
});
const watched = (status.id === subject.id ? status.value : subject.status) === "watched";
const [marks, setMarks] = useState<Record<string, VideoStatus>>({});
const watched = (marks[subject.id] ?? subject.status) === "watched";
const setWatched = (on: boolean) => {
const next = on ? "watched" : "new";
setStatus({ id: subject.id, value: next });
setMarks((m) => ({ ...m, [subject.id]: next }));
onState(subject.id, next, subject);
};
@@ -722,8 +723,14 @@ export default function PlayerModal({
// Turned off on this instance (a daily limit of 0): no tab, no button — nothing that could only
// be refused.
const relatedOn = useMe().related_enabled;
const playRelated = (v: Video, startAt: number) => {
const playRelated = (v: Video, startAt: number | null) => {
setRelatedOpen(false);
// The video already playing (its row stays in the list): a new player for the same id would
// never be built (one per video), so a Restart is a seek, and a plain pick just keeps playing.
if (v.id === subject.id) {
if (startAt != null) seekTo(startAt);
return;
}
setRelated({ video: v, startAt });
};
const descPop = useAnchoredPopover({
@@ -762,6 +769,15 @@ export default function PlayerModal({
}, []);
useKeyScope("ytPlayer");
// Its keys wait under a layer above it (the drawer, a dialog — see onPlayerKey), so the help sheet
// opened there must not list them.
useEffect(() => {
setScopeCoveredProbe(
"ytPlayer",
() => layerIdRef.current != null && anyBlockingLayerAbove(layerIdRef.current),
);
return () => setScopeCoveredProbe("ytPlayer", null);
}, []);
// Keyboard shortcuts. The chords live in the shared keymap (`ytPlayer`
// scope) so the help sheet lists them; only the behaviour behind each id is here.
@@ -888,6 +904,7 @@ export default function PlayerModal({
useYtProgress({
activeId: subject.id,
resumeAt: subjectResume,
exactStart: related?.startAt != null,
mountRef,
playerRef,
currentIdRef,
@@ -25,6 +25,8 @@ import { RelatedVideos } from "./RelatedVideos";
import { api, HttpError, type RelatedVideo, type Video } from "../../lib/api";
import { clearAll, getNotifications } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys";
import { setHintsEnabled } from "../../lib/hints";
import { writeVideoState } from "../video/videoWrites";
beforeAll(() => {
class Stub {
@@ -65,7 +67,7 @@ const page = (items: RelatedVideo[], next: string | null = null) => ({
});
function setup(
onPlay: (v: Video, startAt: number) => void = () => {},
onPlay: (v: Video, startAt: number | null) => void = () => {},
onWrite?: (write: Promise<unknown>) => void,
) {
vi.spyOn(api, "comfortPins").mockResolvedValue({ items: [] } as never);
@@ -116,12 +118,17 @@ describe("RelatedVideos", () => {
expect(saved).toHaveBeenCalledWith({ relatedRegion: "hu" });
});
// A plain pick resumes (null: the player knows the freshest position); the row's own position
// travels on the video it hands over.
it("plays a row from where the user left it", async () => {
vi.spyOn(api, "related").mockResolvedValue(page([row("A", { position_seconds: 95 })]));
const onPlay = vi.fn();
setup(onPlay);
fireEvent.click(await title("Video A"));
expect(onPlay).toHaveBeenCalledWith(expect.objectContaining({ id: "A" }), 95);
expect(onPlay).toHaveBeenCalledWith(
expect.objectContaining({ id: "A", position_seconds: 95 }),
null,
);
});
it("drops a video hidden here at once, and brings it back when the server refuses", async () => {
@@ -308,6 +315,71 @@ describe("RelatedVideos", () => {
expect(await screen.findByText("player.related.followed")).toBeTruthy();
});
// An infinite query builds its next page from the pages it read when that fetch started: a mark
// that landed in between was dropped with them, for the session.
it("keeps a hide that landed while Show more was on its way", async () => {
let land: (p: ReturnType<typeof page>) => void = () => {};
vi.spyOn(api, "related")
.mockResolvedValueOnce(page([row("A"), row("C")], "CUR1"))
.mockReturnValueOnce(new Promise((r) => (land = r)) as never);
const write = vi.spyOn(api, "setState").mockResolvedValue({ ok: true } as never);
setup();
await title("Video A");
fireEvent.click(screen.getByRole("button", { name: "player.related.more" }));
fireEvent.contextMenu((await title("Video A")).closest("[data-video-id]")!);
fireEvent.click(menuItem("card.hide"));
await waitFor(() => expect(write).toHaveBeenCalledWith("A", "hidden"));
await act(async () => {});
await act(async () => land(page([row("B")])));
await title("Video B");
expect(titles()).toEqual(["Video C", "Video B"]);
});
// The Notification Center's undo, the feed's reset, a card elsewhere: every write reaches it.
it("follows a video's state written anywhere else", async () => {
vi.spyOn(api, "related").mockResolvedValue(page([row("A"), row("B")]));
vi.spyOn(api, "setState").mockResolvedValue({ ok: true } as never);
const { qc } = setup();
await title("Video A");
await act(async () => void (await writeVideoState(qc, "A", "hidden")));
await waitFor(() => expect(titles()).toEqual(["Video B"]));
await act(async () => void (await writeVideoState(qc, "A", "new")));
await waitFor(() => expect(titles()).toEqual(["Video A", "Video B"]));
});
// Show more is the request Retry would make: refused for good, it is not offered either.
it("offers no Show more once the next page was refused for good", async () => {
vi.spyOn(api, "related")
.mockResolvedValueOnce(page([row("A")], "CUR1"))
.mockRejectedValueOnce(
new HttpError(429, "Limit", { code: "refused", reason: "related_daily_limit", limit: 3 }),
);
setup();
await title("Video A");
fireEvent.click(screen.getByRole("button", { name: "player.related.more" }));
expect((await screen.findByRole("alert")).textContent).toContain("Limit");
expect(screen.queryByRole("button", { name: "player.related.more" })).toBeNull();
expect(screen.queryByRole("button", { name: "common.retry" })).toBeNull();
expect(titles()).toEqual(["Video A"]);
});
it("names the followed mark on hover with hints turned off too", async () => {
setHintsEnabled(false);
try {
vi.spyOn(api, "related").mockResolvedValue(page([row("A", { followed: true })]));
setup();
await title("Video A");
const mark = screen.getByRole("img", { name: "player.related.followed" });
fireEvent.mouseEnter(mark.parentElement!);
fireEvent.pointerEnter(mark.parentElement!, { pointerType: "mouse" });
expect(await screen.findByText("player.related.followed")).toBeTruthy();
} catch (e) {
setHintsEnabled(true);
throw e;
}
setHintsEnabled(true);
});
it("says when nothing is left that the user has not seen", async () => {
vi.spyOn(api, "related").mockResolvedValue(page([]));
setup();
+23 -13
View File
@@ -3,11 +3,18 @@ import { useTranslation } from "react-i18next";
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
import { CircleCheck } from "lucide-react";
import { api, HttpError, type RelatedRegion, type Video, type VideoStatus } from "../../lib/api";
import {
api,
HttpError,
refusalReason,
type RelatedRegion,
type Video,
type VideoStatus,
} from "../../lib/api";
import { qk } from "../../lib/queryKeys";
import { nextPageOnClick } from "../../lib/nextPage";
import { usePref, type UsePrefOptions } from "../../lib/usePref";
import { patchRelated } from "../../lib/progressCache";
import { useVideoMarks } from "../../lib/progressCache";
import { notifyActionError } from "../../lib/actionErrors";
import { Button } from "../../components/ui/Button";
import SegmentedToggle from "../../components/ui/SegmentedToggle";
@@ -18,6 +25,7 @@ import Tooltip from "../../components/ui/Tooltip";
import VideoCard from "../video/VideoCard";
import { VideoMenu } from "../video/VideoMenu";
import { announceVideoState } from "../video/stateNotice";
import { writeVideoState, writeWatchLater } from "../video/videoWrites";
/** Which list YouTube fills in — on the video's topic or with what trends in Hungary — is a mood,
* decided at the list, and the account keeps the last one (the owner's pick). */
@@ -33,9 +41,7 @@ const isSeen = (v: Video) => v.status === "watched" || v.status === "hidden";
/** The refusals a retry cannot answer: the day's cap (tomorrow does), the feature turned off on
* this instance and the shared budget spent (the admin, or tomorrow). */
const FINAL_REASONS = new Set(["related_daily_limit", "related_disabled", "quota"]);
const isFinal = (e: unknown) =>
e instanceof HttpError &&
FINAL_REASONS.has((e.detailData as { reason?: string } | undefined)?.reason ?? "");
const isFinal = (e: unknown) => FINAL_REASONS.has(refusalReason(e) ?? "");
// A write not yet confirmed: its change shows at once, and leaves when the server answers.
type Pending = { seq: number; id: string; change: Partial<Video> };
@@ -49,7 +55,8 @@ export function RelatedVideos({
onWrite,
}: {
videoId: string;
onPlay: (v: Video, startAt: number) => void;
/** `startAt` null resumes the video where it was left; a number (Restart's 0) starts there. */
onPlay: (v: Video, startAt: number | null) => void;
/** Each write this list makes, for the host's player session (see PlayerModal `onWrite`). */
onWrite?: (write: Promise<unknown>) => void;
}) {
@@ -83,11 +90,10 @@ export function RelatedVideos({
const onState = (id: string, status: VideoStatus) => {
const v = rows.find((x) => x.id === id);
const seq = begin(id, { status });
const write = api.setState(id, status);
const write = writeVideoState(qc, id, status);
onWrite?.(write);
write
.then(() => {
patchRelated(qc, id, { status });
settle(seq);
// Stale-mark the feed, as its own marks do: the video may be in it too.
void qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
@@ -101,11 +107,10 @@ export function RelatedVideos({
const onToggleSave = (id: string, saved: boolean) => {
const seq = begin(id, { saved });
const write = saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id);
const write = writeWatchLater(qc, id, saved);
onWrite?.(write);
write
.then(() => {
patchRelated(qc, id, { saved });
settle(seq);
void qc.invalidateQueries({ queryKey: qk.playlists() });
void qc.invalidateQueries({ queryKey: qk.playlist() });
@@ -119,12 +124,15 @@ export function RelatedVideos({
});
};
const open = (v: Video, startAt?: number | null) => onPlay(v, startAt ?? v.position_seconds);
const open = (v: Video, startAt?: number | null) => onPlay(v, startAt ?? null);
const menu = useContextMenu<Video>();
const openId = menu.open ? menu.target?.id : undefined;
// What this session confirmed about a row (here or anywhere else), then what is on its way.
const marks = useVideoMarks();
const rows = (query.data?.pages ?? [])
.flatMap((p) => p.items)
.map((v) => (marks[v.id] ? { ...v, ...marks[v.id] } : v))
.map((v) => pending.reduce((acc, w) => (w.id === v.id ? { ...acc, ...w.change } : acc), v))
.filter((v) => !isSeen(v));
const followed = new Set(
@@ -139,7 +147,8 @@ export function RelatedVideos({
: null;
// A refused page is the one to try again: with pages loaded, a refetch started over from the first
// (another scrape and another slot) instead of asking for the page that failed.
const retry = isFinal(query.error)
const final = isFinal(query.error);
const retry = final
? undefined
: query.data?.pages.length
? () => void query.fetchNextPage(nextPageOnClick(query.isFetchingNextPage))
@@ -203,7 +212,8 @@ export function RelatedVideos({
/>
))}
</NavGrid>
{query.hasNextPage && (
{/* Show more is the same request a Retry makes: refused for good, neither is offered. */}
{query.hasNextPage && !final && (
<div className="mt-3 flex justify-center">
<Button
variant="outline"
@@ -0,0 +1,34 @@
import { readdirSync, readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
// The whole app source: a state write can be made from any page.
const root = new URL("../../", import.meta.url);
const sources = () =>
readdirSync(root, { recursive: true, encoding: "utf8" })
.map((f) => f.replace(/\\/g, "/"))
.filter((f) => /\.tsx?$/.test(f) && !f.includes(".test."))
.map((f) => ({ f, src: readFileSync(new URL(f, root), "utf8") }));
// The api's own definitions of these writes, and the one module that may call them.
const ALLOWED = new Set([
"lib/api/feed.ts",
"lib/api/playlists.ts",
"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", () => {
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),
)
.map(({ f }) => f);
expect(offenders).toEqual([]);
});
});
@@ -0,0 +1,47 @@
// @vitest-environment jsdom
//
// Each write reaches the lists that are never refetched once the server has it — and not before:
// a refused write leaves them as they were.
import { afterEach, describe, expect, it, vi } from "vitest";
import { QueryClient } from "@tanstack/react-query";
import { api } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
import { resetVideoState, writeVideoState, writeWatchLater } from "./videoWrites";
afterEach(() => vi.restoreAllMocks());
const marks = (qc: QueryClient) => qc.getQueryData(qk.videoMarks());
describe("videoWrites", () => {
it("marks a state written", async () => {
vi.spyOn(api, "setState").mockResolvedValue({ ok: true } as never);
const qc = new QueryClient();
await writeVideoState(qc, "X", "hidden");
expect(marks(qc)).toEqual({ X: { status: "hidden" } });
});
it("marks a reset as new, from the start", async () => {
vi.spyOn(api, "clearState").mockResolvedValue({ ok: true } as never);
const qc = new QueryClient();
await resetVideoState(qc, "X");
expect(marks(qc)).toEqual({ X: { status: "new", position_seconds: 0 } });
});
it("marks a Watch-later save and its removal", async () => {
vi.spyOn(api, "watchLaterAdd").mockResolvedValue({ ok: true } as never);
vi.spyOn(api, "watchLaterRemove").mockResolvedValue({ ok: true } as never);
const qc = new QueryClient();
await writeWatchLater(qc, "X", true);
expect(marks(qc)).toEqual({ X: { saved: true } });
await writeWatchLater(qc, "X", false);
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();
await expect(writeVideoState(qc, "X", "hidden")).rejects.toThrow("offline");
expect(marks(qc)).toBeUndefined();
});
});
@@ -0,0 +1,34 @@
import type { QueryClient } from "@tanstack/react-query";
import { api, 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.
export function writeVideoState(qc: QueryClient, id: string, status: VideoStatus) {
return api.setState(id, status).then((r) => {
markVideo(qc, id, { status });
return r;
});
}
/** Back to pristine: the state row and its resume position both go. */
export function resetVideoState(qc: QueryClient, id: string) {
return api.clearState(id).then((r) => {
markVideo(qc, id, { status: "new", position_seconds: 0 });
return r;
});
}
export function writeWatchLater(qc: QueryClient, id: string, saved: boolean) {
return (saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id)).then((r) => {
markVideo(qc, id, { saved });
return r;
});
}