fix(player): the playing film restarts in place; arrows stay the volume; the bands' holds end

- The info overlay's collection strip listing the film itself: Play/Restart restart or resume it in
  place instead of pausing it on a frozen 0:00.
- Up/Down on the closed ⋯ and tracks buttons stay the volume (openOnArrowKey: false).
- A keyboard focus in the bands holds them for 10 s after the last key, not the whole film; a tap
  leaving a tapped button no longer re-shows the bands its own toggle then hid.
- Next leaving under the focus hands it to its neighbour (the player keeps its focus).
- The chosen audio / subtitle track wears the MenuCheck tick.
This commit is contained in:
2026-09-27 19:42:53 +02:00
parent b7ce1d467c
commit cda626be10
4 changed files with 176 additions and 13 deletions
@@ -253,6 +253,48 @@ describe("keyboard focus in the bands", () => {
act(() => vi.advanceTimersByTime(3000));
expect(bandHidden()).toBe(true);
});
it("but not over the whole film: 10 s after the last key they fade, and a key brings them back", async () => {
mount({ details: { A: item("A") } });
await playingAt("/media/A", 100);
vi.useFakeTimers();
video()._paused = true;
act(() => void video().play());
const vol = screen.getByLabelText("plex.player.volume");
fireEvent.keyDown(document.body, { key: "Tab" });
act(() => vol.focus());
fireEvent.keyDown(vol, { key: "ArrowUp" });
act(() => vi.advanceTimersByTime(9000));
expect(bandHidden()).toBe(false);
act(() => vi.advanceTimersByTime(1500));
expect(bandHidden()).toBe(true);
expect(document.activeElement).toBe(vol);
fireEvent.keyDown(vol, { key: "ArrowUp" });
expect(bandHidden()).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);
vi.useFakeTimers();
video()._paused = true;
act(() => void video().play());
const btn = screen.getByLabelText("plex.player.mute");
// A finger's focus is not :focus-visible (jsdom calls every focus visible), so it holds nothing.
const matches = Element.prototype.matches;
vi.spyOn(Element.prototype, "matches").mockImplementation(function (
this: Element,
sel: string,
) {
return sel === ":focus-visible" ? false : matches.call(this, sel);
});
act(() => btn.focus());
act(() => vi.advanceTimersByTime(3000));
expect(bandHidden()).toBe(true);
act(() => btn.blur()); // the tap on the picture takes the focus first…
pressPicture("touch"); // …then toggles
expect(bandHidden()).toBe(false);
});
});
describe("a double-tap on a side third skips 10 s (C5)", () => {
@@ -351,6 +393,34 @@ describe("a collection sibling in the info overlay", () => {
await waitFor(() => expect(lastStart(session)).toEqual(["B", 0]));
expect(screen.queryByRole("heading", { name: "Saga" })).toBeNull(); // the overlay closed
});
it(
"the film itself in its own collection restarts in place, and the clock runs on",
{ timeout: 10000 },
async () => {
const self = withSibling("A");
self.collections![0]!.items[0] = {
...self.collections![0]!.items[0]!,
id: "A",
title: "Title A",
};
const { session } = mount({ details: { A: self } });
await playingAt("/media/A", 100);
const asked = session.mock.calls.length;
fireEvent.click(screen.getByLabelText("plex.player.more"));
fireEvent.click(await menuItem("plex.player.infoBtn"));
fireEvent.click(
await screen.findByRole("button", { name: "card.restart" }, { timeout: 5000 }),
);
await waitFor(() => expect(video()._t).toBe(0));
expect(session.mock.calls.length).toBe(asked); // the attached session seeks, no reload
expect(video().paused).toBe(false);
const v = video();
v._t = 42;
act(() => void v.dispatchEvent(new Event("timeupdate")));
expect(screen.getByText(/^0:42 \//)).toBeTruthy();
},
);
});
describe("the C5 row", () => {
@@ -395,6 +465,42 @@ describe("the C5 row", () => {
// E28 review D3: the ⋯ was a hand-built <div> of buttons — no menu role, no arrow keys, and Escape
// closed it with the focus left on <body>. It is a menu on the popover engine now.
it("↑/↓ on the closed ⋯ and tracks buttons are the volume, not an open menu", async () => {
const two = item("A");
two.audio_streams = [
...two.audio_streams,
{ ord: 1, label: "Magyar", language: "hu", default: false },
] as PlexItemDetail["audio_streams"];
mount({ details: { A: two } });
await playingAt("/media/A", 100);
for (const label of ["plex.player.more", "plex.player.tracks"]) {
const trigger = screen.getByLabelText(label);
act(() => trigger.focus());
const before = video().volume;
fireEvent.keyDown(trigger, { key: "ArrowDown" });
expect(screen.queryByRole("menu")).toBeNull();
expect(video().volume).toBeLessThan(before);
}
});
it("marks the chosen track with the tick of a one-of-several menu", async () => {
const two = item("A");
two.audio_streams = [
...two.audio_streams,
{ ord: 1, label: "Magyar", language: "hu", default: false },
] as PlexItemDetail["audio_streams"];
mount({ details: { A: two } });
await playingAt("/media/A", 100);
fireEvent.click(screen.getByLabelText("plex.player.tracks"));
const rows = await screen.findAllByRole("menuitemradio", { hidden: true });
const ticked = rows.map((r) => {
const tick = r.querySelector("svg");
return tick != null && !tick.getAttribute("class")!.includes("opacity-0");
});
expect(rows.map((r) => r.getAttribute("aria-checked"))).toEqual(["true", "false"]);
expect(ticked).toEqual([true, false]);
});
it("walks the ⋯ with the arrows, and Escape gives the focus back to the ⋯", async () => {
mount({ details: { A: item("A") } });
await playingAt("/media/A", 100);
@@ -449,6 +555,18 @@ describe("the C5 row", () => {
expect(document.activeElement).toBe(screen.getByLabelText("plex.player.next"));
});
it("Next leaving under the focus (onto the last item) hands the focus to its neighbour", async () => {
mount({ details: { A: item("A", 0, "B"), B: item("B") } });
await playingAt("/media/A", 100);
const next = screen.getByLabelText("plex.player.next");
act(() => next.focus());
fireEvent.click(next);
await waitFor(() => expect(screen.queryByLabelText("plex.player.next")).toBeNull());
await waitFor(() =>
expect(document.activeElement).toBe(screen.getByLabelText("plex.player.mute")),
);
});
it("Next shows only when there is a next item", async () => {
mount({ details: { A: item("A") } });
await playingAt("/media/A", 100);