fix(player): a related video played is tracked, and its list is honest about its state

A related video played in place is the player's item now: its title and its own watched /
playlist / download controls, its progress checkpointed and resumed, marked watched at its end —
and its end does not step the queue. Back returns to the opened video, where it was left.

The related list:
- its marks announce through the shared notice (the Notification Center finds them), join the
  host's player session (the feed refetches on close), and stay per write, so an earlier answer no
  longer undoes a later mark still on its way;
- every host's state write patches the cached related lists, which are never refetched;
- kept for the session (gcTime), a retry asks for the page that failed, a list read so far with
  nothing unseen says so, a refusal a retry cannot answer offers none, and the followed mark names
  itself on hover.

Turned off on the instance (a limit of 0), the tab, the drawer button and the live-search buttons
are left out.

Keys: the player's keys and its focus reclaim defer to a layer above it (the drawer); going wide
moves the drawer's list into the panel's tab; `j` goes on from the slot of a row removed inside the
top layer; the help sheet lists the list keys over a layer that holds a list.
This commit is contained in:
2026-10-25 00:32:28 +02:00
parent d53729a63d
commit b5045165c0
30 changed files with 1024 additions and 207 deletions
@@ -27,14 +27,19 @@ vi.mock("../navigation/navigationContext", () => ({
useNavigationActions: () => ({ openChannel: vi.fn() }),
}));
// Their own queries and popovers are not what is under test here.
vi.mock("../video/AddToPlaylist", () => ({ default: () => null }));
vi.mock("../downloads/DownloadButton", () => ({ default: () => null }));
vi.mock("../video/AddToPlaylist", () => ({
default: ({ videoId }: { videoId: string }) => <span data-testid={`add-${videoId}`} />,
}));
vi.mock("../downloads/DownloadButton", () => ({
default: ({ videoId }: { videoId: string }) => <span data-testid={`download-${videoId}`} />,
}));
import PlayerModal from "./PlayerModal";
import { ConfirmProvider } from "../../components/ui/ConfirmProvider";
import { useConfirm } from "../../components/ui/confirmContext";
import { api, type Video } from "../../lib/api";
import { api, type Me, type Video } from "../../lib/api";
import { clearAll, notify } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys";
beforeAll(() => {
class Stub {
@@ -96,6 +101,7 @@ const vid = (id: string, position_seconds: number): Video =>
function renderModal(ui: ReactNode) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.me(), me);
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
}
@@ -108,6 +114,12 @@ afterEach(() => {
volumes.length = 0;
});
// The signed-in account the player reads; related videos are on unless a test turns them off.
let me = { related_enabled: true } as Me;
beforeEach(() => {
me = { related_enabled: true } as Me;
});
const lastStart = () => started[started.length - 1];
describe("PlayerModal — where each item starts", () => {
@@ -434,7 +446,7 @@ describe("PlayerModal — the watch toggle after a queue advance", () => {
await settle();
// A really is marked watched — that part is correct and must keep working.
expect(onState.mock.calls).toContainEqual(["A", "watched"]);
expect(onState.mock.calls.map((c) => c.slice(0, 2))).toContainEqual(["A", "watched"]);
// B is not, and its toggle says so. Before the fix both toggles read "watchedUnmark" here.
expect(screen.queryAllByTitle("player.watchedUnmark")).toHaveLength(0);
expect(screen.queryAllByTitle("player.markWatched").length).toBeGreaterThan(0);
@@ -449,7 +461,7 @@ describe("PlayerModal — the watch toggle after a queue advance", () => {
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
fireEvent.click(screen.getAllByTitle("player.markWatched")[0]!);
expect(onState.mock.calls).toContainEqual(["A", "watched"]);
expect(onState.mock.calls.map((c) => c.slice(0, 2))).toContainEqual(["A", "watched"]);
expect(screen.queryAllByTitle("player.watchedUnmark").length).toBeGreaterThan(0);
});
});
@@ -813,13 +825,95 @@ describe("PlayerModal — the side panel on a wide window", () => {
fireEvent.click(screen.getByRole("tab", { name: "player.related.title" }));
fireEvent.click(titleOf(await screen.findAllByRole("link", { name: "Video rel2" })));
// In the same player, from where the user left it.
expect(loaded).toEqual([{ videoId: "rel2", startSeconds: 120 }]);
await waitFor(() => expect(lastStart()).toEqual({ videoId: "rel2", start: 120 }));
// The list stays the opened video's, to try the next one; it is not asked for again.
expect(screen.getAllByRole("link", { name: "Video rel1" }).length).toBeGreaterThan(0);
expect(asked.mock.calls).toEqual([["A", "topical", null]]);
expect(screen.getByRole("button", { name: "player.back" })).toBeTruthy();
});
// A related video played in place is the item it now is: tracked like a queue item, while the
// queue stays where it was and Back returns to the opened one.
const playRel2 = async (
onState: (id: string, status: string, v?: Video) => void,
queue?: Video[],
) => {
wideWindow(true);
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
vi.spyOn(api, "related").mockResolvedValue({
items: [related("rel1"), related("rel2", 120)],
has_more: false,
next_cursor: null,
});
const A = vid("A", 40);
renderModal(
<PlayerModal
video={A}
queue={queue ?? [A]}
startIndex={0}
onClose={() => {}}
onState={onState}
/>,
);
act(() => playerEvents.onReady?.());
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"));
};
it("wide: a related video played in place shows its own title and controls", async () => {
await playRel2(() => {});
expect(screen.getByRole("heading", { name: "Video rel2" })).toBeTruthy();
expect(screen.getAllByTestId("add-rel2").length).toBeGreaterThan(0);
expect(screen.getAllByTestId("download-rel2").length).toBeGreaterThan(0);
expect(screen.getAllByTitle("player.markWatched").length).toBeGreaterThan(0);
});
it("wide: a related video's progress is checkpointed, and Back resumes the opened one", async () => {
const saved = 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" }));
// Where leaving it for the related one checkpointed it, not the queue's snapshot (40).
await waitFor(() => expect(lastStart()).toEqual({ videoId: "A", start: 77 }));
expect(saved.mock.calls.map((c) => c[0])).toContain("rel2");
expect(screen.queryByRole("button", { name: "player.back" })).toBeNull();
});
it("wide: a related video watched to its end is marked watched, and the queue stays put", async () => {
const onState = vi.fn();
const B = vid("B", 0);
await playRel2(onState, [vid("A", 40), B]);
act(() => playerEvents.onStateChange?.({ data: 0 }));
expect(onState.mock.calls.map((c) => c.slice(0, 2))).toContainEqual(["rel2", "watched"]);
// Named for the host's notice: it is none of the host's own rows.
expect(onState.mock.calls[0]![2]).toMatchObject({ id: "rel2", title: "Video rel2" });
await settle();
expect(lastStart()?.videoId).toBe("rel2");
});
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(() => {}));
const A = vid("A", 0);
wideWindow(true);
const wideView = renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
expect(screen.queryByRole("tab", { name: "player.related.title" })).toBeNull();
expect(screen.getByTestId("player-description")).toBeTruthy();
wideView.unmount();
wideWindow(false);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
expect(screen.queryByRole("button", { name: "player.related.title" })).toBeNull();
});
// The list keys are the page's, silenced while a layer is up — but this list is IN the layer.
const press = (key: string) =>
act(() => void fireEvent.keyDown(document.activeElement ?? document.body, { key }));
@@ -858,7 +952,7 @@ describe("PlayerModal — the side panel on a wide window", () => {
expect(volumes).toEqual([]);
// Enter on the row plays it, like its title's click.
press("Enter");
expect(loaded).toEqual([{ videoId: "rel2", startSeconds: 0 }]);
await waitFor(() => expect(lastStart()?.videoId).toBe("rel2"));
});
it("stacked: the keyboard walks the list in the drawer too", async () => {
@@ -902,7 +996,7 @@ describe("PlayerModal — the side panel on a wide window", () => {
fireEvent.click(screen.getByRole("button", { name: "player.related.title" }));
const drawer = await screen.findByRole("dialog", { name: "player.related.title" });
fireEvent.click(titleOf(await within(drawer).findAllByRole("link", { name: "Video rel1" })));
expect(loaded).toEqual([{ videoId: "rel1", startSeconds: 0 }]);
await waitFor(() => expect(lastStart()?.videoId).toBe("rel1"));
await waitFor(() =>
expect(screen.queryByRole("dialog", { name: "player.related.title" })).toBeNull(),
);
@@ -911,6 +1005,89 @@ describe("PlayerModal — the side panel on a wide window", () => {
expect(onClose).not.toHaveBeenCalled();
});
const openDrawer = async () => {
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
vi.spyOn(api, "related").mockResolvedValue({
items: [related("rel1")],
has_more: false,
next_cursor: null,
});
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
act(() => playerEvents.onReady?.());
fireEvent.click(screen.getByRole("button", { name: "player.related.title" }));
const drawer = await screen.findByRole("dialog", { name: "player.related.title" });
await within(drawer).findAllByRole("link", { name: "Video rel1" });
return { drawer };
};
it("stacked: the video's keys are the drawer's while it is open", async () => {
wideWindow(false);
const { drawer } = await openDrawer();
act(() => within(drawer).getByRole("button", { name: "common.close" }).focus());
press("ArrowUp");
press("ArrowDown");
press("ArrowLeft");
press("ArrowRight");
expect(volumes).toEqual([]);
expect(seeks).toEqual([]);
});
it("stacked: the window regaining focus leaves it in the drawer", async () => {
wideWindow(false);
const { drawer } = await openDrawer();
const row = titleOf(within(drawer).getAllByRole("link", { name: "Video rel1" }));
act(() => row.focus());
act(() => void window.dispatchEvent(new Event("focus")));
expect(document.activeElement).toBe(row);
});
it("going wide moves the drawer's list into the panel's tab, and narrow again reopens nothing", async () => {
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
vi.spyOn(api, "related").mockResolvedValue({
items: [related("rel1")],
has_more: false,
next_cursor: null,
});
const A = vid("A", 0);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.me(), me);
const ui = () => (
<QueryClientProvider client={qc}>
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />
</QueryClientProvider>
);
wideWindow(false);
const view = render(ui());
fireEvent.click(screen.getByRole("button", { name: "player.related.title" }));
await screen.findByRole("dialog", { name: "player.related.title" });
wideWindow(true);
view.rerender(ui());
await waitFor(() =>
expect(screen.queryByRole("dialog", { name: "player.related.title" })).toBeNull(),
);
// The list the user was in is the panel's open tab now.
expect(
screen.getByRole("tab", { name: "player.related.title" }).getAttribute("aria-selected"),
).toBe("true");
wideWindow(false);
view.rerender(ui());
expect(screen.queryByRole("dialog", { name: "player.related.title" })).toBeNull();
});
it("wide: the panel's tabs keep the row's gap", () => {
wideWindow(true);
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
const row = screen.getByRole("tablist").className.split(" ");
expect(row).toEqual(expect.arrayContaining(["flex", "flex-wrap", "gap-1"]));
});
it("stacked: the card scrolls under the wheel, the video in it is still the volume", () => {
wideWindow(false);
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));