feat(ui): E31 S4c — the channel header folds, the Plex pages fill their hero, the player gets a side panel
- Channel page: the banner and identity block fold away once the grid scrolls (ui/scrollFold: folds past 32px only if the page can stay scrolled, unfolds at the very top) and the name rides in the tab row. One Back button in both states keeps its focus; folded at a tablet width the actions wrap instead of pushing the page sideways. - Plex movie and show pages: the TMDB facts over the key crew are the hero's second column (plexDetailAside), the keywords sit under the text; the show page shows them for the first time, with the same three toggles. A collection's hero is sized to its text. - YouTube player, 1280px and wider: the title, channel, actions, description and queue sit in a side panel beside the video, which takes the window's height (1438x809 at 1920x911, was ~1008 wide). The wheel scrolls that panel while it can and turns the volume elsewhere. - DESIGN.md decision log: the keywords' place under the text.
This commit is contained in:
@@ -468,3 +468,90 @@ describe("PlayerModal — fits the window's height", () => {
|
||||
expect(row.style.maxWidth).toBe("min(63rem, calc((100dvh - 3rem - 160px) * 16 / 9 + 7rem))");
|
||||
});
|
||||
});
|
||||
|
||||
// E31 (the owner's pick "A"): 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,
|
||||
);
|
||||
beforeEach(() => {
|
||||
window.matchMedia ??= (() => ({})) as unknown as typeof window.matchMedia;
|
||||
});
|
||||
|
||||
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!;
|
||||
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 and the side strips.
|
||||
expect(card.parentElement!.style.maxWidth).toBe(
|
||||
"calc((100dvh - 3rem - 0px) * 16 / 9 + 20rem + 7rem)",
|
||||
);
|
||||
});
|
||||
|
||||
it("lets the wheel scroll the panel while it can, and turn the volume everywhere else", 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(true); // at its top: nothing to scroll up, so the volume
|
||||
top = 400;
|
||||
expect(spin(text, 100)).toBe(true); // at its end: the volume
|
||||
expect(spin(text, -100)).toBe(false);
|
||||
expect(spin(document.querySelector(".player-stage")!, 100)).toBe(true); // over the video
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user