From 70d56ab939cb920cb597718de5d47c30365b4c8f Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 8 Oct 2026 01:36:36 +0200 Subject: [PATCH 01/22] 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 From 49a9319f84d0a8ba059dfc7865c6d5d40fca5775 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 8 Oct 2026 01:42:39 +0200 Subject: [PATCH 02/22] 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; From ca949d22dee5bc49c671227d28cf910a8b16fc31 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 8 Oct 2026 02:25:58 +0200 Subject: [PATCH 03/22] fix(player): the channel line wraps instead of squeezing the meta into a column On a narrow card (a tablet, a zoomed window) the unwrapped row of name, meta and actions left the meta a one-word column. The row now wraps: the meta keeps at least 12rem, and the actions move down together. --- frontend/src/components/PlayerModal.tsx | 103 ++++++++++++------------ 1 file changed, 53 insertions(+), 50 deletions(-) diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 6d9cac7f..ae4f2b94 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -145,6 +145,9 @@ const metaSep = ( ); const META_OWN_LINE = "w-full [&>span:first-child>[data-meta-sep]]:hidden"; +// Beside the channel name the meta takes what is left of the line, but never less than this: under it +// the line wraps and the meta, or the actions after it, move down instead of squeezing. +const META_MIN = "min-w-48 flex-1"; export default function PlayerModal({ video, @@ -961,10 +964,13 @@ export default function PlayerModal({
- {/* Channel + meta on one line, with the watched toggle pushed to the right. + {/* Channel + meta on one line, with the actions pushed to the right. When navigated to a linked video we only have its author (from the player), - so we show that as plain text and hide feed-video-specific bits. */} -
+ so we show that as plain text and hide feed-video-specific bits. + It wraps: the meta never gets narrower than META_MIN, and the actions move down as one + group. Unwrapped, a narrow card (a tablet, a zoomed window) squeezed the meta into a + one-word column between the name and the buttons. */} +
{!navigated && ( {active.view_count != null && ( @@ -1043,7 +1049,7 @@ export default function PlayerModal({ ) : ( // Linked video's stats come from the (already-fetched) video detail.
{detail.data?.view_count != null && ( @@ -1076,51 +1082,48 @@ export default function PlayerModal({ own (which hides our chrome). Mirrors the F key (maximise, a CSS big view); from there the docked bar offers OUR true fullscreen. */} {!navigated && ( - - - - )} - {!navigated && } - {!navigated && ( - - )} - {!navigated && onRemoveFromList && ( - void removeFromList()} - // Named with its object, like the list's menu row: a bare "Remove" among Fullscreen / - // Download / Mark watched reads as "delete this video". - label={t("playlists.removeItemOf", { title: active.title })} - title={t("playlists.removeItem")} - > - - - )} - {!navigated && ( - + // Narrow: the actions end the channel line. Wide: their own line in the panel. +
+ + + + + + {onRemoveFromList && ( + void removeFromList()} + // Named with its object, like the list's menu row: a bare "Remove" among Fullscreen / + // Download / Mark watched reads as "delete this video". + label={t("playlists.removeItemOf", { title: active.title })} + title={t("playlists.removeItem")} + > + + + )} + +
)}
{wide && ( From 1044eaf8a967be3afcb356ed4572442ca078b3a2 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 8 Oct 2026 02:42:31 +0200 Subject: [PATCH 04/22] fix(plex): the player holds the page still and covers it whole; the row drops the countdown On an iPad a drag on the video scrolled the page behind the player, and in Brave dvh did not follow the toolbar, so a strip of the page showed under it. The page is now locked without overscroll on a black root while the player is open, and the player is sized from its fixed box. The time row keeps the position, the length and the end clock; the countdown is gone (owner). --- .../PlexPlayer.controls.dom.test.tsx | 34 +++++++++++++++++++ frontend/src/components/PlexPlayer.tsx | 34 ++++++++++++++----- frontend/src/components/ui/dialogFocus.ts | 2 +- 3 files changed, 60 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/PlexPlayer.controls.dom.test.tsx b/frontend/src/components/PlexPlayer.controls.dom.test.tsx index 1db2467d..4ad6c7fc 100644 --- a/frontend/src/components/PlexPlayer.controls.dom.test.tsx +++ b/frontend/src/components/PlexPlayer.controls.dom.test.tsx @@ -857,3 +857,37 @@ describe("the seek bar (C3)", () => { expect(head?.className).toContain("[@media(hover:none)]:opacity-100"); }); }); + +describe("the time in the row", () => { + it("reads the position and the length, and the clock it ends at — no countdown", async () => { + mount({ details: { A: item("A") } }); + await playingAt("/media/A", 100); + const row = screen.getByText(/1:40 \/ 50:00/).closest("div")!; + expect(row.textContent).not.toMatch(/-\s*48:20/); + expect(row.textContent).toMatch(/\(.+\)/); + }); +}); + +// On an iPad a drag on the video moved the page behind it, and the strip it moved showed the page's +// own background under the player. +describe("the page under the player", () => { + it("is locked, without overscroll, on black, while the player is open — and given back after", async () => { + const root = document.documentElement.style; + mount({ details: { A: item("A") } }); + await playingAt("/media/A", 100); + expect(document.body.style.overflow).toBe("hidden"); + expect(root.overscrollBehavior).toBe("none"); + expect(root.backgroundColor).toBe("black"); + cleanup(); + expect(document.body.style.overflow).toBe(""); + expect(root.overscrollBehavior).toBe(""); + expect(root.backgroundColor).toBe(""); + }); + + it("is covered by a player sized from its fixed box, not from dvh", async () => { + mount({ details: { A: item("A") } }); + await playingAt("/media/A", 100); + const wrap = video().closest("[class*='fixed']")!; + expect([wrap.style.width, wrap.style.height]).toEqual(["80%", "80%"]); + }); +}); diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index 9ba2dcf3..996a258c 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -84,7 +84,7 @@ import { LS, useAccountPersistedObject } from "../lib/storage"; import { useDismiss } from "../lib/useDismiss"; import { useScrollFade } from "../lib/useScrollFade"; import { useBackToClose } from "../lib/history"; -import { trapTab } from "./ui/dialogFocus"; +import { lockBodyScroll, trapTab } from "./ui/dialogFocus"; import { pushLayer, removeLayer, @@ -154,6 +154,21 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false } // hands it to its neighbour instead of . The player is portaled outside the // page scroller, whose keeper does not watch it. useFocusKeeper(wrapRef); + // The page stays put under the player: on an iPad a drag on the video scrolled (or rubber-banded) the + // document behind it, and the strip it moved showed the page's own background under the player. + // Locked, with no overscroll, and the root painted black so a sub-pixel gap reads as the player's. + useEffect(() => { + const unlock = lockBodyScroll(); + const root = document.documentElement.style; + const before = { background: root.backgroundColor, overscroll: root.overscrollBehavior }; + root.backgroundColor = "black"; + root.overscrollBehavior = "none"; + return () => { + unlock(); + root.backgroundColor = before.background; + root.overscrollBehavior = before.overscroll; + }; + }, []); // The key of the last press that landed on faded bands, while it is held (see onKey). const blindKeyRef = useRef(null); const durationRef = useLatest(duration); @@ -1089,25 +1104,27 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false } onClick={wake} onWheel={onWheel} // Windowed (pseudo-fullscreen): everything is 25% larger for lean-back / HTPC legibility. Laid - // out at 80vw×80dvh and scaled ×1.25 from the top-left so it fills the viewport exactly (no - // overflow, controls stay on-screen; `dvh` so a tablet's toolbar does not push the bar off it). We use `transform: scale` rather than `zoom` deliberately: + // out at 80%×80% and scaled ×1.25 from the top-left so it fills the viewport exactly (no + // overflow, controls stay on-screen). The size is a share of the fixed box's own containing block, + // the layout viewport, not `dvh`: in an iPad browser built on its own web view (Brave) `dvh` did + // not follow the toolbar, the player came out a few px short and the page showed under it. We use `transform: scale` rather than `zoom` deliberately: // under `zoom`, event clientX stays in CSS pixels while getBoundingClientRect returns zoomed // pixels, so the seek/volume/hover math (clientX − rect.left)/rect.width breaks; with `transform` // both are in the same viewport space, so that ratio stays correct. Skip buttons ride this too. // - // In NATIVE fullscreen we must NOT carry the ×1.25 transform: with the element at 80vw×80dvh, the + // In NATIVE fullscreen we must NOT carry the ×1.25 transform: with the element at 80%×80%, the // scaled layer is composited letterboxed and the video never grows past its windowed size (the UA // does NOT force this fixed, explicitly-sized element to fill). So in `fs` we size the wrapper to - // the full screen (100vw×100dvh) with NO transform — the flex box then centres the video and its + // the full screen (the whole containing block) with NO transform — the flex box then centres the video and its // `max-h-full`/`max-w-full` aspect-fit fills the available height. The % seek-bar left and the // clientX/rect math stay correct under the identity transform. style={{ cursor: uiVisible ? "default" : "none", ...(fs - ? { width: "100vw", height: "100dvh" } + ? { width: "100%", height: "100%" } : { - width: "80vw", - height: "80dvh", + width: "80%", + height: "80%", transform: "scale(1.25)", transformOrigin: "top left", }), @@ -1462,7 +1479,6 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
{fmt(abs)} / {fmt(duration)} - -{fmt(Math.max(0, duration - abs))} {/* Projected finish time (wall clock now + time remaining), in the clock's own format — pushes later while paused, since the remaining time then rides the advancing clock. */} {duration > 0 && ( diff --git a/frontend/src/components/ui/dialogFocus.ts b/frontend/src/components/ui/dialogFocus.ts index 318f04da..c757b169 100644 --- a/frontend/src/components/ui/dialogFocus.ts +++ b/frontend/src/components/ui/dialogFocus.ts @@ -62,7 +62,7 @@ export function trapTab( // second copy of the lock is gone. let scrollLocks = 0; let unlockedOverflow = ""; -function lockBodyScroll(): () => void { +export function lockBodyScroll(): () => void { if (scrollLocks++ === 0) { unlockedOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; From c9b8fe15a3cc2fb1a180a20728902a4472173062 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 8 Oct 2026 02:43:58 +0200 Subject: [PATCH 05/22] fix(ui): a scroller with no edge to fade carries no mask iPad WebKit kept a scrolled container's mask stuck to its content: at the bottom of a scrolled page the last strip, a table's pager, sat under a dark band. With nothing to fade there is no mask; isolation keeps its layer. --- frontend/src/lib/useScrollFade.dom.test.tsx | 49 +++++++++++++++++++++ frontend/src/lib/useScrollFade.ts | 7 ++- 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 frontend/src/lib/useScrollFade.dom.test.tsx diff --git a/frontend/src/lib/useScrollFade.dom.test.tsx b/frontend/src/lib/useScrollFade.dom.test.tsx new file mode 100644 index 00000000..090eee25 --- /dev/null +++ b/frontend/src/lib/useScrollFade.dom.test.tsx @@ -0,0 +1,49 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeAll, afterEach } from "vitest"; +import { render, cleanup, fireEvent } from "@testing-library/react"; +import { useScrollFade } from "./useScrollFade"; + +beforeAll(() => { + class Stub { + observe() {} + unobserve() {} + disconnect() {} + } + globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; +}); +afterEach(cleanup); + +function Scroller({ fadeTop = true }: { fadeTop?: boolean }) { + const fade = useScrollFade(20, fadeTop); + const { ref } = fade; + return
; +} + +/** A scroller 100 px tall over 300 px of content, scrolled to `top`. */ +function scrolledTo(el: HTMLElement, top: number) { + Object.defineProperties(el, { + clientHeight: { configurable: true, value: 100 }, + scrollHeight: { configurable: true, value: 300 }, + scrollTop: { configurable: true, value: top, writable: true }, + }); + fireEvent.scroll(el); +} + +// iPad WebKit kept a scrolled container's mask stuck to the content: at the bottom of a scrolled page +// the last strip (a table's pager) sat under a dark band. With no edge to fade there is no mask. +describe("useScrollFade", () => { + it("masks the edge that is clipped", () => { + const { getByTestId } = render(); + const el = getByTestId("s"); + scrolledTo(el, 0); + expect(el.style.maskImage).toContain("calc(100% - 20px)"); + }); + + it("sets no mask once nothing fades, and keeps the layer it made", () => { + const { getByTestId } = render(); + const el = getByTestId("s"); + scrolledTo(el, 200); + expect(el.style.maskImage).toBe(""); + expect(el.style.isolation).toBe("isolate"); + }); +}); diff --git a/frontend/src/lib/useScrollFade.ts b/frontend/src/lib/useScrollFade.ts index d64d879c..5da6b32f 100644 --- a/frontend/src/lib/useScrollFade.ts +++ b/frontend/src/lib/useScrollFade.ts @@ -18,7 +18,12 @@ export function useScrollFade( style: CSSProperties; } { const { ref, start, end } = useScrollEdges("y"); - const mask = `linear-gradient(to bottom, transparent, #000 ${start && fadeTop ? fadePx : 0}px, #000 calc(100% - ${ + // No edge to fade, no mask at all: iPad WebKit kept a scrolled container's mask stuck to the + // content, so at the bottom of a scrolled page the last strip (a table's pager) sat under a dark + // band. `isolation` keeps the stacking context the mask made, so nothing inside changes layer. + const top = start && fadeTop; + if (!top && !end) return { ref, style: { isolation: "isolate" } }; + const mask = `linear-gradient(to bottom, transparent, #000 ${top ? fadePx : 0}px, #000 calc(100% - ${ end ? fadePx : 0 }px), transparent)`; return { ref, style: { maskImage: mask, WebkitMaskImage: mask } }; From c3664826feb7c0e33efcd353b3d326184379f824 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 8 Oct 2026 02:45:48 +0200 Subject: [PATCH 06/22] fix(ui): a caption never opens under a finger On an iPad a tap's synthetic mouseenter opened the Tooltip; WebKit then took the tap as a hover and swallowed its click, so every rail button needed two taps, and with no mouseleave from a finger the caption stayed up. Tooltip now opens for a mouse and a key's focus only (owner: no captions on touch). --- docs/DESIGN.md | 1 + .../components/ui/Tooltip.layer.dom.test.tsx | 38 +++++++++++++++++++ frontend/src/components/ui/Tooltip.tsx | 2 + .../src/components/ui/anchoredPopoverHooks.ts | 7 +++- 4 files changed, 47 insertions(+), 1 deletion(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 4a05fd31..097821a5 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -711,3 +711,4 @@ of a full-width panel and nothing on the right. | 2026-10-04 | E32 `field` focus skips a mode switch | A form opens on its first field; a control above it that is not a field (the composer's Edit / Preview) is passed over by marking the field `data-initial-focus` | The first text field everywhere | That moved the focus in the download dialog off its profile choice and in the upload dialog off its drop zone; the mark changes only the form that needs it | | 2026-10-04 | E35 Lint for z-levels and edged labels | Both rules stay; the two eslint selectors (`Z_RAW`, `EDGED_LABEL`) that refused them go | Keep the selectors | 0 hits since the E28 sweep that fixed the sites: a check that never fires costs a probe test and a snapshot each, and the UI review holds both rules | | 2026-10-06 | `player` focus | A dialog that is a player opens focused on its panel (the YouTube player's rule, now `Modal`'s too); on the media element itself, Space and the arrows stay the element's own | `close`, or the media element | On the ✕ Space closed the player; on the element the browser's own controls acted on the same key as the player's (one Space played and paused, measured in Chrome) | +| 2026-10-08 | Captions on touch | A `Tooltip` opens for a mouse and a key's focus only, never for a finger | A caption on tap; a long-press caption | On an iPad the tap that showed the caption was taken as a hover and lost its click (every rail button took two taps), and no finger sends the mouseleave that hides it; the rail's labels setting names the buttons on touch | diff --git a/frontend/src/components/ui/Tooltip.layer.dom.test.tsx b/frontend/src/components/ui/Tooltip.layer.dom.test.tsx index 17660911..9846b5b6 100644 --- a/frontend/src/components/ui/Tooltip.layer.dom.test.tsx +++ b/frontend/src/components/ui/Tooltip.layer.dom.test.tsx @@ -64,3 +64,41 @@ describe("an open tooltip", () => { await waitFor(() => expect(screen.queryByRole("tooltip")).toBeNull()); }); }); + +// On an iPad a tap's synthetic mouseenter showed the caption; WebKit then took the tap as a hover and +// swallowed its click (every rail button needed two taps), and with no mouseleave the caption stayed. +describe("a tooltip under a finger", () => { + class PointerEventStub extends MouseEvent { + pointerType: string; + constructor(type: string, init: PointerEventInit = {}) { + super(type, init); + this.pointerType = init.pointerType ?? ""; + } + } + const hover = (trigger: HTMLElement, pointerType: string) => + act(() => { + trigger.dispatchEvent(new PointerEventStub("pointerdown", { bubbles: true, pointerType })); + trigger.dispatchEvent(new PointerEventStub("pointerenter", { pointerType })); + fireEvent.mouseEnter(trigger); + fireEvent.mouseMove(trigger); + }); + const mount = () => { + render( + + + , + ); + return screen.getByText("icon").parentElement as HTMLElement; + }; + + it("does not open from a tap", async () => { + hover(mount(), "touch"); + await new Promise((r) => setTimeout(r, 50)); + expect(screen.queryByRole("tooltip")).toBeNull(); + }); + + it("still opens under a mouse", async () => { + hover(mount(), "mouse"); + expect(await screen.findByRole("tooltip")).toBeTruthy(); + }); +}); diff --git a/frontend/src/components/ui/Tooltip.tsx b/frontend/src/components/ui/Tooltip.tsx index a383ea25..6ce58ff6 100644 --- a/frontend/src/components/ui/Tooltip.tsx +++ b/frontend/src/components/ui/Tooltip.tsx @@ -37,6 +37,8 @@ export default function Tooltip({ // Show/hide on contact by default (as the pre-platform tooltip did); `openDelay` adds a // rest-before-show for labels that shouldn't flash on a fast sweep across an icon row. hoverDelay: { open: openDelay, close: 0 }, + // A caption is for a mouse (and a key's focus). Opened by a tap it cost touch the tap itself. + mouseHoverOnly: true, }); const { setReference } = pop.refs; diff --git a/frontend/src/components/ui/anchoredPopoverHooks.ts b/frontend/src/components/ui/anchoredPopoverHooks.ts index a00c55e7..0ac6a6c6 100644 --- a/frontend/src/components/ui/anchoredPopoverHooks.ts +++ b/frontend/src/components/ui/anchoredPopoverHooks.ts @@ -62,6 +62,11 @@ export interface AnchoredPopoverOptions { * preview (ui/MediaPreview): a keyboard walk down a grid would otherwise pop a panel at every stop, * and a tap is the card's play. */ pointerOnly?: boolean; + /** Hover mode only: never opened by a finger, still by a mouse and a key's focus. A caption over a + * control (ui/Tooltip): on an iPad the tap's synthetic mouseenter showed it, WebKit then took that + * first tap as a hover and swallowed its click (every rail button needed two taps), and with no + * mouseleave from a finger the caption stayed up. */ + mouseHoverOnly?: boolean; /** Index of the item that is currently CHOSEN, for a single-choice menu/listbox. Opening then * lands the roving focus on it instead of the first item — the behaviour a "pick one of these * modes" menu needs. Leave unset for action menus and multi-select toggle lists. */ @@ -166,7 +171,7 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) { move: false, // A hover-card is a pointer affordance either way; a hover-ALSO menu must ignore touch, or the // synthetic mouseenter a tap emits would race the click toggle (open, then immediately shut). - mouseOnly: !isHover || !!opts.pointerOnly, + mouseOnly: !isHover || !!opts.pointerOnly || !!opts.mouseHoverOnly, // A submenu wants a shorter intent delay than a hover card: the pointer is already inside a // menu, so the "did they mean it" question is mostly answered. delay: opts.hoverDelay ?? (isHover ? { open: 120, close: 140 } : { open: 90, close: 120 }), From 5249c41edca633b0111f8ed0c875109489fc7137 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 8 Oct 2026 02:50:51 +0200 Subject: [PATCH 07/22] fix(player): a fullscreen button the browser cannot honour is not shown Brave on iPadOS switches element fullscreen off, patches requestFullscreen with a video-only stand-in and fakes fullscreenEnabled, so the Plex fullscreen button and the YouTube docked bar's did nothing. Support is now read off Element.prototype and the buttons are left out without it (owner). A stand-in that returns a boolean no longer throws. A test's bare matchMedia stub no longer outlives its describe. --- .../src/components/PlayerModal.dom.test.tsx | 43 ++++++++++++++++++- frontend/src/components/PlayerModal.tsx | 21 +++++---- .../PlexPlayer.controls.dom.test.tsx | 30 +++++++++++++ .../PlexPlayer.tabtrap.dom.test.tsx | 6 +++ frontend/src/components/PlexPlayer.tsx | 12 ++++-- frontend/src/lib/fullscreen.ts | 19 +++++++- 6 files changed, 116 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/PlayerModal.dom.test.tsx b/frontend/src/components/PlayerModal.dom.test.tsx index 7f7c661b..5ae0a9b9 100644 --- a/frontend/src/components/PlayerModal.dom.test.tsx +++ b/frontend/src/components/PlayerModal.dom.test.tsx @@ -551,8 +551,19 @@ describe("PlayerModal — the side panel on a wide window", () => { removeEventListener: () => {}, }) as unknown as MediaQueryList, ); + // jsdom has no matchMedia to spy on: a bare one is put in for these tests and taken out after, or + // every later test's media query would read that bare stub. + let added = false; beforeEach(() => { - window.matchMedia ??= (() => ({})) as unknown as typeof window.matchMedia; + if (typeof window.matchMedia === "function") return; + window.matchMedia = (() => ({})) as unknown as typeof window.matchMedia; + added = true; + }); + afterEach(() => { + // The spy goes first: restoring it later would put the bare stub back. + vi.restoreAllMocks(); + if (added) delete (window as { matchMedia?: unknown }).matchMedia; + added = false; }); it("puts the details beside the video, the description open, and sizes the card for both", async () => { @@ -657,3 +668,33 @@ describe("PlayerModal — the side panel on a wide window", () => { expect(screen.queryByTestId("player-description")).toBeNull(); }); }); + +// Brave on an iPad cannot fullscreen an element (it fakes `fullscreenEnabled`): the docked bar's +// true-fullscreen button did nothing there. The window-filling view is then the biggest one. +describe("PlayerModal — the docked bar's fullscreen", () => { + const maximised = (api_: boolean) => { + if (api_) + Object.defineProperty(Element.prototype, "requestFullscreen", { + configurable: true, + value: () => Promise.resolve(), + }); + vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {})); + const A = vid("A", 0); + renderModal( + {}} onState={() => {}} />, + ); + fireEvent.click(screen.getByLabelText("player.fullscreen")); + return screen.getAllByLabelText("player.fullscreen").length; + }; + afterEach(() => { + delete (Element.prototype as { requestFullscreen?: unknown }).requestFullscreen; + }); + + it("is offered where the browser can fullscreen an element", () => { + expect(maximised(true)).toBe(2); + }); + + it("is left out where it cannot", () => { + expect(maximised(false)).toBe(1); + }); +}); diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index ae4f2b94..9998b6ab 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -57,7 +57,7 @@ import { formatCompact, relativeTime, } from "../lib/format"; -import { fullscreenElement } from "../lib/fullscreen"; +import { canFullscreenElements, fullscreenElement } from "../lib/fullscreen"; import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap"; import { useBackToClose } from "../lib/history"; import ChannelNameLink from "./ChannelNameLink"; @@ -813,13 +813,18 @@ export default function PlayerModal({ )} - - {isFullscreen ? : } - + {/* Absent where the browser cannot fullscreen an element (Brave on an iPad): there + the window-filling view is the biggest, and YouTube's own button opens the + system player. */} + {canFullscreenElements() && ( + + {isFullscreen ? : } + + )}
- - {fs ? : } - + {/* Absent where the browser cannot fullscreen an element (Brave on an iPad): the button + did nothing there, and the windowed player already fills the window. */} + {canFullscreenElements() && ( + + {fs ? : } + + )}
diff --git a/frontend/src/lib/fullscreen.ts b/frontend/src/lib/fullscreen.ts index 482d2e11..22177137 100644 --- a/frontend/src/lib/fullscreen.ts +++ b/frontend/src/lib/fullscreen.ts @@ -8,7 +8,7 @@ import { // The browser's Fullscreen API, WebKit included. // -// Safari and iPadOS still ship only the prefixed spelling: no `fullscreenchange`, no +// Safari and iPadOS before 16.4 ship only the prefixed spelling: no `fullscreenchange`, no // `fullscreenElement`, no `requestFullscreen` on a plain element. Reading only the unprefixed names // there means the page believes it is NEVER fullscreen — which silently breaks whatever the // fullscreen state drives (an overlay that must yield, a focus reclaim, an Esc branch that would @@ -29,11 +29,26 @@ export function fullscreenElement(): Element | null { return d.fullscreenElement ?? d.webkitFullscreenElement ?? null; } +/** Can this browser put a plain element in fullscreen at all? Read off `Element.prototype`, never + * `document.fullscreenEnabled`: Brave on iPadOS switches element fullscreen off in its web view, then + * patches `HTMLElement.prototype.requestFullscreen` with a stand-in that only fullscreens a