fix(plex): the player's stage follows the tablet toolbar, and its bands stay out of a double-tap

- The stage is sized in dvh (80dvh × 1.25 windowed, 100dvh fullscreen); the viewport-units
  contract no longer exempts the player.
- A tap that reveals the bands keeps them pointer-events-none for the double-tap window, so the
  second tap of a skip at the bottom is not a seek.
- An arrow on the volume slider wakes the bands, and a keyboard focus in a band holds them
  (`holdOnFocus`, `:focus-visible` only, so a clicked button does not pin the bar).
- Next stays mounted (disabled) while the next episode's detail loads, keeping the focus.
This commit is contained in:
2026-09-27 14:34:31 +02:00
parent 922d589b09
commit 76eead976f
4 changed files with 129 additions and 20 deletions
@@ -224,6 +224,37 @@ describe("a tap on the picture (C3)", () => {
});
});
// E28 review E6: the volume slider counted as a text field, so its arrows never woke the bands — the
// bar faded from under a keyboard user adjusting it — and nothing held the bands while a key-focused
// control sat in them.
describe("keyboard focus in the bands", () => {
it("an arrow on the volume slider wakes the bands", async () => {
mount({ details: { A: item("A") } });
await playingAt("/media/A", 100);
pressPicture("touch"); // hide
expect(bandHidden()).toBe(true);
fireEvent.keyDown(screen.getByLabelText("plex.player.volume"), { key: "ArrowUp" });
expect(bandHidden()).toBe(false);
});
it("the bands stay while a key-focused control is in them, and fade 3 s after it leaves", async () => {
mount({ details: { A: item("A") } });
await playingAt("/media/A", 100);
vi.useFakeTimers();
video()._paused = true;
act(() => void video().play()); // playing: the timer may hide the bands
const vol = screen.getByLabelText("plex.player.volume");
fireEvent.keyDown(document.body, { key: "Tab" }); // the keyboard is in use: focus shows
act(() => vol.focus());
fireEvent.keyDown(vol, { key: "ArrowUp" });
act(() => vi.advanceTimersByTime(5000));
expect(bandHidden()).toBe(false);
act(() => vol.blur());
act(() => vi.advanceTimersByTime(3000));
expect(bandHidden()).toBe(true);
});
});
describe("a double-tap on a side third skips 10 s (C5)", () => {
it("skips from where it plays, again for each further tap, and takes back the first tap's toggle", async () => {
const { session } = mount({ details: { A: item("A") } });
@@ -255,6 +286,22 @@ describe("a double-tap on a side third skips 10 s (C5)", () => {
expect(session.mock.calls.length).toBe(calls);
});
// E28 review E5: the tap that brings the bands back must not hand the SECOND tap to them — a double-tap
// at the bottom would land on the seek bar (a seek to wherever the finger was) instead of the skip.
it("a tap that reveals the bands keeps them out of the finger's way for the double-tap window", async () => {
mount({ details: { A: item("A") } });
await playingAt("/media/A", 1500);
const band = () =>
screen.getByLabelText("plex.player.playPause").closest("[class*='bottom-0']")!.className;
pressPicture("touch", 450); // hide
vi.useFakeTimers();
pressPicture("touch", 450); // reveal
expect(bandHidden()).toBe(false);
expect(band()).toContain("pointer-events-none");
act(() => vi.advanceTimersByTime(300));
expect(band()).not.toContain("pointer-events-none");
});
it("the left third skips back", async () => {
const { session } = mount({ details: { A: item("A") } });
await playingAt("/media/A", 1500);
@@ -340,6 +387,27 @@ describe("the C5 row", () => {
await waitFor(() => expect(document.activeElement).toBe(more));
});
// E28 review E4: an episode's Next comes from its detail, which is unknown while the next episode
// loads — the button unmounted from under the focus that had just pressed it (focus on <body>).
it("Next keeps the focus while the next item's detail loads", async () => {
let arrive: (d: PlexItemDetail) => void = () => {};
mount({ details: { A: item("A", 0, "B") } });
vi.mocked(api.plexItem).mockImplementation((id: string) =>
id === "A"
? Promise.resolve(item("A", 0, "B"))
: new Promise<PlexItemDetail>((r) => (arrive = r)),
);
await playingAt("/media/A", 100);
const next = screen.getByLabelText("plex.player.next");
act(() => next.focus());
fireEvent.click(next);
await waitFor(() => expect(api.plexItem).toHaveBeenCalledWith("B", expect.anything()));
expect(document.activeElement).toBe(next);
expect(next.getAttribute("aria-disabled")).toBe("true");
await act(async () => arrive(item("B", 0, "C")));
expect(document.activeElement).toBe(screen.getByLabelText("plex.player.next"));
});
it("Next shows only when there is a next item", async () => {
mount({ details: { A: item("A") } });
await playingAt("/media/A", 100);
+47 -12
View File
@@ -255,6 +255,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
uiVisible,
wake,
holdOnHover,
holdOnFocus,
toggle: toggleControls,
show: showControls,
} = useControlsVisibility({
@@ -262,10 +263,16 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
menuOpenRef,
videoRef,
});
// The pointer resting on either band holds the controls up (F22); both bands wear these.
// The pointer resting on either band holds the controls up (F22), and so does a keyboard focus in
// one (E6) — not the focus a click leaves on a button, which would pin the bar until the next click;
// `:focus-visible` is the browser's own "this focus came from the keyboard". Both bands wear these.
const holdWhileHovered = {
onPointerEnter: () => holdOnHover(true),
onPointerLeave: () => holdOnHover(false),
onFocus: (e: React.FocusEvent<HTMLElement>) => holdOnFocus(e.target.matches(":focus-visible")),
onBlur: (e: React.FocusEvent<HTMLElement>) => {
if (!e.currentTarget.contains(e.relatedTarget)) holdOnFocus(false);
},
};
// The tuning panel stays open until a click/Escape OUTSIDE it (so sliders can be dragged freely).
// It renders inside the player, whose Tab trap spans it; the ⋯ counts as inside, since the ⋯ is
@@ -848,6 +855,13 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
? queue[qIdx + 1]
: null
: detail?.next_id;
// Whether the row has a Next. An episode's next is in its detail, unknown while the next episode
// loads; the slot keeps its last answer meanwhile (a disabled Next), because the button unmounting
// took the focus that had just pressed it to <body> (E28 review E4). Adjusted during render.
const nextKnown = queue || !detailQ.isPending ? !!nextId : undefined;
const [nextSlot, setNextSlot] = useState(false);
if (nextKnown !== undefined && nextKnown !== nextSlot) setNextSlot(nextKnown);
const showNext = nextKnown ?? nextSlot;
// Back (C5): past the first BACK_TO_PREV_S seconds it restarts the item; inside them it steps to the
// previous one (at its resume point, as `go` opens every item) — or restarts again on the first.
@@ -867,6 +881,12 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
const tapRef = useRef<{ at: number; zone: number; wasShown: boolean; skips: number } | null>(
null,
);
// A tap that brings the bands back keeps them out of the pointer's way until a double-tap can no
// longer follow (E28 review E5): the second tap of a skip at the bottom would otherwise land on the
// band the first one just revealed — the seek bar, a seek to wherever the finger was.
const [tapShield, setTapShield] = useState(false);
const tapShieldTimer = useRef<number | undefined>(undefined);
useEffect(() => () => window.clearTimeout(tapShieldTimer.current), []);
const onPictureClick = useCallback(
(e: React.MouseEvent<HTMLVideoElement>) => {
if (pointerRef.current !== "touch") {
@@ -893,6 +913,11 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
return;
}
tapRef.current = { at: now, zone, wasShown: uiVisible, skips: 0 };
if (!uiVisible) {
setTapShield(true);
window.clearTimeout(tapShieldTimer.current);
tapShieldTimer.current = window.setTimeout(() => setTapShield(false), DOUBLE_TAP_MS);
}
toggleControls();
},
[togglePlay, showControls, toggleControls, seekTo, flashOsd, t, uiVisible],
@@ -1076,7 +1101,9 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
// Keyboard use also reveals the controls — but typing in a player field is not "using the
// remote", and `dispatch` still lets Escape through from inside a field (the only key that
// opts in), which is why this is a guard around `wake` rather than an early return.
if (!isTypingTarget(e)) wake();
// A slider is not a text field: its arrows are the remote too (E28 review E6).
if (!isTypingTarget(e) || (e.target as Element | null)?.matches?.('input[type="range"]'))
wake();
// The shared dialog Tab trap. This player paints an opaque full-viewport layer over the app,
// so Tab leaving it lands on a control nobody can see, where the ring is invisible and the
// next Enter fires something blind. Every other overlay in the tree traps (ui/Modal,
@@ -1235,25 +1262,25 @@ 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×80vh and scaled ×1.25 from the top-left so it fills the viewport exactly (no
// overflow, controls stay on-screen). We use `transform: scale` rather than `zoom` deliberately:
// 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:
// 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×80vh, the
// In NATIVE fullscreen we must NOT carry the ×1.25 transform: with the element at 80vw×80dvh, 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×100vh) with NO transform — the flex box then centres the video and its
// the full screen (100vw×100dvh) 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: "100vh" }
? { width: "100vw", height: "100dvh" }
: {
width: "80vw",
height: "80vh",
height: "80dvh",
transform: "scale(1.25)",
transformOrigin: "top left",
}),
@@ -1380,7 +1407,9 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
<div
{...holdWhileHovered}
className={`absolute top-0 inset-x-0 p-4 bg-linear-to-b from-black/70 to-transparent transition-opacity ${
uiVisible ? "opacity-100" : "opacity-0 pointer-events-none"
uiVisible
? `opacity-100${tapShield ? " pointer-events-none" : ""}`
: "opacity-0 pointer-events-none"
}`}
>
<div className="flex items-center gap-3 text-white">
@@ -1458,7 +1487,9 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
<div
{...holdWhileHovered}
className={`absolute bottom-0 inset-x-0 px-4 pb-3 pt-3 bg-linear-to-t from-black/80 to-transparent transition-opacity ${
uiVisible ? "opacity-100" : "opacity-0 pointer-events-none"
uiVisible
? `opacity-100${tapShield ? " pointer-events-none" : ""}`
: "opacity-0 pointer-events-none"
}`}
>
{/* Seek bar — click to jump, or drag the head (pointer capture) to scrub; the seek commits on
@@ -1554,8 +1585,12 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
>
<SkipBack className="w-5 h-5" />
</Ctrl>
{nextId && (
<Ctrl label={t("plex.player.next")} onClick={() => go(nextId)}>
{showNext && (
<Ctrl
label={t("plex.player.next")}
onClick={() => nextId && go(nextId)}
disabled={!nextId}
>
<SkipForward className="w-5 h-5" />
</Ctrl>
)}
+14 -3
View File
@@ -72,7 +72,8 @@ export function useFullscreen(wrapRef: RefObject<HTMLElement | null>): {
// bar up so it can't vanish mid-choice), and the <video> paused state (a paused video keeps the bar).
// `holdOnHover` is the pointer resting on the chrome: the bar stays while it is there (as on YouTube
// and Plex Web — E28 F22, it used to fade out from under a mouse parked on Next), and leaving it
// starts a fresh 3 s.
// starts a fresh 3 s. `holdOnFocus` is the same for a KEYBOARD focus in the chrome (E28 review E6): the
// bar must not fade from under the control a key user is on.
//
// On touch (C3) nothing moves a mouse, so the timer is also armed by PLAYBACK starting — before, the
// bar stayed up until the first tap however long the video played — and a tap on the picture only
@@ -86,6 +87,7 @@ export function useControlsVisibility(opts: {
uiVisible: boolean;
wake: () => void;
holdOnHover: (over: boolean) => void;
holdOnFocus: (on: boolean) => void;
toggle: () => void;
show: (visible: boolean) => void;
} {
@@ -94,6 +96,7 @@ export function useControlsVisibility(opts: {
const visibleRef = useRef(true);
const hideTimer = useRef<number | null>(null);
const hovered = useRef(false);
const focused = useRef(false);
const setVisible = useCallback((v: boolean) => {
visibleRef.current = v;
setUiVisible(v);
@@ -104,7 +107,8 @@ export function useControlsVisibility(opts: {
if (!barAutoHideRef.current) return; // per-user setting: never auto-hide the control bar
hideTimer.current = window.setTimeout(() => {
// Keep the controls up while the audio/subtitle menu is open, else it would vanish mid-choice.
if (!videoRef.current?.paused && !menuOpenRef.current && !hovered.current) setVisible(false);
if (!videoRef.current?.paused && !menuOpenRef.current && !hovered.current && !focused.current)
setVisible(false);
}, 3000);
}, [barAutoHideRef, menuOpenRef, videoRef, setVisible]);
const show = useCallback(
@@ -124,6 +128,13 @@ export function useControlsVisibility(opts: {
},
[wake],
);
const holdOnFocus = useCallback(
(on: boolean) => {
focused.current = on;
if (!on) wake();
},
[wake],
);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
@@ -136,7 +147,7 @@ export function useControlsVisibility(opts: {
},
[],
);
return { uiVisible, wake, holdOnHover, toggle, show };
return { uiVisible, wake, holdOnHover, holdOnFocus, toggle, show };
}
// Periodic + on-unmount progress checkpoint. Reads live position/duration through refs (owned by the
@@ -18,14 +18,9 @@ function sources(): Array<[string, string]> {
return walk(new URL("../", import.meta.url), "");
}
// The Plex player's stage is sized with the 80vh × 1.25 zoom trick. It changes in S3f, where the
// players are re-fitted for touch and verified by playing them on the tablet.
const PENDING_S3F = new Set(["components/PlexPlayer.tsx"]);
describe("viewport units", () => {
it("are dynamic: no h-screen / min-h-screen, no static vh", () => {
const hits = sources()
.filter(([name]) => !PENDING_S3F.has(name))
.filter(([, src]) => /\b(?:min-|max-)?h-screen\b|\d(?:\.\d+)?vh\b/.test(src))
.map(([name]) => name);
expect(hits).toEqual([]);