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:
2026-09-27 14:34:31 +02:00
parent 922d589b09
commit 76eead976f
4 changed files with 129 additions and 20 deletions
@@ -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);