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
@@ -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;
});
}