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();