Files
siftlode/frontend/src/features/player/PlayerModal.dom.test.tsx
T
peter 620dd8178f feat(player): the side panel's list controls and watch toggle are glyphs on one line
The queue row (previous, count, next, auto-advance, loop) wrapped onto three lines in the
narrow side panel; glyph-only, it fits one. Mark watched joins the action icons as a toggle
(check / double check). Every control keeps its full name and shortcut as its tooltip and
accessible name; the docked bar keeps its labels. The related switch moves up to the toolbar
rung (12px), and its second side reads just "Trending".
2026-10-25 02:43:01 +01:00

1465 lines
64 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
//
// The YouTube player modal, rendered for real. Its pure helpers (afterRemoval, the progress cache)
// had tests; what the modal DOES with them had none — a review during the 2026-09 bug hunt found
// that deleting the lines that apply them left every lane green.
//
// `window.YT` is a fake installed before the module's API loader first runs (that loader memoises
// its promise in a module-level singleton, so this file must not share a module instance with a
// test that leaves `window.YT` unset). Each item the modal plays constructs a new YT.Player, and the
// fake records what it was asked for — that is how these tests see where playback starts.
import { describe, it, expect, vi, afterEach, beforeAll, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup, act, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
t: (key: string, o?: Record<string, unknown>) => (o?.title ? `${key}:${o.title}` : key),
i18n: { language: "en" },
}),
};
});
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: ({ 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 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";
import { clearAll, notify } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
// --- 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),
seekTo: (s: number) => void seeks.push(s),
setVolume: (v: number) => void volumes.push(v),
};
const seeks: number[] = [];
const volumes: number[] = [];
// 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.
const fakePlayer = new Proxy(base, {
get: (t, k) => (k in t ? t[k as keyof typeof t] : k === "then" ? undefined : () => undefined),
});
window.YT = {
Player: function (
_el: unknown,
opts: { videoId: string; playerVars: { start?: number }; events?: typeof playerEvents },
) {
started.push({ videoId: opts.videoId, start: opts.playerVars.start });
playerEvents = opts.events ?? {};
return fakePlayer;
} as never,
};
const vid = (id: string, position_seconds: number): Video =>
({
id,
title: `Video ${id}`,
channel_id: "UCx",
channel_title: "Channel",
status: "new",
position_seconds,
duration_seconds: 3000,
watch_url: `https://www.youtube.com/watch?v=${id}`,
}) as unknown as 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>);
}
afterEach(() => {
cleanup();
vi.restoreAllMocks();
started.length = 0;
loaded.length = 0;
seeks.length = 0;
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", () => {
it("resumes an item played earlier in the SAME session where this session left it", async () => {
// The launch queue is frozen at mount, so its rows keep the position they were snapshotted
// with (748). Stepping next→prev back to an item already watched to 830 used to start it at
// 748 — and the next checkpoint then wrote 748 over the 830 the server held.
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
vi.spyOn(api, "saveProgress").mockImplementation(async (id: string) => ({
video_id: id,
position_seconds: id === "A" ? 830 : 0,
}));
const A = vid("A", 748);
const B = vid("B", 0);
clock.current = 830;
renderModal(
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
await waitFor(() => expect(lastStart()).toEqual({ videoId: "A", start: 748 }));
fireEvent.click(screen.getAllByRole("button", { name: "player.next" })[0]!);
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
fireEvent.click(screen.getAllByRole("button", { name: "player.previous" })[0]!);
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
expect(lastStart()?.start).toBe(830);
});
});
// --- "remove from this playlist", from inside the player ----------------------------------------
const trash = (title: string) =>
screen.getByRole("button", { name: `playlists.removeItemOf:Video ${title}` });
const next = () => fireEvent.click(screen.getAllByRole("button", { name: "player.next" })[0]!);
const prev = () => fireEvent.click(screen.getAllByRole("button", { name: "player.previous" })[0]!);
const settle = () => act(async () => {});
describe("PlayerModal — removing the playing video from its playlist", () => {
const [A, B, C, D] = ["A", "B", "C", "D"].map((id) => vid(id, 0));
beforeEach(() => {
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
vi.spyOn(api, "saveProgress").mockImplementation(async (id: string) => ({
video_id: id,
position_seconds: 0,
}));
clock.current = 0;
});
it("steps to the item that followed, and the removed one is gone from the queue", async () => {
renderModal(
<PlayerModal
video={B!}
queue={[A!, B!, C!]}
startIndex={1}
onClose={() => {}}
onState={() => {}}
onRemoveFromList={async () => true}
/>,
);
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
fireEvent.click(trash("B"));
await waitFor(() => expect(lastStart()?.videoId).toBe("C"));
prev(); // …straight back to A: B is no longer there to step onto
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
});
it("closes the player when the last item goes", async () => {
const onClose = vi.fn();
renderModal(
<PlayerModal
video={A!}
queue={[A!]}
startIndex={0}
onClose={onClose}
onState={() => {}}
onRemoveFromList={async () => true}
/>,
);
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
fireEvent.click(trash("A"));
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
it("leaves playback and the queue alone when the removal did not happen", async () => {
const onClose = vi.fn();
renderModal(
<PlayerModal
video={A!}
queue={[A!, B!]}
startIndex={0}
onClose={onClose}
onState={() => {}}
onRemoveFromList={async () => false}
/>,
);
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
fireEvent.click(trash("A"));
await settle();
expect(onClose).not.toHaveBeenCalled();
expect(started.map((s) => s.videoId)).toEqual(["A"]);
next();
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
prev(); // A is still in the queue
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
});
it("keeps playback where the viewer went while the confirm was open", async () => {
// prev/next keep working under the confirm dialog; deciding from the cursor as it was when the
// button was pressed threw the viewer back onto a video they had already moved past.
let answer!: (ok: boolean) => void;
renderModal(
<PlayerModal
video={A!}
queue={[A!, B!, C!, D!]}
startIndex={0}
onClose={() => {}}
onState={() => {}}
onRemoveFromList={() => new Promise<boolean>((r) => (answer = r))}
/>,
);
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
fireEvent.click(trash("A"));
next();
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
next();
await waitFor(() => expect(lastStart()?.videoId).toBe("C"));
await act(async () => answer(true));
expect(lastStart()?.videoId).toBe("C");
prev();
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
// …and A is gone from the queue, not just skipped over: B is its head now, so stepping back
// again goes nowhere. (One step back lands on B either way — it took the second to tell.)
prev();
await settle();
expect(lastStart()?.videoId).toBe("B");
expect(started.filter((s) => s.videoId === "A")).toHaveLength(1); // never played again
});
it("gives focus back to the Remove button when the confirm is cancelled", async () => {
// The button used to be disabled for the confirm's whole lifetime, so the dialog's focus
// restore landed on a disabled element — a no-op — and a keyboard user was left on <body>,
// outside the aria-modal player.
function WithConfirm() {
const confirm = useConfirm();
return (
<PlayerModal
video={A!}
queue={[A!, B!]}
startIndex={0}
onClose={() => {}}
onState={() => {}}
onRemoveFromList={(v) =>
confirm({ title: "t", message: v.title, confirmLabel: "remove", danger: true })
}
/>
);
}
renderModal(
<ConfirmProvider>
<WithConfirm />
</ConfirmProvider>,
);
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
trash("A").focus();
fireEvent.click(trash("A"));
fireEvent.click(await screen.findByRole("button", { name: "common.cancel" }));
await waitFor(() => expect(document.activeElement).toBe(trash("A")));
});
});
describe("PlayerModal — toasts raised while it is open", () => {
afterEach(() => act(() => clearAll()));
it("shows them inside the player, not under its scrim", async () => {
// The app's Toaster is in a stacking context below this body-level portal: a failed Remove
// reported by a toast was drawn under the 80% black scrim, i.e. still silent.
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
act(() => void notify({ level: "error", message: "Couldn't remove the video." }));
expect(screen.getByTestId("player-modal").textContent).toContain("Couldn't remove the video.");
});
it("shows them inside the stage in true fullscreen — the element holding the lock", async () => {
// Anything outside the fullscreen element is painted under it. The player's host sat beside the
// card, so in fullscreen a lost connection was reported to nobody.
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
const stage = document.querySelector(".player-stage")!;
Object.defineProperty(document, "fullscreenElement", { configurable: true, get: () => stage });
try {
act(() => void document.dispatchEvent(new Event("fullscreenchange")));
act(() => void notify({ level: "error", message: "Connection lost" }));
await waitFor(() => expect(stage.textContent).toContain("Connection lost"));
} finally {
delete (document as { fullscreenElement?: unknown }).fullscreenElement;
}
});
});
// --- the dialog contract it now shares (ui/dialogFocus) -----------------------------------------
// This was once the one `aria-modal` shell with no accessible name, no Tab trap and no focus
// restore: Tab walked out of an open player into the page behind the scrim, and closing it with
// Escape left a keyboard user on <body>, their place in a long feed lost.
describe("PlayerModal — the dialog contract", () => {
const open = (onClose = () => {}) => {
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0);
return renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={onClose} onState={() => {}} />,
);
};
it("is named by the video's title", () => {
open();
expect(screen.getByRole("dialog", { name: "Video A" })).toBeTruthy();
});
it("opens focused on its card, not on a control, so its shortcuts keep working", () => {
open();
const active = document.activeElement as HTMLElement;
expect(active.tagName).not.toBe("BUTTON");
expect(screen.getByTestId("player-modal").contains(active)).toBe(true);
});
it("gives the focus back to what opened it when it closes", () => {
const opener = document.createElement("button");
document.body.appendChild(opener);
opener.focus();
try {
const view = open();
expect(document.activeElement).not.toBe(opener);
view.unmount();
expect(document.activeElement).toBe(opener);
} finally {
opener.remove();
}
});
// The card takes the focus back on its own when it may have slipped into the iframe: the player
// turning ready, the pointer leaving the video, the window regaining focus. None of those may take
// it off a control the viewer tabbed to, or their next Tab starts over from the first button.
it.each([
["the player turns ready", () => playerEvents.onReady?.()],
[
"the pointer leaves the video",
() => {
fireEvent.mouseLeave(document.querySelector(".player-stage")!);
},
],
[
"the window regains focus",
() => {
fireEvent.focus(window);
},
],
])("leaves the focus on a control the viewer tabbed to when %s", async (_why, happen) => {
open();
await waitFor(() => expect(playerEvents.onReady).toBeTypeOf("function"));
const control = within(screen.getByTestId("player-modal")).getByTestId("player-close");
control.focus();
act(happen);
expect(document.activeElement).toBe(control);
});
it("takes the focus back from the iframe when the player turns ready", async () => {
open();
await waitFor(() => expect(playerEvents.onReady).toBeTypeOf("function"));
(document.activeElement as HTMLElement).blur();
act(() => playerEvents.onReady?.());
expect(screen.getByTestId("player-modal").contains(document.activeElement)).toBe(true);
});
it("keeps Tab inside the player", () => {
open();
const dialog = screen.getByTestId("player-modal");
const tabbables = [...dialog.querySelectorAll<HTMLElement>("button:not([disabled]), a[href]")];
const last = tabbables[tabbables.length - 1]!;
last.focus();
// jsdom has no layout, so the trap's visibility filter would see nothing tabbable; give every
// element an offsetParent for the duration of this check.
const spy = vi
.spyOn(HTMLElement.prototype, "offsetParent", "get")
.mockReturnValue(document.body);
try {
fireEvent.keyDown(window, { key: "Tab" });
expect(dialog.contains(document.activeElement)).toBe(true);
expect(document.activeElement).not.toBe(last);
} finally {
spy.mockRestore();
}
});
});
// --- the watch toggle belongs to the item it describes -------------------------------------------
// A late writer exists by construction: useYtProgress's cleanup for the OUTGOING item runs after the
// commit that already advanced `active`, and within the finish margin it auto-marks that item
// watched. While the mirror was a bare boolean that write landed last and painted the INCOMING item
// as watched. Keying the mirror by id makes the stray write not this item's, so it is ignored.
describe("PlayerModal — the watch toggle after a queue advance", () => {
const A = vid("A", 0);
const B = vid("B", 0);
beforeEach(() => {
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
vi.spyOn(api, "saveProgress").mockImplementation(async (id: string) => ({
video_id: id,
position_seconds: 0,
}));
});
it("does not inherit the outgoing item's auto-watch when stepping away near the end", async () => {
const onState = vi.fn();
clock.current = 2995; // inside min(FINISH_MARGIN, duration/2) = 10s of 3000
renderModal(
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={onState} />,
);
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
next();
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
await settle();
// A really is marked watched — that part is correct and must keep working.
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);
});
it("still reflects a watch toggled on the item you are actually on", async () => {
const onState = vi.fn();
clock.current = 0;
renderModal(
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={onState} />,
);
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
fireEvent.click(screen.getAllByTitle("player.markWatched")[0]!);
expect(onState.mock.calls.map((c) => c.slice(0, 2))).toContainEqual(["A", "watched"]);
expect(screen.queryAllByTitle("player.watchedUnmark").length).toBeGreaterThan(0);
});
});
// C3: the description used to open on hovering the title, which a finger never does. It opens from
// an ⓘ beside the title now, by click or tap, and a hover over the title opens nothing.
describe("PlayerModal — the description", () => {
it("opens from the ⓘ, not from hovering the title", async () => {
vi.spyOn(api, "videoDetail").mockResolvedValue({
description: "A long description",
} as Awaited<ReturnType<typeof api.videoDetail>>);
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
fireEvent.mouseEnter(screen.getByRole("heading", { name: "Video A" }));
fireEvent.pointerEnter(screen.getByRole("heading", { name: "Video A" }));
await new Promise((r) => setTimeout(r, 500));
expect(screen.queryByText("A long description")).toBeNull();
const info = screen.getByRole("button", { name: "player.description" });
fireEvent.click(info);
expect(await screen.findByText("A long description")).toBeTruthy();
expect(info.getAttribute("aria-expanded")).toBe("true");
});
});
// The full-height strips beside the card were a second Previous / Next that cost the video 7rem of
// width (owner): the panel's transport row and Shift+← / → step on their own.
describe("PlayerModal — no side strips", () => {
it("steps from the panel only, and the card keeps no gutter for strips", () => {
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0);
const B = vid("B", 0);
renderModal(
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
expect(screen.getAllByRole("button", { name: "player.next" })).toHaveLength(1);
expect(screen.getAllByRole("button", { name: "player.previous" })).toHaveLength(1);
// stage → its wrapper (with the clip panel) → the card → the row
const row =
document.querySelector<HTMLElement>(".player-stage")!.parentElement!.parentElement!
.parentElement!;
expect(row.className).not.toMatch(/px-14/);
});
// The panel is narrow: worded, the queue row wrapped onto three lines. Each glyph keeps its name.
it("keeps the panel's queue row and the watch toggle to glyphs, each still named", () => {
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0);
const B = vid("B", 0);
renderModal(
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
const named = [
"player.previous",
"player.next",
/^player\.autoAdvance\.label: /,
/^player\.loop\.label: /,
"player.markWatched",
];
for (const name of named) {
const [button] = screen.getAllByRole("button", { name });
expect(button!.textContent!.trim(), String(name)).toBe("");
expect(button!.querySelector("svg"), String(name)).not.toBeNull();
}
});
it("closes from one icon-only button", () => {
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const onClose = vi.fn();
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={onClose} onState={() => {}} />,
);
const close = screen.getByTestId("player-close");
expect(close.getAttribute("aria-label")).toBe("player.close");
expect(close.textContent).toBe("");
fireEvent.click(close);
expect(onClose).toHaveBeenCalledTimes(1);
});
});
// YouTube's embed keeps its controls at fixed px from the iframe's edges: the seek bar across the bottom
// 52–96 px up, the fullscreen button above its right end 80–128 px up, the gear and volume along the
// top. A cutout given only as a share of the height lay over that button on a short player (a small
// window, a browser zoomed to 150 %): the click paused instead.
describe("PlayerModal — the click layer over the video", () => {
const layers = () => {
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
return screen.getAllByTestId("yt-click-layer");
};
const box = (el: HTMLElement) => {
const { left, right, width, top, bottom } = el.style;
return { left, right, width, top, bottom };
};
it("clears the seek bar across the width and the fullscreen button in its right-hand column", () => {
const [picture, column] = layers();
expect(box(picture!)).toEqual({
left: "0px",
right: "88px",
width: "",
top: "max(12%, 56px)",
bottom: "max(22%, 104px)",
});
expect(box(column!)).toEqual({
left: "",
right: "0px",
width: "88px",
top: "max(12%, 56px)",
bottom: "max(22%, 140px)",
});
});
// In the big views our docked bar sits under the iframe; YouTube's px are counted from the iframe's
// bottom, so a box measured from the stage's would start that bar's height too low again.
it.each([
["the windowed card", false, /(^|\s)relative(\s|$)/],
["true fullscreen", true, /(^|\s)bottom-14(\s|$)/],
])("measures those edges against the iframe's own box in %s", (_view, fullscreen, inset) => {
const els = layers();
const stage = document.querySelector(".player-stage")!;
if (fullscreen) {
Object.defineProperty(document, "fullscreenElement", {
configurable: true,
get: () => stage,
});
act(() => void document.dispatchEvent(new Event("fullscreenchange")));
}
try {
for (const el of screen.getAllByTestId("yt-click-layer")) {
const wrapper = el.parentElement!;
expect(wrapper).not.toBe(stage);
expect(wrapper.parentElement).toBe(stage);
expect(wrapper.className).toMatch(inset);
expect(wrapper.className).not.toMatch(/(^|\s)p[byt]?-/);
}
expect(els).toHaveLength(2);
} finally {
delete (document as { fullscreenElement?: unknown }).fullscreenElement;
}
});
});
// At 133% zoom (a 685px window) a full-width card ran 20px past the bottom and cut its channel
// row. The card's width is capped from the window height less what sits under the video, measured.
describe("PlayerModal — fits the window's height", () => {
it("caps the card's width by the chrome measured under the video", () => {
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockImplementation(function (
this: HTMLElement,
) {
return this.classList.contains("player-stage") ? 470 : 0;
});
vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockImplementation(function (
this: HTMLElement,
) {
return this.querySelector(":scope > * > .player-stage") ? 630 : 0;
});
const A = vid("A", 0);
const B = vid("B", 0);
renderModal(
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
// stage → its wrapper (with the clip panel) → the card → the row
const row =
document.querySelector<HTMLElement>(".player-stage")!.parentElement!.parentElement!
.parentElement!;
expect(row.style.maxWidth).toBe("min(56rem, max(36rem, (100dvh - 3rem - 160px) * 16 / 9))");
});
});
// The owner's pick: on a wide window the video was capped at ~1008 px of a 1920 screen, with
// empty space all round. From 1280 px the title, channel, actions, description and queue sit in a side
// panel beside the video, and the video takes the window's height.
describe("PlayerModal — the side panel on a wide window", () => {
const wideWindow = (matches: boolean) =>
vi.spyOn(window, "matchMedia").mockImplementation(
(q: string) =>
({
matches: matches && q === "(min-width: 1280px)",
media: q,
addEventListener: () => {},
removeEventListener: () => {},
}) as unknown as MediaQueryList,
);
// jsdom has no matchMedia to spy on: a bare one is put in for these tests and taken out after, or
// every later test's media query would read that bare stub.
let added = false;
beforeEach(() => {
if (typeof window.matchMedia === "function") return;
window.matchMedia = (() => ({})) as unknown as typeof window.matchMedia;
added = true;
});
afterEach(() => {
// The spy goes first: restoring it later would put the bare stub back.
vi.restoreAllMocks();
if (added) delete (window as { matchMedia?: unknown }).matchMedia;
added = false;
});
it("puts the details beside the video, the description open, and sizes the card for both", async () => {
wideWindow(true);
vi.spyOn(api, "videoDetail").mockResolvedValue({
description: "A long description",
} as Awaited<ReturnType<typeof api.videoDetail>>);
const A = vid("A", 0);
const B = vid("B", 0);
renderModal(
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
const stage = document.querySelector<HTMLElement>(".player-stage")!;
const card = stage.parentElement!.parentElement!; // the stage sits in a wrapper (+ clip panel)
const info = screen.getByTestId("player-info");
// The card is a row: the video, then the panel holding the title and the channel.
expect(card.className).toMatch(/(^|\s)flex(\s|$)/);
expect(card.contains(info) && !stage.contains(info)).toBe(true);
expect(info.contains(screen.getByRole("heading", { name: "Video A" }))).toBe(true);
expect(stage.compareDocumentPosition(info) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
// The description is shown outright; there is no ⓘ to open it.
expect(await screen.findByText("A long description")).toBeTruthy();
expect(screen.queryByRole("button", { name: "player.description" })).toBeNull();
// The row's width: the video at the window's height, plus the panel.
expect(card.parentElement!.style.maxWidth).toBe("calc((100dvh - 3rem - 0px) * 16 / 9 + 20rem)");
// A long channel name is cut on the panel's line, not run past its 20rem into a sideways scroll.
const name = within(info).getByText("Channel").closest("button, a")!.parentElement!;
expect(name.className).toMatch(/\bmin-w-0\b/);
expect(name.className).toMatch(/\bflex-1\b/);
expect(name.className).not.toMatch(/\bshrink-0\b/);
// The description is cut to three lines, with a More once it is cut.
expect((await screen.findByText("A long description")).closest(".line-clamp-3")).toBeTruthy();
});
it("shuts the panel to a strip for a bigger video, and keeps it shut as the queue steps on", () => {
wideWindow(true);
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0);
const B = vid("B", 0);
renderModal(
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
const row = () =>
document.querySelector<HTMLElement>(".player-stage")!.parentElement!.parentElement!
.parentElement!;
fireEvent.click(screen.getByRole("button", { name: "player.collapsePanel" }));
expect(screen.queryByTestId("player-info")).toBeNull();
const strip = screen.getByTestId("player-strip");
// The pressed toggle is gone: focus is on its counterpart, not on <body>.
expect(document.activeElement).toBe(
within(strip).getByRole("button", { name: "player.expandPanel" }),
);
expect(row().style.maxWidth).toBe("calc((100dvh - 3rem - 0px) * 16 / 9 + 2.75rem)");
// The strip keeps the way out and the stepping.
expect(within(strip).getByTestId("player-close")).toBeTruthy();
// The dialog keeps its name with the title out of sight: it is named by that heading's id.
expect(screen.getByRole("dialog", { name: "Video A" })).toBeTruthy();
fireEvent.click(within(strip).getByRole("button", { name: "player.next" }));
expect(screen.getByTestId("player-strip")).toBeTruthy(); // still shut on the next video
expect(screen.getByRole("dialog", { name: "Video B" })).toBeTruthy();
fireEvent.click(
within(screen.getByTestId("player-strip")).getByRole("button", {
name: "player.expandPanel",
}),
);
expect(screen.getByTestId("player-info")).toBeTruthy();
expect(document.activeElement).toBe(
screen.getByRole("button", { name: "player.collapsePanel" }),
);
expect(row().style.maxWidth).toBe("calc((100dvh - 3rem - 0px) * 16 / 9 + 20rem)");
});
it("gives the focus to the player when the scissors that opened the clip is not there to take it", async () => {
wideWindow(true);
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
// Shut by the user: closing the clip leaves it shut, and its strip has no scissors.
fireEvent.click(screen.getByRole("button", { name: "player.collapsePanel" }));
act(
() =>
void window.dispatchEvent(
new KeyboardEvent("keydown", { key: "c", bubbles: true, cancelable: true }),
),
);
fireEvent.click(screen.getByRole("button", { name: "clip.close" }));
await waitFor(() => expect(document.activeElement).not.toBe(document.body));
expect(screen.getByRole("dialog").contains(document.activeElement)).toBe(true);
});
it("shuts while a clip is marked and opens again after it, unless the user opened it meanwhile", async () => {
wideWindow(true);
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
fireEvent.click(screen.getByRole("button", { name: "clip.open" }));
expect(screen.getByTestId("player-strip")).toBeTruthy();
// The clip panel sits under the video here too: the stacked card's floor, and its scroll past it
// (without them the fit ran the card down to a sliver at 133% with the largest text).
const stage = document.querySelector<HTMLElement>(".player-stage")!;
const cardEl = stage.parentElement!.parentElement!;
expect(cardEl.parentElement!.style.maxWidth).toBe(
"calc(max(36rem, (100dvh - 3rem - 0px) * 16 / 9) + 2.75rem)",
);
expect(cardEl.className).toContain("overflow-y-auto");
fireEvent.click(screen.getByRole("button", { name: "clip.close" }));
expect(screen.getByTestId("player-info")).toBeTruthy();
expect(cardEl.className).toContain("overflow-hidden");
// Opened again by hand while clipping: it stays open, and closing the clip leaves it so.
fireEvent.click(screen.getByRole("button", { name: "clip.open" }));
fireEvent.click(
within(screen.getByTestId("player-strip")).getByRole("button", {
name: "player.expandPanel",
}),
);
fireEvent.click(screen.getByRole("button", { name: "player.collapsePanel" }));
fireEvent.click(screen.getByRole("button", { name: "clip.close" }));
expect(screen.getByTestId("player-strip")).toBeTruthy();
});
it("gives a panel with something to scroll the wheel for that alone, the volume elsewhere", async () => {
wideWindow(true);
vi.spyOn(api, "videoDetail").mockResolvedValue({
description: "A long description",
} as Awaited<ReturnType<typeof api.videoDetail>>);
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
const text = await screen.findByText("A long description");
const panel = screen.getByTestId("player-info");
let top = 0;
Object.defineProperty(panel, "scrollTop", { get: () => top, configurable: true });
Object.defineProperty(panel, "scrollHeight", { value: 900, configurable: true });
Object.defineProperty(panel, "clientHeight", { value: 500, configurable: true });
const spin = (el: Element, deltaY: number) => {
const e = new WheelEvent("wheel", { deltaY, bubbles: true, cancelable: true });
el.dispatchEvent(e);
return e.defaultPrevented; // prevented = the wheel went to the volume
};
expect(spin(text, 100)).toBe(false); // room below: the panel scrolls
expect(spin(text, -100)).toBe(false); // at its top too: one gesture, one meaning (owner)
top = 400;
expect(spin(text, 100)).toBe(false); // and at its end, where it once fell through to the volume
expect(spin(document.querySelector(".player-stage")!, 100)).toBe(true); // over the video
// A panel that fits has nothing to scroll: the wheel is the volume there.
Object.defineProperty(panel, "scrollHeight", { value: 500, configurable: true });
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.
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[],
opts: { onRemoveFromList?: (v: Video) => Promise<boolean>; before?: () => void } = {},
) => {
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}
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"));
};
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 () => {
// Auto-advance on: an end of the queue's own item would step to B.
me = { related_enabled: true, preferences: { playerAutoAdvance: "next" } } as unknown as Me;
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");
});
// 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(() => {}));
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 }));
const rowOf = (el: Element | null) => el?.closest("[data-nav-index]");
it("wide: the keyboard walks the related list, and its arrows are not the video's", async () => {
wideWindow(true);
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
vi.spyOn(api, "related").mockResolvedValue({
items: [related("rel1"), related("rel2")],
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("tab", { name: "player.related.title" }));
const first = titleOf(await screen.findAllByRole("link", { name: "Video rel1" }));
const second = titleOf(screen.getAllByRole("link", { name: "Video rel2" }));
act(() => first.focus());
// (jsdom lays nothing out, so the rows read as one row: ←→ step them, ↑↓ have no row to go to.)
press("ArrowRight");
expect(document.activeElement).toBe(rowOf(second));
press("ArrowLeft");
expect(document.activeElement).toBe(rowOf(first));
press("j");
expect(document.activeElement).toBe(rowOf(second));
press("k");
press("j");
// None of the arrows reached the video: no seek, no volume step.
press("ArrowUp");
press("ArrowDown");
expect(seeks).toEqual([]);
expect(volumes).toEqual([]);
// Enter on the row plays it, like its title's click.
press("Enter");
await waitFor(() => expect(lastStart()?.videoId).toBe("rel2"));
});
it("stacked: the keyboard walks the list in the drawer too", async () => {
wideWindow(false);
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
vi.spyOn(api, "related").mockResolvedValue({
items: [related("rel1"), related("rel2")],
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" });
const first = titleOf(await within(drawer).findAllByRole("link", { name: "Video rel1" }));
act(() => first.focus());
press("j");
expect(document.activeElement).toBe(
rowOf(titleOf(within(drawer).getAllByRole("link", { name: "Video rel2" }))),
);
});
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" })));
await waitFor(() => expect(lastStart()?.videoId).toBe("rel1"));
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();
});
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([]);
});
// 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();
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(() => {}));
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
const stage = document.querySelector<HTMLElement>(".player-stage")!;
const card = stage.parentElement!.parentElement!; // the stage sits in a wrapper (+ clip panel)
Object.defineProperty(card, "scrollHeight", { value: 900, configurable: true });
Object.defineProperty(card, "clientHeight", { value: 500, configurable: true });
const spin = (el: Element) => {
const e = new WheelEvent("wheel", { deltaY: 100, bubbles: true, cancelable: true });
el.dispatchEvent(e);
return e.defaultPrevented;
};
expect(spin(screen.getByRole("heading", { name: "Video A" }))).toBe(false);
expect(spin(stage)).toBe(true);
});
// A failed load read "No description." in the always-open panel — an error drawn as empty.
it("says the description failed to load, with a retry, instead of 'No description'", async () => {
wideWindow(true);
const detail = vi
.spyOn(api, "videoDetail")
.mockRejectedValueOnce(new Error("boom"))
.mockResolvedValue({ description: "A long description" } as Awaited<
ReturnType<typeof api.videoDetail>
>);
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
const panel = screen.getByTestId("player-description");
expect(await within(panel).findByText("player.descriptionFailed")).toBeTruthy();
expect(within(panel).queryByText("player.noDescription")).toBeNull();
fireEvent.click(within(panel).getByRole("button", { name: "common.retry" }));
expect(await within(panel).findByText("A long description")).toBeTruthy();
expect(detail).toHaveBeenCalledTimes(2);
});
// The popover below 1280 px said it the same way only by a copy nobody tested.
it("says it in the description popover below 1280 px too", async () => {
wideWindow(false);
vi.spyOn(api, "videoDetail").mockRejectedValue(new Error("boom"));
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
fireEvent.click(screen.getByRole("button", { name: "player.description" }));
expect(await screen.findByText("player.descriptionFailed")).toBeTruthy();
expect(screen.queryByText("player.noDescription")).toBeNull();
});
it("keeps the details under the video below 1280 px", () => {
wideWindow(false);
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
const card = document.querySelector<HTMLElement>(".player-stage")!.parentElement!;
expect(card.className).not.toMatch(/(^|\s)flex(\s|$)/);
expect(screen.getByRole("button", { name: "player.description" })).toBeTruthy();
expect(screen.queryByTestId("player-description")).toBeNull();
});
});
// Brave on an iPad cannot fullscreen an element (it fakes `fullscreenEnabled`): the docked bar's
// true-fullscreen button did nothing there. The window-filling view is then the biggest one.
describe("PlayerModal — the docked bar's fullscreen", () => {
const maximised = (api_: boolean) => {
if (api_)
Object.defineProperty(Element.prototype, "requestFullscreen", {
configurable: true,
value: () => Promise.resolve(),
});
// Brave's stand-in otherwise: a <video>-only requestFullscreen on HTMLElement.prototype.
else
Object.defineProperty(HTMLElement.prototype, "requestFullscreen", {
configurable: true,
value: () => true,
});
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
fireEvent.click(screen.getByLabelText("player.fullscreen"));
return screen.getAllByLabelText("player.fullscreen").length;
};
afterEach(() => {
delete (Element.prototype as { requestFullscreen?: unknown }).requestFullscreen;
delete (HTMLElement.prototype as { requestFullscreen?: unknown }).requestFullscreen;
});
it("is offered where the browser can fullscreen an element", () => {
expect(maximised(true)).toBe(2);
});
it("is left out where it cannot", () => {
expect(maximised(false)).toBe(1);
});
});
// Clip mode: the panel sits under the video (the video is its preview), Escape closes the panel and
// not the player, the scissors gets the focus back; in the maximised view the panel takes the docked
// bar's place; a crop frame lies over the video and Escape turns it off before the panel closes.
describe("PlayerModal — clip mode", () => {
const key = (k: string) =>
act(
() =>
void window.dispatchEvent(
new KeyboardEvent("keydown", { key: k, bubbles: true, cancelable: true }),
),
);
const open = (onClose = () => {}) => {
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={onClose} onState={() => {}} />,
);
};
it("opens under the video on C, and Escape closes the panel, not the player", async () => {
const onClose = vi.fn();
clock.current = 42;
open(onClose);
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
key("c");
const panel = screen.getByTestId("clip-panel");
const stage = document.querySelector(".player-stage")!;
expect(stage.compareDocumentPosition(panel) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(screen.getByRole("slider", { name: "clip.in" }).getAttribute("aria-valuenow")).toBe(
"42",
);
key("Escape");
expect(screen.queryByTestId("clip-panel")).toBeNull();
expect(onClose).not.toHaveBeenCalled();
await waitFor(() =>
expect(document.activeElement).toBe(screen.getByRole("button", { name: "clip.open" })),
);
});
it("marks in the maximised view without leaving it: the panel takes the docked bar's place", async () => {
clock.current = 10;
open();
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
key("f");
const stage = document.querySelector(".player-stage--max")!;
expect(stage).toBeTruthy();
key("]");
expect(document.querySelector(".player-stage--max")).toBe(stage);
expect(stage.contains(screen.getByTestId("clip-panel"))).toBe(true);
expect(screen.queryByRole("button", { name: "player.exitMaximize" })).toBeNull();
expect(screen.getByRole("slider", { name: "clip.out" }).getAttribute("aria-valuenow")).toBe(
"10",
);
// Escape: the panel first, then the maximised view, then (not here) the player.
key("Escape");
expect(screen.queryByTestId("clip-panel")).toBeNull();
expect(document.querySelector(".player-stage--max")).toBe(stage);
expect(screen.getByRole("button", { name: "player.exitMaximize" })).toBeTruthy();
});
it("draws a short's frame on its 9:16 picture and tells the cut so", async () => {
const enqueue = vi.spyOn(api, "enqueueClip").mockResolvedValue({ id: 9 } as never);
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const S = { ...vid("S", 0), is_short: true } as Video;
renderModal(
<PlayerModal video={S} queue={[S]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
await waitFor(() => expect(lastStart()?.videoId).toBe("S"));
key("c");
fireEvent.click(screen.getByRole("button", { name: "clip.crop" }));
fireEvent.click(screen.getByRole("button", { name: "clip.cut" }));
await waitFor(() => expect(enqueue).toHaveBeenCalled());
expect(enqueue.mock.calls[0]![0].crop?.aspect).toBe(0.5625);
});
it("lays the crop frame over the video, and Escape turns it off before closing the panel", async () => {
// jsdom lays nothing out: the frame's layer measures a 16:9 box.
vi.spyOn(Element.prototype, "clientWidth", "get").mockReturnValue(1600);
vi.spyOn(Element.prototype, "clientHeight", "get").mockReturnValue(900);
open();
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
key("c");
fireEvent.click(screen.getByRole("button", { name: "clip.crop" }));
const frame = screen.getByTestId("crop-frame");
expect(document.querySelector(".player-stage")!.contains(frame)).toBe(true);
act(() => screen.getByRole("group", { name: "common.cropFrame.frame" }).focus());
key("Escape");
expect(screen.queryByTestId("crop-frame")).toBeNull();
expect(screen.getByTestId("clip-panel")).toBeTruthy();
// The frame took the focus with it: it is on the Crop toggle, not on <body>.
expect(document.activeElement).toBe(screen.getByRole("button", { name: "clip.crop" }));
key("Escape");
expect(screen.queryByTestId("clip-panel")).toBeNull();
});
it("gives the focus back to the scissors the user can see: in the maximised view, the bar's", async () => {
open();
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
key("f");
key("c");
key("Escape");
const stage = document.querySelector(".player-stage--max")!;
await waitFor(() =>
expect(document.activeElement?.getAttribute("aria-label")).toBe("clip.open"),
);
// The card's own scissors lies under the maximised stage: a Space there reopened the panel.
expect(stage.contains(document.activeElement)).toBe(true);
});
it("keeps what was typed in the panel when the view is maximised under it", async () => {
open();
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
key("c");
fireEvent.change(screen.getByRole("textbox", { name: "clip.name" }), {
target: { value: "The bit" },
});
const loop = screen.getByRole("button", { name: "clip.loop" });
fireEvent.click(loop);
act(() => loop.focus());
const panel = screen.getByTestId("clip-panel");
key("f");
expect(document.querySelector(".player-stage--max")!.contains(panel)).toBe(true);
expect(screen.getByTestId("clip-panel")).toBe(panel);
expect((screen.getByRole("textbox", { name: "clip.name" }) as HTMLInputElement).value).toBe(
"The bit",
);
expect(loop.getAttribute("aria-pressed")).toBe("true");
expect(document.activeElement).toBe(loop);
// And back: the dock the panel sat in goes, the panel and its focus stay.
key("f");
expect(document.querySelector(".player-stage--max")).toBeNull();
expect(screen.getByTestId("clip-panel")).toBe(panel);
expect(document.activeElement).toBe(loop);
});
it("does not step on to the next video when one ends while a clip is marked", async () => {
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0);
const B = vid("B", 0);
renderModal(
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
// Auto-advance on: an ending video steps on to B.
fireEvent.click(screen.getAllByRole("button", { name: /^player\.autoAdvance\.label/ })[0]!);
key("c");
act(() => playerEvents.onStateChange?.({ data: 0 }));
await settle();
expect(lastStart()?.videoId).toBe("A");
expect(screen.getByTestId("clip-panel")).toBeTruthy();
});
});