diff --git a/frontend/src/components/PlexPlayer.controls.dom.test.tsx b/frontend/src/components/PlexPlayer.controls.dom.test.tsx index 2916bc87..ec26ecb5 100644 --- a/frontend/src/components/PlexPlayer.controls.dom.test.tsx +++ b/frontend/src/components/PlexPlayer.controls.dom.test.tsx @@ -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 ). + 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((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); diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index d759114d..968492a9 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -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) => holdOnFocus(e.target.matches(":focus-visible")), + onBlur: (e: React.FocusEvent) => { + 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 (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(undefined); + useEffect(() => () => window.clearTimeout(tapShieldTimer.current), []); const onPictureClick = useCallback( (e: React.MouseEvent) => { 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 }
@@ -1458,7 +1487,9 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
{/* 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 } > - {nextId && ( - go(nextId)}> + {showNext && ( + nextId && go(nextId)} + disabled={!nextId} + > )} diff --git a/frontend/src/lib/plexPlayerHooks.ts b/frontend/src/lib/plexPlayerHooks.ts index 03693593..7655c5e1 100644 --- a/frontend/src/lib/plexPlayerHooks.ts +++ b/frontend/src/lib/plexPlayerHooks.ts @@ -72,7 +72,8 @@ export function useFullscreen(wrapRef: RefObject): { // bar up so it can't vanish mid-choice), and the