From 49a9319f84d0a8ba059dfc7865c6d5d40fca5775 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 8 Oct 2026 01:42:39 +0200 Subject: [PATCH] fix(player): the click layer clears only the fullscreen button's column, not a full-width band Measured: across the full width YouTube needs only its seek bar (up to 96 px from the bottom); the 140 px is the fullscreen button's, at the right end. A full-width 140 px band handed clicks on the picture to the iframe, which then takes the keyboard focus and our shortcuts with it. The layer is now two boxes, hidden from assistive tech (a mouse surface; the keys are its shortcuts), and the tests render the big view too, where the docked bar sits under the iframe. --- .../src/components/PlayerModal.dom.test.tsx | 67 +++++++++++++---- frontend/src/components/PlayerModal.tsx | 73 ++++++++++++------- 2 files changed, 101 insertions(+), 39 deletions(-) diff --git a/frontend/src/components/PlayerModal.dom.test.tsx b/frontend/src/components/PlayerModal.dom.test.tsx index beb507d1..7f7c661b 100644 --- a/frontend/src/components/PlayerModal.dom.test.tsx +++ b/frontend/src/components/PlayerModal.dom.test.tsx @@ -444,30 +444,69 @@ 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. +// YouTube's embed keeps its controls at fixed px from the iframe's edges: the seek bar across the bottom +// 52–96 px up, the fullscreen button above its right end 80–128 px up, the 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 = () => { + const layers = () => { vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {})); const A = vid("A", 0); renderModal( {}} onState={() => {}} />, ); - return screen.getByTestId("yt-click-layer"); + return screen.getAllByTestId("yt-click-layer"); + }; + const box = (el: HTMLElement) => { + const { left, right, width, top, bottom } = el.style; + return { left, right, width, top, bottom }; }; - 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("clears the seek bar across the width and the fullscreen button in its right-hand column", () => { + const [picture, column] = layers(); + expect(box(picture!)).toEqual({ + left: "0px", + right: "88px", + width: "", + top: "max(12%, 56px)", + bottom: "max(22%, 104px)", + }); + expect(box(column!)).toEqual({ + left: "", + right: "0px", + width: "88px", + top: "max(12%, 56px)", + bottom: "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|$)/); + // In the big views our docked bar sits under the iframe; YouTube's px are counted from the iframe's + // bottom, so a box measured from the stage's would start that bar's height too low again. + it.each([ + ["the windowed card", false, /(^|\s)relative(\s|$)/], + ["true fullscreen", true, /(^|\s)bottom-14(\s|$)/], + ])("measures those edges against the iframe's own box in %s", (_view, fullscreen, inset) => { + const els = layers(); + const stage = document.querySelector(".player-stage")!; + if (fullscreen) { + Object.defineProperty(document, "fullscreenElement", { + configurable: true, + get: () => stage, + }); + act(() => void document.dispatchEvent(new Event("fullscreenchange"))); + } + try { + for (const el of screen.getAllByTestId("yt-click-layer")) { + const wrapper = el.parentElement!; + expect(wrapper).not.toBe(stage); + expect(wrapper.parentElement).toBe(stage); + expect(wrapper.className).toMatch(inset); + expect(wrapper.className).not.toMatch(/(^|\s)p[byt]?-/); + } + expect(els).toHaveLength(2); + } finally { + delete (document as { fullscreenElement?: unknown }).fullscreenElement; + } }); }); diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 6622cf07..6d9cac7f 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -6,6 +6,7 @@ import { useLayoutEffect, useRef, useState, + type CSSProperties, } from "react"; import { useTranslation } from "react-i18next"; import { createPortal } from "react-dom"; @@ -106,13 +107,35 @@ 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. +// YouTube's embed draws its controls at fixed CSS px from the iframe's edges, whatever its size +// (measured 576×324 up to 2460×1384): the channel avatar, volume, captions and gear in a row along the +// top (48 px), the seek bar across the full width 52–96 px up from the bottom, and the fullscreen button +// above its right end, 80–128 px up and 24–72 px in from the right. 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. Only the right-hand column needs the +// fullscreen button's height: a full-width band that tall would hand the clicks on the picture to the +// iframe, which takes the keyboard focus and with it our shortcuts. On a player too short for any +// centre (under ~200 px, a phone) the layer is gone and YouTube's own touch controls take every tap. // 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; +const YT_SEEK_BAR_PX = 104; +const YT_FULLSCREEN_BUTTON_PX = { above: 140, fromRight: 88 }; +// The layer is these two boxes: the picture left of the fullscreen button down to the seek bar, and the +// column above that button. +const YT_CLICK_LAYER: CSSProperties[] = [ + { + left: 0, + right: YT_FULLSCREEN_BUTTON_PX.fromRight, + top: `max(12%, ${YT_TOP_CONTROLS_PX}px)`, + bottom: `max(22%, ${YT_SEEK_BAR_PX}px)`, + }, + { + right: 0, + width: YT_FULLSCREEN_BUTTON_PX.fromRight, + top: `max(12%, ${YT_TOP_CONTROLS_PX}px)`, + bottom: `max(22%, ${YT_FULLSCREEN_BUTTON_PX.above}px)`, + }, +]; // 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. @@ -710,34 +733,34 @@ export default function PlayerModal({ 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). + and never thinner than those controls (see YT_CLICK_LAYER). A mouse surface only, so hidden + from assistive tech: the keyboard has the shortcuts it stands for. 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" : "" - }`} - /> - )} + {playerError == null && + YT_CLICK_LAYER.map((box, i) => ( +
{ + focusModal(); + togglePlay(); + }} + title={t("player.shortcutsHint")} + aria-hidden="true" + style={box} + className={`absolute 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 - this bar's height (see mountRef's `bottom-14`), so the bar NEVER overlaps YouTube's seek + this bar's height (see the iframe wrapper's `bottom-14`), so the bar NEVER overlaps YouTube's seek bar — that stays reachable by hovering the video's bottom edge, just above ours. Always visible (no auto-hide) so there's no guessing whether it will appear and no fight with the seek bar over the same pixels. stopPropagation so a click here doesn't toggle playback;