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.
This commit is contained in:
2026-10-08 01:36:36 +02:00
parent 3865689c26
commit 70d56ab939
2 changed files with 65 additions and 22 deletions
@@ -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(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} 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", () => {
+20 -4
View File
@@ -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. */}
<div
className={
maximized || isFullscreen ? "absolute inset-x-0 top-0 bottom-14" : "w-full h-full"
maximized || isFullscreen
? "absolute inset-x-0 top-0 bottom-14"
: "relative w-full h-full"
}
>
<div
ref={mountRef}
className={`w-full h-full ${playerError != null ? "invisible" : ""}`}
/>
</div>
{/* 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.
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 && (
<div
data-testid="yt-click-layer"
onClick={() => {
focusModal();
togglePlay();
}}
title={t("player.shortcutsHint")}
aria-label={t("player.shortcutsHint")}
className={`absolute inset-x-0 top-[12%] bottom-[22%] z-base cursor-pointer ${
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" : ""
}`}
/>
)}
</div>
{/* 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
<body> portal would not) and DOCKED in its own strip: the video area above is inset by