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).
This commit is contained in:
2026-10-08 02:42:31 +02:00
parent ca949d22de
commit 1044eaf8a9
3 changed files with 60 additions and 10 deletions
@@ -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<HTMLElement>("[class*='fixed']")!;
expect([wrap.style.width, wrap.style.height]).toEqual(["80%", "80%"]);
});
});
+25 -9
View File
@@ -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 <body>. 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<string | null>(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 }
<div className="min-w-0 truncate text-xs tabular-nums text-white/90">
{fmt(abs)} / {fmt(duration)}
<span className="ml-1.5 text-white/55">-{fmt(Math.max(0, duration - abs))}</span>
{/* 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 && (
+1 -1
View File
@@ -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";