Files
siftlode/frontend/src/components/PlexPlayer.controls.dom.test.tsx
T
peter d5ccb1091a 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.
2026-09-27 22:34:14 +02:00

838 lines
33 KiB
TypeScript

// @vitest-environment jsdom
//
// The Plex player's controls on touch and the C5 control set (DESIGN "Players", E28 S3f2): a tap on
// the picture only shows or hides the controls, a double-tap on a side third skips 10 s, Back goes by
// position, Next exists only with a next item, the rare tools sit behind a fixed ⋯, and the card's
// Restart opens the player at 0. jsdom has no media pipeline: the <video> is stubbed as in
// PlexPlayer.session.dom.test.tsx, and where the player asks a session to start is the observable.
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
import { render, cleanup, waitFor, screen, fireEvent, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
t: (key: string, o?: { n?: number; d?: string }) =>
o?.d != null ? `${key}:${o.d}` : o?.n != null ? `${key}:${o.n}` : key,
i18n: { language: "en" },
}),
};
});
import PlexPlayer from "./PlexPlayer";
import { api, type PlexItemDetail } from "../lib/api";
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
type Media = HTMLMediaElement & { _t?: number; _paused?: boolean };
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
// jsdom has no PointerEvent, and the player tells a finger from a mouse by `pointerType`.
class PointerEventStub extends MouseEvent {
pointerType: string;
constructor(type: string, init: PointerEventInit = {}) {
super(type, init);
this.pointerType = init.pointerType ?? "mouse";
}
}
globalThis.PointerEvent ??= PointerEventStub as unknown as typeof PointerEvent;
Object.defineProperty(HTMLMediaElement.prototype, "currentTime", {
configurable: true,
get(this: Media) {
return this._t ?? 0;
},
set(this: Media, v: number) {
this._t = v;
},
});
Object.defineProperty(HTMLMediaElement.prototype, "paused", {
configurable: true,
get(this: Media) {
return this._paused ?? true;
},
});
Object.defineProperty(HTMLMediaElement.prototype, "load", {
configurable: true,
value(this: Media) {
this._t = 0;
},
});
Object.defineProperty(HTMLMediaElement.prototype, "seekable", {
configurable: true,
get: () => ({ length: 0, end: () => 0 }),
});
Object.defineProperty(HTMLMediaElement.prototype, "textTracks", {
configurable: true,
get: () => Object.assign([], { addEventListener() {}, removeEventListener() {} }),
});
});
let play: ReturnType<typeof vi.fn>;
let pause: ReturnType<typeof vi.fn>;
beforeEach(() => {
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "7");
play = vi.fn(function (this: Media) {
this._paused = false;
this.dispatchEvent(new Event("play"));
return Promise.resolve();
});
pause = vi.fn(function (this: Media) {
this._paused = true;
this.dispatchEvent(new Event("pause"));
});
Object.defineProperty(HTMLMediaElement.prototype, "play", { configurable: true, value: play });
Object.defineProperty(HTMLMediaElement.prototype, "pause", { configurable: true, value: pause });
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.useRealTimers();
sessionStorage.clear();
localStorage.clear();
});
const item = (id: string, position = 0, next: string | null = null): PlexItemDetail =>
({
id,
title: `Title ${id}`,
type: "movie",
duration_seconds: 3000,
status: "new",
position_seconds: position,
next_id: next,
prev_id: null,
audio_streams: [{ ord: 0, label: "English", language: "en", default: true }],
subtitle_streams: [],
markers: [],
}) as unknown as PlexItemDetail;
const direct = (id: string) =>
({ mode: "direct", url: `/media/${id}`, start_s: 0 }) as Awaited<
ReturnType<typeof api.plexSession>
>;
function mount(opts: {
details: Record<string, PlexItemDetail>;
queue?: string[];
first?: string;
fromStart?: boolean;
}) {
vi.spyOn(api, "plexItem").mockImplementation((id: string) => Promise.resolve(opts.details[id]!));
const session = vi
.spyOn(api, "plexSession")
.mockImplementation((id: string) => Promise.resolve(direct(id)));
vi.spyOn(api, "plexProgress").mockResolvedValue(undefined as never);
vi.spyOn(api, "plexSubtitleStatus").mockResolvedValue({ state: "preparing", code: null, gen: 0 });
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const first = opts.first ?? opts.queue?.[0] ?? Object.keys(opts.details)[0]!;
render(
<QueryClientProvider client={qc}>
<PlexPlayer itemId={first} onClose={() => {}} queue={opts.queue} fromStart={opts.fromStart} />
</QueryClientProvider>,
);
return { session };
}
const video = () => document.querySelector("video") as Media;
/** Which item the player last asked a session for, and where in it. */
const lastStart = (session: { mock: { calls: unknown[][] } }) =>
session.mock.calls[session.mock.calls.length - 1]?.slice(0, 2);
async function playingAt(src: string, t: number) {
await waitFor(() => expect(video()?.getAttribute("src")).toBe(src));
const v = video();
v._t = t;
act(() => void v.dispatchEvent(new Event("timeupdate")));
}
/** The bottom band (the seek bar and the row): hidden = faded AND out of the pointer's way. */
const bandHidden = () =>
screen
.getByLabelText("plex.player.playPause")
.closest("[class*='bottom-0']")!
.className.includes("opacity-0");
/** A press on the picture, from a finger or a mouse, at `x` of a 900px-wide picture. */
function pressPicture(pointerType: "touch" | "mouse", x = 450) {
const v = video();
vi.spyOn(v, "getBoundingClientRect").mockReturnValue({
left: 0,
top: 0,
width: 900,
height: 500,
right: 900,
bottom: 500,
x: 0,
y: 0,
toJSON: () => ({}),
});
fireEvent.pointerDown(v, { pointerType });
fireEvent.click(v, { clientX: x, clientY: 250 });
}
// A menu's rows are named by their text: the surface is `visibility: hidden` until floating-ui has
// measured it (never, under jsdom), and a hidden element's accessible name is empty.
const menuItem = (label: string) =>
waitFor(() => {
const row = screen
.queryAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === label);
expect(row).toBeDefined();
return row!;
});
describe("a tap on the picture (C3)", () => {
it("a mouse click plays or pauses; a finger's tap only shows or hides the controls", async () => {
mount({ details: { A: item("A") } });
await playingAt("/media/A", 100);
const plays = play.mock.calls.length;
const pauses = pause.mock.calls.length;
pressPicture("touch");
expect(bandHidden()).toBe(true);
pressPicture("touch");
expect(bandHidden()).toBe(false);
expect(play.mock.calls.length).toBe(plays);
expect(pause.mock.calls.length).toBe(pauses);
pressPicture("mouse");
expect(play.mock.calls.length + pause.mock.calls.length).toBe(plays + pauses + 1);
});
it("the hide timer starts with playback, not with the first touch", async () => {
mount({ details: { A: item("A") } });
await playingAt("/media/A", 100);
vi.useFakeTimers();
video()._paused = true;
act(() => void video().play());
expect(bandHidden()).toBe(false);
act(() => vi.advanceTimersByTime(3000));
expect(bandHidden()).toBe(true);
});
});
// 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);
});
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 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.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);
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)", () => {
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") } });
await playingAt("/media/A", 1500);
expect(bandHidden()).toBe(false);
pressPicture("touch", 800);
expect(bandHidden()).toBe(true); // the first tap alone is a toggle…
pressPicture("touch", 800);
expect(bandHidden()).toBe(false); // …which the second takes back
await waitFor(() => expect(lastStart(session)).toEqual(["A", 1510]));
expect(screen.getByText("plex.player.tapForward:10 sec")).toBeTruthy();
pressPicture("touch", 800);
await waitFor(() => expect(lastStart(session)).toEqual(["A", 1520]));
expect(screen.getByText("plex.player.tapForward:20 sec")).toBeTruthy();
pressPicture("touch", 50); // the other side, a fresh first tap: a toggle, no skip
expect(lastStart(session)).toEqual(["A", 1520]);
});
it("the middle third never skips: a quick second tap there just toggles back", async () => {
const { session } = mount({ details: { A: item("A") } });
await playingAt("/media/A", 1500);
const calls = session.mock.calls.length;
pressPicture("touch", 450);
pressPicture("touch", 450);
expect(bandHidden()).toBe(false);
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);
pressPicture("touch", 100);
pressPicture("touch", 100);
await waitFor(() => expect(lastStart(session)).toEqual(["A", 1490]));
expect(screen.getByText("plex.player.tapBack:10 sec")).toBeTruthy();
});
});
// E28 review D1: the info overlay drew its collection siblings with a Play that did nothing — the
// overlay had no way to play one. It plays it in this player, at its resume point or from the start.
describe("a collection sibling in the info overlay", () => {
const withSibling = (id: string): PlexItemDetail => ({
...item(id),
cast: [],
genres: [],
directors: [],
collections: [
{
id: "c1",
title: "Saga",
source: "plex",
items: [
{
id: "B",
type: "movie",
title: "Title B",
thumb: "",
status: "in_progress",
position_seconds: 300,
duration_seconds: 3000,
},
],
},
],
});
it("plays its Restart in this player, from the start", { timeout: 10000 }, async () => {
const { session } = mount({ details: { A: withSibling("A"), B: item("B", 300) } });
await playingAt("/media/A", 100);
fireEvent.click(screen.getByLabelText("plex.player.more"));
fireEvent.click(await menuItem("plex.player.infoBtn"));
// The overlay is a lazy chunk: its first import takes a while under the test runner.
const restart = await screen.findByRole("button", { name: "card.restart" }, { timeout: 5000 });
fireEvent.click(restart);
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("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<typeof api.plexSession>
>),
);
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("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<ReturnType<typeof api.plexSession>>) => 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<ReturnType<typeof api.plexSession>>) => 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") } });
await playingAt("/media/A", 100);
for (const gone of [
"plex.player.stop",
"plex.player.rewind10",
"plex.player.forward10",
"plex.player.stats.title",
"plex.player.infoBtn",
"plex.player.help",
"plex.player.download",
"plex.player.settings",
])
expect(screen.queryByLabelText(gone), gone).toBeNull();
fireEvent.click(screen.getByLabelText("plex.player.more"));
const rows = [
"plex.player.settings",
"plex.player.infoBtn",
"plex.player.stats.title",
"plex.player.help",
"plex.player.download",
];
const menu = await screen.findByRole("menu", { hidden: true });
expect(
[...menu.querySelectorAll('[role="menuitem"]')].map(
(b) => b.getAttribute("aria-label") ?? b.textContent,
),
).toEqual(rows);
// A row closes the menu and opens its tool: Settings → the tuning panel.
fireEvent.click(await menuItem("plex.player.settings"));
await screen.findByRole("dialog", { name: "plex.player.settings" });
expect(
screen
.queryAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === "plex.player.stats.title") ?? null,
).toBeNull();
expect(screen.getByLabelText("plex.player.more").getAttribute("aria-expanded")).toBe("true");
});
// 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);
const more = screen.getByLabelText("plex.player.more");
more.focus();
fireEvent.click(more);
const menu = await screen.findByRole("menu", { hidden: true });
const rows = [...menu.querySelectorAll<HTMLElement>('[role="menuitem"]')];
await waitFor(() => expect(document.activeElement).toBe(rows[0]));
fireEvent.keyDown(document.activeElement!, { key: "ArrowDown" });
expect(document.activeElement).toBe(rows[1]);
fireEvent.keyDown(document.activeElement!, { key: "End" });
expect(document.activeElement).toBe(rows[rows.length - 1]);
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
await waitFor(() => expect(screen.queryByRole("menu")).toBeNull());
await waitFor(() => expect(document.activeElement).toBe(more));
});
it("gives the focus back to the ⋯ when the tuning panel closes from inside", async () => {
mount({ details: { A: item("A") } });
await playingAt("/media/A", 100);
const more = screen.getByLabelText("plex.player.more");
fireEvent.click(more);
fireEvent.click(await menuItem("plex.player.settings"));
const panel = await screen.findByRole("dialog", { name: "plex.player.settings" });
// Into the panel on open…
await waitFor(() => expect(panel.contains(document.activeElement)).toBe(true));
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
// …and back on the ⋯ when it goes, not on <body>.
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 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);
expect(screen.queryByLabelText("plex.player.next")).toBeNull();
cleanup();
mount({ details: { A: item("A"), B: item("B") }, queue: ["A", "B"] });
await playingAt("/media/A", 100);
expect(screen.getByLabelText("plex.player.next")).toBeTruthy();
});
});
describe("Back goes by position (C5)", () => {
it("more than 3 s in: back to the start of this item", async () => {
// B opened at its resume point, so a session asked at 0 can only be the restart.
const { session } = mount({
details: { A: item("A"), B: item("B", 900) },
queue: ["A", "B"],
first: "B",
});
await playingAt("/media/B", 40);
fireEvent.click(screen.getByLabelText("plex.player.restart"));
// Inside the loaded media the restart is a native seek: the clock goes to 0, on B.
await waitFor(() => expect(video()._t).toBe(0));
expect(lastStart(session)?.[0]).toBe("B");
});
it("within the first 3 s: the previous item, at its resume point", async () => {
const { session } = mount({
details: { A: item("A", 700), B: item("B") },
queue: ["A", "B"],
first: "B",
});
await playingAt("/media/B", 2);
fireEvent.click(screen.getByLabelText("plex.player.prev"));
await waitFor(() => expect(lastStart(session)).toEqual(["A", 700]));
});
it("the first item has only the restart", async () => {
const { session } = mount({ details: { A: item("A", 900), B: item("B") }, queue: ["A", "B"] });
await playingAt("/media/A", 2);
expect(screen.queryByLabelText("plex.player.prev")).toBeNull();
fireEvent.click(screen.getByLabelText("plex.player.restart"));
await waitFor(() => expect(video()._t).toBe(0));
expect(lastStart(session)?.[0]).toBe("A");
});
});
describe("the card's Restart (C5)", () => {
it("opens the item at 0 instead of its resume point", async () => {
const { session } = mount({ details: { A: item("A", 900) }, fromStart: true });
await waitFor(() => expect(session).toHaveBeenCalled());
expect(session.mock.calls[0]![1]).toBe(0);
});
it("without it, the item resumes", async () => {
const { session } = mount({ details: { A: item("A", 900) } });
await waitFor(() => expect(session).toHaveBeenCalled());
expect(session.mock.calls[0]![1]).toBe(900);
});
it("only for the item it opened with: the next one resumes", async () => {
const { session } = mount({
details: { A: item("A", 900), B: item("B", 300) },
queue: ["A", "B"],
fromStart: true,
});
await playingAt("/media/A", 100);
fireEvent.click(screen.getByLabelText("plex.player.next"));
await waitFor(() => expect(lastStart(session)).toEqual(["B", 300]));
});
});
describe("the seek bar (C3)", () => {
it("is a 2.75rem hit area a finger drags without panning, with the head shown on touch", async () => {
mount({ details: { A: item("A") } });
await playingAt("/media/A", 100);
const hit = document.querySelector<HTMLElement>(".touch-none.h-11");
expect(hit).not.toBeNull();
const head = hit!.querySelector("[class*='rounded-full'][class*='w-3']");
expect(head?.className).toContain("[@media(hover:none)]:opacity-100");
});
});