Files
siftlode/frontend/src/modules/plex/PlexPlayer.controls.dom.test.tsx
T
peter 63a0fecc8b refactor(frontend): the flat components/ folder moves into shell/, modules/<page>/ and features/<name>/
A pure move (git mv, history kept): 333 files leave components/, which now holds only the platform
layer (ui/) and the legal pages. The shell is the frame (App, header, nav rail, palette, boot); each
page lives in modules/<page>/ with its rail, store and tests; what several pages share sits in
features/<name>/; the shared-link page is watch/. Domain-free helpers went straight to ui/
(ViewSwitcher, FilterRail, the confirm dialog, the language switcher, the copy rows).

Every relative specifier was rewritten (imports, dynamic imports, vi.mock, new URL), and the
contract tests that scan sources now scan the whole app source, not only components/ — each glob
was counted before and after so none went green over fewer files. The locale-format rule matched
`/src/components/` only, so the move would have taken ~300 files out from under its i18n-singleton
check; it now covers every folder that holds components, with a test per folder.
2026-10-22 23:12:45 +02:00

1103 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
//
// The Plex player's controls on touch and the C5 control set (DESIGN "Players"): 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 { pushLayer, removeLayer } from "../../lib/layerStack";
import { claimLocalData } from "../../lib/storage";
type Media = HTMLMediaElement & { _t?: number; _paused?: boolean };
// The info overlay is a lazy chunk, and the first test to open it would pay for transforming it
// inside its own wait for the overlay — over that on a busy machine. Loaded once, up front.
beforeAll(async () => {
await import("./PlexInfo");
}, 60_000);
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(() => {
claimLocalData(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;
onClose?: () => void;
}) {
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={opts.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);
});
});
// 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, final: false }],
} 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);
});
// 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();
});
});
// 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"));
const restart = await screen.findByRole("button", { name: "card.restart" });
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" }));
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" });
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");
});
// 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));
});
// 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");
});
});
describe("the time in the row", () => {
it("reads the position and the length, and the clock it ends at — no countdown", async () => {
mount({ details: { A: item("A") } });
await playingAt("/media/A", 100);
const row = screen.getByText(/1:40 \/ 50:00/).closest("div")!;
expect(row.textContent).not.toMatch(/-\s*48:20/);
expect(row.textContent).toMatch(/\(.+\)/);
});
});
// On an iPad a drag on the video moved the page behind it, and the strip it moved showed the page's
// own background under the player.
describe("the page under the player", () => {
it("is locked, without overscroll, on black, while the player is open — and given back after", async () => {
const root = document.documentElement.style;
mount({ details: { A: item("A") } });
await playingAt("/media/A", 100);
expect(document.body.style.overflow).toBe("hidden");
expect(root.overscrollBehavior).toBe("none");
expect(root.backgroundColor).toBe("black");
cleanup();
expect(document.body.style.overflow).toBe("");
expect(root.overscrollBehavior).toBe("");
expect(root.backgroundColor).toBe("");
});
it("is covered by a player sized from its fixed box, not from dvh", async () => {
mount({ details: { A: item("A") } });
await playingAt("/media/A", 100);
const wrap = video().closest<HTMLElement>("[class*='fixed']")!;
expect([wrap.style.width, wrap.style.height]).toEqual(["80%", "80%"]);
});
});
// Brave on an iPad cannot fullscreen an element (it fakes `fullscreenEnabled`), and the button did
// nothing there. It is shown only where the browser has the API on Element.prototype.
describe("the fullscreen button", () => {
const withApi = (on: boolean) => {
if (on)
Object.defineProperty(Element.prototype, "requestFullscreen", {
configurable: true,
value: () => Promise.resolve(),
});
else delete (Element.prototype as { requestFullscreen?: unknown }).requestFullscreen;
};
afterEach(() => withApi(false));
it("is there where the browser can fullscreen an element", async () => {
withApi(true);
mount({ details: { A: item("A") } });
await playingAt("/media/A", 100);
expect(screen.queryByLabelText("plex.player.fullscreen")).not.toBeNull();
});
it("is left out where it cannot, whatever Brave's stand-ins claim", async () => {
withApi(false);
// Brave's: a <video>-only requestFullscreen on HTMLElement.prototype, and fullscreenEnabled true.
Object.defineProperty(HTMLElement.prototype, "requestFullscreen", {
configurable: true,
value: () => true,
});
Object.defineProperty(document, "fullscreenEnabled", { configurable: true, value: true });
mount({ details: { A: item("A") } });
await playingAt("/media/A", 100);
expect(screen.queryByLabelText("plex.player.fullscreen")).toBeNull();
delete (document as { fullscreenEnabled?: unknown }).fullscreenEnabled;
delete (HTMLElement.prototype as { requestFullscreen?: unknown }).requestFullscreen;
});
});
// Clip mode: `[` / `]` mark at the playhead (opening the panel), the panel takes the control row's
// place, Escape closes the panel before it would close the player, and the scissors gets the focus
// back. Cut sends this item and the audio track that is playing.
describe("PlexPlayer — clip mode", () => {
const key = (k: string, init: KeyboardEventInit = {}) =>
act(
() =>
void window.dispatchEvent(
new KeyboardEvent("keydown", { key: k, bubbles: true, cancelable: true, ...init }),
),
);
it("opens on [ with Start at the playhead, and Escape closes only the panel", async () => {
mount({ details: { a: item("a") } });
await playingAt("/media/a", 120);
expect(screen.queryByTestId("clip-panel")).toBeNull();
key("[");
expect(screen.getByTestId("clip-panel")).toBeTruthy();
expect(screen.getByRole("slider", { name: "clip.in" }).getAttribute("aria-valuenow")).toBe(
"120",
);
// The seek bar and the control row make room for it.
expect(screen.queryByLabelText("plex.player.playPause")).toBeNull();
key("Escape");
expect(screen.queryByTestId("clip-panel")).toBeNull();
expect(screen.getByLabelText("plex.player.playPause")).toBeTruthy();
await waitFor(() =>
expect(document.activeElement).toBe(screen.getByRole("button", { name: "clip.open" })),
);
});
it("marks End on ] typed through AltGr (the HU layout's ])", async () => {
mount({ details: { a: item("a") } });
await playingAt("/media/a", 200);
key("]", { ctrlKey: true, altKey: true, modifierAltGraph: true } as KeyboardEventInit);
expect(screen.getByRole("slider", { name: "clip.out" }).getAttribute("aria-valuenow")).toBe(
"200",
);
});
it("cuts this item with the audio track that is playing", async () => {
const enqueue = vi.spyOn(api, "enqueueClip").mockResolvedValue({ id: 9 } as never);
mount({ details: { a: item("a") } });
await playingAt("/media/a", 60);
fireEvent.click(screen.getByRole("button", { name: "clip.open" }));
fireEvent.click(screen.getByRole("button", { name: "clip.cut" }));
await waitFor(() => expect(enqueue).toHaveBeenCalled());
expect(enqueue.mock.calls[0]![0]).toMatchObject({
source: "plex",
ref: "a",
start_s: 60,
end_s: 90,
audio_track: 0,
});
});
it("crops on the picture's own shape, and Escape turns the crop off before the panel", async () => {
const enqueue = vi.spyOn(api, "enqueueClip").mockResolvedValue({ id: 9 } as never);
// A 720×480 stream: the frame is drawn on a 3:2 picture, and the cut is told so.
const proto = HTMLVideoElement.prototype;
const own = ["videoWidth", "videoHeight"].map(
(k) => [k, Object.getOwnPropertyDescriptor(proto, k)] as const,
);
Object.defineProperty(proto, "videoWidth", { configurable: true, get: () => 720 });
Object.defineProperty(proto, "videoHeight", { configurable: true, get: () => 480 });
try {
mount({ details: { a: item("a") } });
await playingAt("/media/a", 60);
key("c");
fireEvent.click(screen.getByRole("button", { name: "clip.crop" }));
// The frame's layer is the video's box: both end above the panel, both at the player's top
// (its flex box centred the shortened video, and the frame sat 155 px above the picture).
const video = document.querySelector("video")!;
const layer = screen.getByTestId("crop-frame").parentElement!;
expect(video.style.alignSelf).toBe("flex-start");
expect(layer.className).toContain("top-0");
expect(layer.style.height).toBe(video.style.height);
key("Escape");
expect(screen.queryByTestId("crop-frame")).toBeNull();
expect(screen.getByTestId("clip-panel")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "clip.crop" }));
fireEvent.click(screen.getByRole("button", { name: "clip.cut" }));
await waitFor(() => expect(enqueue).toHaveBeenCalled());
expect(enqueue.mock.calls[0]![0].crop).toEqual({
x: 0.1,
y: 0.1,
w: 0.8,
h: 0.8,
aspect: 1.5,
});
} finally {
for (const [k, d] of own)
if (d) Object.defineProperty(proto, k, d);
else delete (proto as unknown as Record<string, unknown>)[k];
}
});
const prefs = (p: object) =>
localStorage.setItem("siftlode.plexPlayerPrefs", JSON.stringify({ subLang: "en", ...p }));
const infoOverlay = () => document.querySelector("[class*='bg-black/70']");
it("Escape closes what is over the picture first, then the crop and the panel", async () => {
const full = { ...item("a"), cast: [], genres: [], directors: [], collections: [] };
mount({ details: { a: full as PlexItemDetail } });
await playingAt("/media/a", 120);
key("c");
key("i");
expect(infoOverlay()).toBeTruthy();
key("Escape");
expect(infoOverlay()).toBeNull();
expect(screen.getByTestId("clip-panel")).toBeTruthy();
key("Escape");
expect(screen.queryByTestId("clip-panel")).toBeNull();
});
it("⌫ under a dialog over the player is the dialog's, not the clip's", async () => {
mount({ details: { a: item("a") } });
await playingAt("/media/a", 120);
key("c");
const layer = pushLayer();
try {
key("Backspace");
expect(screen.getByTestId("clip-panel")).toBeTruthy();
} finally {
removeLayer(layer);
}
key("Backspace");
expect(screen.queryByTestId("clip-panel")).toBeNull();
});
it("opens no range while the item's length is unknown", async () => {
mount({
details: { a: { ...item("a"), duration_seconds: null } as unknown as PlexItemDetail },
});
await playingAt("/media/a", 120);
for (const k of ["c", "[", "]"]) key(k);
expect(screen.queryByTestId("clip-panel")).toBeNull();
});
it("an item played to its end while a clip is marked stays, with the clip", async () => {
const onClose = vi.fn();
mount({ details: { a: item("a") }, onClose });
await playingAt("/media/a", 2990);
key("c");
act(() => void video().dispatchEvent(new Event("ended")));
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByTestId("clip-panel")).toBeTruthy();
});
it("does not auto-skip while a clip is marked: the skip would carry the player off it", async () => {
prefs({ autoSkipCredits: true, autoSkipDelay: 0 });
const withCredits = {
...item("a", 0, "b"),
markers: [{ type: "credits", start_s: 2900, end_s: 2995, final: true }],
} as unknown as PlexItemDetail;
const { session } = mount({ details: { a: withCredits, b: item("b") }, queue: ["a", "b"] });
await playingAt("/media/a", 2800);
key("c");
await playingAt("/media/a", 2910);
await act(() => new Promise((r) => setTimeout(r, 50)));
expect(lastStart(session)?.[0]).toBe("a");
expect(screen.getByTestId("clip-panel")).toBeTruthy();
});
});