From 00886d92febb3166de2215f65fa202ceedbc6ba8 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 5 Oct 2026 23:40:13 +0200 Subject: [PATCH] fix(player): the YouTube player keeps its size on a short window and when maximised MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/e2e/player.spec.ts | 40 ++++++++++++++++++- .../src/components/PlayerModal.dom.test.tsx | 6 ++- frontend/src/components/PlayerModal.tsx | 15 +++++-- frontend/src/index.css | 6 +++ 4 files changed, 60 insertions(+), 7 deletions(-) diff --git a/frontend/e2e/player.spec.ts b/frontend/e2e/player.spec.ts index 0fdc3bfd..8a234fa9 100644 --- a/frontend/e2e/player.spec.ts +++ b/frontend/e2e/player.spec.ts @@ -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]); + }); + } +}); diff --git a/frontend/src/components/PlayerModal.dom.test.tsx b/frontend/src/components/PlayerModal.dom.test.tsx index f856b5cd..21d3a03d 100644 --- a/frontend/src/components/PlayerModal.dom.test.tsx +++ b/frontend/src/components/PlayerModal.dom.test.tsx @@ -465,7 +465,9 @@ describe("PlayerModal — fits the window's height", () => { {}} onState={() => {}} />, ); const row = document.querySelector(".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)", ); }); diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index e6506986..bce2d2b1 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -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 , 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 && ( diff --git a/frontend/src/index.css b/frontend/src/index.css index 686669d7..d6adc705 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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. */