From 70d56ab939cb920cb597718de5d47c30365b4c8f Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 8 Oct 2026 01:36:36 +0200 Subject: [PATCH] fix(player): YouTube's fullscreen button stays clickable on a short player The click layer over the embed left the bottom 22 % of the stage free. YouTube's new embed keeps its fullscreen button 80-128 px above the iframe's bottom, so on a stage under ~580 px (a small window, a browser zoomed to 150 %) the layer lay over it and a click paused instead. The cutouts now have px floors (56 top, 140 bottom) and are measured against the iframe's own box. --- .../src/components/PlayerModal.dom.test.tsx | 27 +++++++++ frontend/src/components/PlayerModal.tsx | 60 ++++++++++++------- 2 files changed, 65 insertions(+), 22 deletions(-) diff --git a/frontend/src/components/PlayerModal.dom.test.tsx b/frontend/src/components/PlayerModal.dom.test.tsx index 774e903b..beb507d1 100644 --- a/frontend/src/components/PlayerModal.dom.test.tsx +++ b/frontend/src/components/PlayerModal.dom.test.tsx @@ -444,6 +444,33 @@ describe("PlayerModal — the side arrows", () => { }); }); +// YouTube's embed keeps its fullscreen button at a fixed 80–128 px above the iframe's bottom, its 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 layer = () => { + vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {})); + const A = vid("A", 0); + renderModal( + {}} onState={() => {}} />, + ); + return screen.getByTestId("yt-click-layer"); + }; + + it("leaves YouTube's own controls a px floor at the top and the bottom, whatever the height", () => { + const el = layer(); + expect(el.style.top).toBe("max(12%, 56px)"); + expect(el.style.bottom).toBe("max(22%, 140px)"); + }); + + it("measures those edges against the iframe's box, not the stage under our docked bar", () => { + const el = layer(); + expect(el.parentElement!.classList.contains("player-stage")).toBe(false); + expect(el.parentElement!.closest(".player-stage")).toBeTruthy(); + expect(el.parentElement!.className).toMatch(/(^|\s)(relative|absolute)(\s|$)/); + }); +}); + // 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", () => { diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index b4e24f3c..6622cf07 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -106,6 +106,13 @@ const WIDE_PLAYER_PX = 1280; const SIDE_PANEL = "20rem"; // The narrowest the stacked video gets to make room for the chrome under it (see the card's maxWidth). const MIN_VIDEO = "36rem"; +// YouTube's embed draws its controls at fixed CSS px from the iframe's edges, whatever its size: the +// volume, captions and gear along the top, the fullscreen button above the seek bar 80–128 px up from +// the bottom. The click layer's cutouts never shrink below those, else on a short player (a small window, +// a browser zoomed to 150 % or more) the layer lies over that button and a click on it pauses instead. +// Px, not rem: the text-size slider does not grow YouTube's controls. +const YT_TOP_CONTROLS_PX = 56; +const YT_BOTTOM_CONTROLS_PX = 140; // 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. @@ -689,35 +696,44 @@ export default function PlayerModal({ which would drop any class on mountRef itself — the iframe then sizes to this wrapper. */}
+ {/* Interaction layer over the CENTRE of the video: catches click (play/pause) + wheel + volume (the wheel event bubbles from here to the modal's listener — over the bare + cross-origin iframe it would be swallowed) and stops the iframe stealing keyboard focus. + It leaves the top AND bottom edges uncovered so YouTube's edge controls stay reachable, + measured against the iframe it sits in (in the big views that excludes our docked bar) + and never thinner than those controls (see YT_*_CONTROLS_PX). + Armed in the big views too (that is what keeps wheel volume working after F, the reported + gap) — it only yields to `nativeControls`, i.e. once the user opens YouTube's own + settings menu (focus moves into the iframe), so that menu is navigable end to end. + Hidden on error so the "Open on YouTube" CTA is clickable. */} + {playerError == null && ( +
{ + focusModal(); + togglePlay(); + }} + title={t("player.shortcutsHint")} + aria-label={t("player.shortcutsHint")} + style={{ + top: `max(12%, ${YT_TOP_CONTROLS_PX}px)`, + bottom: `max(22%, ${YT_BOTTOM_CONTROLS_PX}px)`, + }} + className={`absolute inset-x-0 z-base cursor-pointer ${ + nativeControls ? "pointer-events-none" : "" + }`} + /> + )}
- {/* Interaction layer over the CENTRE of the video: catches click (play/pause) + wheel - volume (the wheel event bubbles from here to the modal's listener — over the bare - cross-origin iframe it would be swallowed) and stops the iframe stealing keyboard focus. - It leaves the top AND bottom edges uncovered so YouTube's edge controls stay reachable. - Armed in the big views too (that is what keeps wheel volume working after F, the reported - gap) — it only yields to `nativeControls`, i.e. once the user opens YouTube's own - settings menu (focus moves into the iframe), so that menu is navigable end to end. - Hidden on error so the "Open on YouTube" CTA is clickable. */} - {playerError == null && ( -
{ - focusModal(); - togglePlay(); - }} - title={t("player.shortcutsHint")} - aria-label={t("player.shortcutsHint")} - className={`absolute inset-x-0 top-[12%] bottom-[22%] z-base cursor-pointer ${ - nativeControls ? "pointer-events-none" : "" - }`} - /> - )} {/* Our own control bar for the big views (maximise / our fullscreen), where the card's controls are off-screen. It's IN the stage (so it survives true fullscreen, where a portal would not) and DOCKED in its own strip: the video area above is inset by