feat(player): a video's related videos, on demand beside it

The player shows what YouTube lists beside the video, without what the
user has watched or hidden. Wide, a Related tab beside the description;
stacked, a button beside the info opens them in a drawer. A row plays in
the same player (Back returns) and the list stays the opened video's.
A switch picks on-topic or Hungarian-trending lists, kept on the account.

Each related row says whether the user follows its channel; the row is
the video card's narrow form with its whole menu. VideoMenu moves to
features/video, where the player can reach it.
This commit is contained in:
2026-10-24 16:57:52 +02:00
parent 8b37c470ba
commit 686c673fb2
18 changed files with 697 additions and 19 deletions
@@ -52,12 +52,15 @@ beforeAll(() => {
// --- the fake IFrame API ------------------------------------------------------------------------
const clock = { current: 0, duration: 3000 };
const started: { videoId: string; start: number | undefined }[] = [];
// What the player was asked to switch to in place (a description link, a related video).
const loaded: { videoId: string; startSeconds: number }[] = [];
// The last player's event handlers, so a test can say "the player is ready" when it wants to.
let playerEvents: { onReady?: () => void; onStateChange?: (e: { data: number }) => void } = {};
const base = {
getCurrentTime: () => clock.current,
getDuration: () => clock.duration,
getVideoData: () => null,
loadVideoById: (o: { videoId: string; startSeconds: number }) => void loaded.push(o),
};
// Anything else the modal calls on the player (volume, rate, destroy…) is a no-op. Not `then`:
// a thenable would be swallowed by the first `await` that touched it.
@@ -96,6 +99,7 @@ afterEach(() => {
cleanup();
vi.restoreAllMocks();
started.length = 0;
loaded.length = 0;
});
const lastStart = () => started[started.length - 1];
@@ -777,6 +781,66 @@ describe("PlayerModal — the side panel on a wide window", () => {
expect(spin(text, 100)).toBe(true);
});
// A row is reached by its title (its picture is a link of the same name).
const titleOf = (links: HTMLElement[]) => links.find((a) => a.hasAttribute("data-nav-open"))!;
const related = (id: string, position_seconds = 0) => ({
...vid(id, position_seconds),
followed: false,
});
it("wide: the related videos are a tab beside the description, and one plays in place", async () => {
wideWindow(true);
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const asked = vi.spyOn(api, "related").mockResolvedValue({
items: [related("rel1"), related("rel2", 120)],
has_more: false,
next_cursor: null,
});
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
act(() => playerEvents.onReady?.());
// Closed until asked: nothing is fetched for a player nobody opened the list in.
expect(screen.getByTestId("player-description")).toBeTruthy();
expect(asked).not.toHaveBeenCalled();
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 }]);
// 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();
});
it("stacked: a button opens the related videos in a drawer, and playing one closes it", async () => {
wideWindow(false);
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 onClose = vi.fn();
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={onClose} onState={() => {}} />,
);
act(() => playerEvents.onReady?.());
expect(screen.queryByRole("tab", { name: "player.related.title" })).toBeNull();
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(screen.queryByRole("dialog", { name: "player.related.title" })).toBeNull(),
);
// Only the drawer: the click on its row must not reach the player's backdrop (measured live —
// rendered outside the card, the whole player closed).
expect(onClose).not.toHaveBeenCalled();
});
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(() => {}));