fix(player): the YouTube player keeps its size on a short window and when maximised

Two layout bugs since the player grew a side panel, both reported from prod.

Maximised (F) on a wide window, the video was a thin line across the screen: the side-panel layout
aligns the stage `self-start`, and Chrome applies a flex item's self-alignment to an absolutely
positioned box too, so the fixed stage took its content's height — 0 px. The maximised stage now
stretches between its insets whatever the layout asks of it.

On a short window under 1280 px (a laptop at 100%, a 1080p screen at 175%) only the side strips were
left: the card narrows so the chrome under the video fits, but that chrome grows as the card narrows
(its text wraps), and the loop ran the card down to 2 px. The video now never goes narrower than
36rem to make room; past that the card scrolls.

Measured before/after (localdev, real YouTube iframe): 1097×520 open 0×0 → 610×343; 1920×911 maximised
1920×0 → 1920×911; 1440×760 and 1366×650 maximised fill the window; leaving maximise restores the card.
Held by an e2e test at both sizes (red on the old code: 0 px wide, and 911 ≠ 0), green on Chrome,
WebKit and iPad.
This commit is contained in:
2026-10-05 23:40:13 +02:00
parent e7d2eb8cf6
commit 00886d92fe
4 changed files with 60 additions and 7 deletions
+39 -1
View File
@@ -1,4 +1,4 @@
import { test, expect } from "@playwright/test";
import { test, expect, type Page } from "@playwright/test";
import { openApp, openFirstVideo } from "./helpers";
// Locks the in-app player's open/close contract. The player is a portaled overlay whose
@@ -26,3 +26,41 @@ test.describe("player modal", () => {
await expect(page.getByTestId("player-modal")).toHaveCount(0);
});
});
// 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).
test.describe("player modal sizing", () => {
test.beforeEach(async ({ page }) => {
await page.route(/youtube\.com/, (route) => route.abort());
});
const box = async (page: Page, selector: string) =>
(await page.locator(selector).first().boundingBox())!;
for (const [w, h] of [
[1920, 911],
[1097, 520],
] as const) {
test(`the video keeps a real size at ${w}×${h}, and maximised fills the window`, async ({
page,
}) => {
await page.setViewportSize({ width: w, height: h });
await openApp(page);
await openFirstVideo(page);
// A short window narrows the card so the chrome under the video fits — but the chrome grows
// as the card narrows (its text wraps), and that loop once ran the card down to 2 px.
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);
// 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.
await page.getByTestId("player-modal").press("f");
await expect(page.locator(".player-stage--max")).toHaveCount(1);
const max = await box(page, ".player-stage--max");
expect([Math.round(max.width), Math.round(max.height)]).toEqual([w, h]);
});
}
});
@@ -465,7 +465,9 @@ describe("PlayerModal — fits the window's height", () => {
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
const row = document.querySelector<HTMLElement>(".player-stage")!.parentElement!.parentElement!;
expect(row.style.maxWidth).toBe("min(63rem, calc((100dvh - 3rem - 160px) * 16 / 9 + 7rem))");
expect(row.style.maxWidth).toBe(
"min(63rem, calc(max(36rem, (100dvh - 3rem - 160px) * 16 / 9) + 7rem))",
);
});
});
@@ -510,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((100dvh - 3rem - 0px) * 16 / 9 + 20rem + 7rem)",
"calc(max(36rem, (100dvh - 3rem - 0px) * 16 / 9) + 20rem + 7rem)",
);
});
+11 -4
View File
@@ -104,6 +104,8 @@ 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).
const MIN_VIDEO = "36rem";
// The meta line's items each lead with a "·" that parts them from what stands before: the channel name
// on its line. In the wide panel the meta has a line of its own, where the first "·" would lead nothing.
@@ -611,6 +613,10 @@ export default function PlayerModal({
onLoadVideo: loadVideo,
};
// 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 gutters = hasQueue ? " + 7rem" : "";
// Portaled to <body>, like every other full-screen overlay here. A fixed, full-viewport modal
// must not render inside the page scroller: once that scroller is a stacking context (its
// edge-fade mask makes it one) this z-overlay is scoped inside it, and the nav rail (z-rail) and the
@@ -636,12 +642,13 @@ export default function PlayerModal({
// 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.
style={{
maxWidth: wide
? `calc((100dvh - 3rem - ${below}px) * 16 / 9 + ${SIDE_PANEL}${hasQueue ? " + 7rem" : ""})`
: `min(${hasQueue ? "63rem" : "56rem"}, calc((100dvh - 3rem - ${below}px) * 16 / 9${
hasQueue ? " + 7rem" : ""
}))`,
? `calc(${videoWidth} + ${SIDE_PANEL}${gutters})`
: `min(${hasQueue ? "63rem" : "56rem"}, calc(${videoWidth}${gutters}))`,
}}
>
{hasQueue && (
+6
View File
@@ -1176,6 +1176,12 @@ html[data-theme="light"] {
.player-stage--max {
position: fixed;
inset: 0;
/* Stretched between the insets, whatever the layout asks of the in-flow stage: the side-panel
layout aligns it `self-start`, and Chrome applies a flex item's self-alignment to an absolutely
positioned box too — the fixed stage then took its content's height, 0 px (its content is all
absolutely positioned): a thin line across the window instead of the video. */
align-self: stretch;
justify-self: stretch;
/* rail level: this stage only has to beat the modal chrome it is nested inside (whose own
children top out at the menu level). The modal root is a z-overlay stacking context, so nothing
here escapes it — a higher level would claim an authority this layer neither has nor needs. */