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
+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>
)}