From d5ccb1091a77cf0a93a53235fcf565519ee3a784 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 27 Sep 2026 22:34:14 +0200 Subject: [PATCH] fix(player): a held key on faded bands stays one blind press; a play asked for mid-restart is the new session's The repeats of a Space/Enter that landed on faded bands pressed the control once the bands were up; they are swallowed with it. A play or pause while a session restart is out now sets that session's intent instead of playing the outgoing stream. Tests: Skip intro outside the bands keeps its keys, every restore takeover (wheel, finger, scrollbar, key) is covered, and the chip handoff test tells the chip before from the row's first control. --- .../PlexPlayer.controls.dom.test.tsx | 97 +++++++++++++++++++ frontend/src/components/PlexPlayer.tsx | 77 +++++++++++---- frontend/src/components/ui/Chip.dom.test.tsx | 15 +-- .../components/ui/PageScroller.dom.test.tsx | 13 ++- 4 files changed, 173 insertions(+), 29 deletions(-) diff --git a/frontend/src/components/PlexPlayer.controls.dom.test.tsx b/frontend/src/components/PlexPlayer.controls.dom.test.tsx index 5faa774c..a56bd335 100644 --- a/frontend/src/components/PlexPlayer.controls.dom.test.tsx +++ b/frontend/src/components/PlexPlayer.controls.dom.test.tsx @@ -313,6 +313,54 @@ describe("keyboard focus in the bands", () => { expect(video().paused).toBe(false); }); + it.each([" ", "Enter"])( + "a held %j on a control the bands faded from is one blind press, repeats and all", + async (key) => { + 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); + expect(fireEvent.keyDown(mute, { key })).toBe(false); + const toggles = pause.mock.calls.length + play.mock.calls.length; + // the bands are up by now, and the OS repeats the held key: still not the control's + expect(fireEvent.keyDown(mute, { key, repeat: true })).toBe(false); + expect(fireEvent.keyDown(mute, { key, repeat: true })).toBe(false); + expect(pause.mock.calls.length + play.mock.calls.length).toBe(toggles); + // a new press on the shown bands is the control's own again + fireEvent.keyUp(mute, { key }); + expect(fireEvent.keyDown(mute, { key })).toBe(true); + }, + ); + + it("a Space or Enter on Skip intro, outside the faded bands, stays Skip intro's", async () => { + const intro = { + ...item("A"), + markers: [{ type: "intro", start_s: 50, end_s: 200 }], + } as unknown as PlexItemDetail; + mount({ details: { A: intro } }); + await playingAt("/media/A", 100); + vi.useFakeTimers(); + video()._paused = true; + act(() => void video().play()); + const skip = screen.getByRole("button", { name: /plex\.player\.skipIntro/ }); + fireEvent.keyDown(document.body, { key: "Tab" }); + act(() => skip.focus()); + act(() => vi.advanceTimersByTime(10_500)); + expect(bandHidden()).toBe(true); + const pauses = pause.mock.calls.length; + // not prevented: the button's own activation runs, and Space does not pause the film instead + expect(fireEvent.keyDown(skip, { key: " " })).toBe(true); + expect(fireEvent.keyDown(skip, { key: "Enter" })).toBe(true); + expect(pause.mock.calls.length).toBe(pauses); + }); + 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); @@ -512,6 +560,55 @@ describe("a Restart of the paused film whose session starts past 0", () => { }); }); +describe("a play asked for while a restart is out", () => { + it("is the new session's: the outgoing stream never plays", async () => { + const { session } = mount({ details: { A: item("A") } }); + await playingAt("/media/A", 100); + act(() => void video().pause()); + let answer: (s: Awaited>) => void = () => {}; + session.mockImplementation( + () => + new Promise((resolve) => { + answer = resolve; + }), + ); + const asked = session.mock.calls.length; + // paused, and a seek outside the loaded region: a restart that lands paused + fireEvent.keyDown(document.body, { key: "ArrowRight" }); + await waitFor(() => expect(session.mock.calls.length).toBe(asked + 1)); + const plays = play.mock.calls.length; + fireEvent.keyDown(document.body, { key: " " }); + expect(play.mock.calls.length).toBe(plays); // the old stream stays silent + expect(video().paused).toBe(true); + await act(async () => answer({ ...direct("A"), start_s: 110 } as never)); + act(() => void video().dispatchEvent(new Event("loadedmetadata"))); + expect(play.mock.calls.length).toBe(plays + 1); // and the new one plays, as asked + }); + + it("a pause asked for while a playing restart is out lands paused", async () => { + const { session } = mount({ details: { A: item("A") } }); + await playingAt("/media/A", 100); + act(() => void video().play()); + let answer: (s: Awaited>) => void = () => {}; + session.mockImplementation( + () => + new Promise((resolve) => { + answer = resolve; + }), + ); + const asked = session.mock.calls.length; + fireEvent.keyDown(document.body, { key: "ArrowRight" }); + await waitFor(() => expect(session.mock.calls.length).toBe(asked + 1)); + fireEvent.keyDown(document.body, { key: " " }); + expect(video().paused).toBe(true); + const plays = play.mock.calls.length; + await act(async () => answer({ ...direct("A"), start_s: 110 } as never)); + act(() => void video().dispatchEvent(new Event("loadedmetadata"))); + act(() => void video().dispatchEvent(new Event("canplay"))); + expect(play.mock.calls.length).toBe(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 8eb76ae1..46d691a0 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -178,6 +178,13 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false } // a timeupdate AFTER the reset to 0) — sent as the new item's subtitle position and saved as its // progress, or, during a seek-restart, the pre-seek spot overwriting the target. const clockLiveRef = useRef(false); + // Whether the session being asked for plays once it can — null while none is out. Set from + // loadSession's `resumePlay` and then the viewer's to change: until the answer attaches, the media + // is the OUTGOING one, and a play() there sounded the old stream while the new one still attached + // paused (E28 review round 4). `said`: the viewer changed it, so it is what they now see. + const pendingPlayRef = useRef<{ play: boolean; said?: boolean } | null>(null); + // The key of the last press that landed on faded bands, while it is held (see onKey). + const blindKeyRef = useRef(null); // The item whose session is attached to the