From 1044eaf8a967be3afcb356ed4572442ca078b3a2 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 8 Oct 2026 02:42:31 +0200 Subject: [PATCH] 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";