diff --git a/frontend/e2e/player.spec.ts b/frontend/e2e/player.spec.ts index 8a234fa9..4e4fcfdc 100644 --- a/frontend/e2e/player.spec.ts +++ b/frontend/e2e/player.spec.ts @@ -29,7 +29,8 @@ test.describe("player modal", () => { // The video's size, in the windows the owner meets: layout bugs no DOM test can see. Each viewport // is a real one — 1920×911 is a 1080p screen at 100% (the side-panel layout), 1097×520 the same -// screen at 175% or a small laptop at 100% (the stacked layout, on a short window). +// screen at 175% or a small laptop at 100% (the stacked layout, on a short window), 1400×380 a wide +// window made short by docked devtools (the side-panel layout, whose card does not scroll). test.describe("player modal sizing", () => { test.beforeEach(async ({ page }) => { await page.route(/youtube\.com/, (route) => route.abort()); @@ -41,6 +42,7 @@ test.describe("player modal sizing", () => { for (const [w, h] of [ [1920, 911], [1097, 520], + [1400, 380], ] as const) { test(`the video keeps a real size at ${w}×${h}, and maximised fills the window`, async ({ page, @@ -54,6 +56,16 @@ test.describe("player modal sizing", () => { const stage = await box(page, ".player-stage"); expect(stage.width).toBeGreaterThanOrEqual(Math.min(w * 0.5, 560)); expect(stage.height).toBeGreaterThan(stage.width * 0.5); + // …and all of it is in sight: its bottom edge is the YouTube control bar, and the side-panel + // card clips what overflows it (it does not scroll), the stacked card scrolls to it. + const overflow = await page + .locator(".player-stage") + .first() + .evaluate((el) => { + const card = el.parentElement!; + return el.getBoundingClientRect().bottom - card.getBoundingClientRect().bottom; + }); + expect(overflow).toBeLessThanOrEqual(0.5); // Maximise (F): the stage is fixed to the viewport. In the side-panel layout its `self-start` // once aligned the fixed box too, and it shrank to its content's height — 0 px, a thin line. diff --git a/frontend/src/components/PlayerModal.dom.test.tsx b/frontend/src/components/PlayerModal.dom.test.tsx index 21d3a03d..774e903b 100644 --- a/frontend/src/components/PlayerModal.dom.test.tsx +++ b/frontend/src/components/PlayerModal.dom.test.tsx @@ -512,7 +512,7 @@ describe("PlayerModal — the side panel on a wide window", () => { 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(max(36rem, (100dvh - 3rem - 0px) * 16 / 9) + 20rem + 7rem)", + "calc((100dvh - 3rem - 0px) * 16 / 9 + 20rem + 7rem)", ); }); diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index bce2d2b1..b4e24f3c 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -104,7 +104,7 @@ function canScrollBy(el: HTMLElement | null, dir: -1 | 1): boolean { // a rem width, so it grows with the text size; the video takes what is left. const WIDE_PLAYER_PX = 1280; const SIDE_PANEL = "20rem"; -// The narrowest the video gets to make room for the chrome under it (see the card's maxWidth). +// The narrowest the stacked video gets to make room for the chrome under it (see the card's maxWidth). const MIN_VIDEO = "36rem"; // The meta line's items each lead with a "·" that parts them from what stands before: the channel name @@ -614,7 +614,7 @@ export default function PlayerModal({ }; // The card's width budget (its maxWidth, below): the video's, and the side strips' gutters. - const videoWidth = `max(${MIN_VIDEO}, (100dvh - 3rem - ${below}px) * 16 / 9)`; + const fitWidth = `(100dvh - 3rem - ${below}px) * 16 / 9`; const gutters = hasQueue ? " + 7rem" : ""; // Portaled to , like every other full-screen overlay here. A fixed, full-viewport modal @@ -641,14 +641,16 @@ export default function PlayerModal({ className={`relative flex w-full max-h-full ${hasQueue ? "sm:px-14" : ""}`} // The design width (63rem with the side strips, else 56rem), narrowed so the 16:9 video plus the // chrome under it fits the window's height less the backdrop's padding (`p-6` = 3rem). - // Wide: no design cap — the video is as wide as the window's height allows, plus the side panel. - // Never narrower than MIN_VIDEO: the chrome under the video grows as the card narrows (its text - // wraps), so on a short window fitting it was a loop that ran the card down to 2 px (a laptop - // at 100%, a 1080p screen at 175%). Past the floor the card scrolls instead. + // Wide: no design cap — the video is as wide as the window's height allows, plus the side panel, + // whose content sits beside the video (`below` is ~0), so the fit is exact and the card, which + // does not scroll there, always holds the whole video. + // Stacked: never narrower than MIN_VIDEO. The chrome under the video grows as the card narrows + // (its text wraps), so on a short window fitting it was a loop that ran the card down to 2 px + // (a laptop at 100%, a 1080p screen at 175%). Past the floor the stacked card scrolls instead. style={{ maxWidth: wide - ? `calc(${videoWidth} + ${SIDE_PANEL}${gutters})` - : `min(${hasQueue ? "63rem" : "56rem"}, calc(${videoWidth}${gutters}))`, + ? `calc(${fitWidth} + ${SIDE_PANEL}${gutters})` + : `min(${hasQueue ? "63rem" : "56rem"}, calc(max(${MIN_VIDEO}, ${fitWidth})${gutters}))`, }} > {hasQueue && (