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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user