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:
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
Reference in New Issue
Block a user