diff --git a/frontend/src/components/PlexPlayer.controls.dom.test.tsx b/frontend/src/components/PlexPlayer.controls.dom.test.tsx index 37b6b6d8..5faa774c 100644 --- a/frontend/src/components/PlexPlayer.controls.dom.test.tsx +++ b/frontend/src/components/PlexPlayer.controls.dom.test.tsx @@ -273,6 +273,46 @@ describe("keyboard focus in the bands", () => { expect(bandHidden()).toBe(false); }); + it("a Space on a control the bands faded from only brings them back, and plays or pauses", async () => { + mount({ details: { A: item("A") } }); + await playingAt("/media/A", 100); + vi.useFakeTimers(); + video()._paused = true; + act(() => void video().play()); + const mute = screen.getByLabelText("plex.player.mute"); + fireEvent.keyDown(document.body, { key: "Tab" }); + act(() => mute.focus()); + fireEvent.keyDown(mute, { key: "ArrowUp" }); + act(() => vi.advanceTimersByTime(10_500)); + expect(bandHidden()).toBe(true); + const pauses = pause.mock.calls.length; + // prevented: the unseen Mute is not pressed on keyup + expect(fireEvent.keyDown(mute, { key: " " })).toBe(false); + expect(bandHidden()).toBe(false); + expect(pause.mock.calls.length).toBe(pauses + 1); + // the bands are up again: now Space is the focused control's own + expect(fireEvent.keyDown(mute, { key: " " })).toBe(true); + }); + + it("an Enter on a control the bands faded from only brings them back", async () => { + mount({ details: { A: item("A") } }); + await playingAt("/media/A", 100); + vi.useFakeTimers(); + video()._paused = true; + act(() => void video().play()); + const mute = screen.getByLabelText("plex.player.mute"); + fireEvent.keyDown(document.body, { key: "Tab" }); + act(() => mute.focus()); + fireEvent.keyDown(mute, { key: "ArrowUp" }); + act(() => vi.advanceTimersByTime(10_500)); + expect(bandHidden()).toBe(true); + const pauses = pause.mock.calls.length; + expect(fireEvent.keyDown(mute, { key: "Enter" })).toBe(false); + expect(bandHidden()).toBe(false); + expect(pause.mock.calls.length).toBe(pauses); + expect(video().paused).toBe(false); + }); + it("a tap leaving a tapped button for the picture shows the hidden bands", async () => { mount({ details: { A: item("A") } }); await playingAt("/media/A", 100); @@ -423,6 +463,55 @@ describe("a collection sibling in the info overlay", () => { ); }); +describe("a Restart of the paused film whose session starts past 0", () => { + it("plays the new session, and never the one it replaces", { timeout: 10000 }, async () => { + const self: PlexItemDetail = { + ...item("A"), + cast: [], + genres: [], + directors: [], + collections: [ + { + id: "c1", + title: "Saga", + source: "plex", + items: [ + { + id: "A", + type: "movie", + title: "Title A", + thumb: "", + status: "in_progress", + position_seconds: 1500, + duration_seconds: 3000, + }, + ], + }, + ], + } as unknown as PlexItemDetail; + const { session } = mount({ details: { A: self } }); + // A session opened at the resume point: 0 is outside it, so a restart asks for a new one. + session.mockImplementation((id: string) => + Promise.resolve({ ...direct(id), start_s: 1500 } as Awaited< + ReturnType + >), + ); + await playingAt("/media/A", 100); + const asked = session.mock.calls.length; + act(() => void video().pause()); + fireEvent.click(screen.getByLabelText("plex.player.more")); + fireEvent.click(await menuItem("plex.player.infoBtn")); + const restart = await screen.findByRole("button", { name: "card.restart" }, { timeout: 5000 }); + const plays = play.mock.calls.length; + fireEvent.click(restart); + await waitFor(() => expect(session.mock.calls.length).toBe(asked + 1)); + expect(lastStart(session)).toEqual(["A", 0]); + expect(play.mock.calls.length).toBe(plays); // the outgoing stream stays paused + act(() => void video().dispatchEvent(new Event("loadedmetadata"))); + expect(play.mock.calls.length).toBe(plays + 1); // and the new one plays + }); +}); + describe("the C5 row", () => { it("has no Stop, Restart or ±10 s buttons, and keeps the rare tools behind the ⋯", async () => { mount({ details: { A: item("A") } }); diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index 2d1fbea6..8eb76ae1 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -263,6 +263,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false } // Auto-hiding control bar (reveal on mouse move / any key; hide after 3s while playing). const { uiVisible, + controlsShown, wake, holdOnHover, holdOnFocus, @@ -277,6 +278,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false } // 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 = { + "data-player-band": "", // what a key on a faded-out control is told apart by (onKey, below) onPointerEnter: () => holdOnHover(true), onPointerLeave: () => holdOnHover(false), onFocus: (e: React.FocusEvent) => holdOnFocus(e.target.matches(":focus-visible")), @@ -792,10 +794,12 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false } // --- seeking (native within the loaded region, else restart the session) --------------------- const seekTo = useCallback( - (target: number) => { + (target: number, play?: boolean) => { const video = videoRef.current; if (!video) return; - const wasPlaying = !video.paused; // preserve play/pause intent across the seek + // Play/pause intent across the seek: the caller's when it has one (a Restart plays), else + // whatever the video was doing. + const wasPlaying = play ?? !video.paused; const dur = durationRef.current || target; const T = Math.max(0, Math.min(target, dur - 1)); // Reflect the target immediately so a save/beacon right after a seek is accurate — in the STATE @@ -813,6 +817,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false } rel <= (video.seekable.length ? video.seekable.end(0) : 0) + 0.5 ) { video.currentTime = rel; // native seek within the loaded region keeps playing on its own + if (play) video.play().catch(() => {}); } else { loadSession(T, wasPlaying); // session restart: only auto-resume if we were playing } @@ -849,9 +854,11 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false } if (otherId && otherId === id) { // The item already playing (the info overlay's collection strip lists the film itself): // nothing to switch to. The switch below would pause it and stop trusting its clock, and - // `setId` to the same id loads nothing — a frozen 0:00 (E28 review round 2, P1). - if (fromZero) seekTo(0); - videoRef.current?.play().catch(() => {}); + // `setId` to the same id loads nothing — a frozen 0:00 (E28 review round 2, P1). The play + // intent goes INTO the seek: a paused film whose session starts past 0 restarts the session, + // and a play() beside that resumed the outgoing stream while the new one stayed paused. + if (fromZero) seekTo(0, true); + else videoRef.current?.play().catch(() => {}); return; } if (otherId) { @@ -1134,12 +1141,26 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false } go(id); }; const onKey = (e: KeyboardEvent) => { + // A Space or Enter on a control the bands have faded out from under (a key user's focus stays + // put when they hide) must not press it unseen — Back leaving the player, Next skipping the + // episode. That key only brings the bands back; Space also does what a viewer who sees no + // button means by it, and pauses or plays. Read before `wake` below shows them. + const blind = + !controlsShown() && + isActivationTarget(e) && + !!(e.target as Element | null)?.closest?.("[data-player-band]") && + (e.key === " " || e.key === "Enter"); // 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. // 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(); + if (blind) { + e.preventDefault(); + if (e.key === " ") togglePlay(); + return; + } // 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, @@ -1256,6 +1277,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false } nextId, onClose, wake, + controlsShown, handleBack, skipProgressRef, cancelAutoSkipRef, diff --git a/frontend/src/lib/plexPlayerHooks.ts b/frontend/src/lib/plexPlayerHooks.ts index 45f02636..ff2a228e 100644 --- a/frontend/src/lib/plexPlayerHooks.ts +++ b/frontend/src/lib/plexPlayerHooks.ts @@ -90,6 +90,8 @@ export function useControlsVisibility(opts: { videoRef: RefObject; }): { uiVisible: boolean; + /** Whether the bands are up right now — for a key handler that runs before the next render. */ + controlsShown: () => boolean; wake: () => void; holdOnHover: (over: boolean) => void; holdOnFocus: (on: boolean) => void; @@ -132,6 +134,7 @@ export function useControlsVisibility(opts: { [wake, menuOpenRef, setVisible], ); const toggle = useCallback(() => show(!visibleRef.current), [show]); + const controlsShown = useCallback(() => visibleRef.current, []); const holdOnHover = useCallback( (over: boolean) => { hovered.current = over; @@ -162,7 +165,7 @@ export function useControlsVisibility(opts: { }, [], ); - return { uiVisible, wake, holdOnHover, holdOnFocus, toggle, show }; + return { uiVisible, controlsShown, wake, holdOnHover, holdOnFocus, toggle, show }; } // Periodic + on-unmount progress checkpoint. Reads live position/duration through refs (owned by the