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