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