Files
siftlode/frontend/src/components/PlexPlayer.controls.dom.test.tsx
T
peter c489b60c8b fix(clip): clip mode keeps its range, its focus and its keys through the player's own moves
- A mark on the very end seeks a moment short of it; while a clip is marked the end of the item
  neither closes the player nor steps the queue, and Plex's auto-skip waits.
- Plex: Escape and Back peel what is over the picture (info, up-next, the countdown) before the
  clip; Back under a dialog is the dialog's; no range opens while the length is unknown.
- YouTube: the focus goes back to the scissors the user can see, else the player; the panel keeps
  its name, Loop and focus when the view is maximised or restored under it.
- A typed time stays in its field until taken, then shows where it landed; unreadable stays marked
  invalid; a decimal comma is read (ui/TimecodeField, the editor's fields too).
- RangeTrack: a drag is its pointer's, a cancelled drag reports final, an adornment sits beside the
  slider, a caller-chosen focus ring (amber on the clip strip). CropFrame: a drag is its pointer's.
- The editor's remove-cut is a real button at the cut's foot; a segment's keep toggle sits over the
  cut's hit area.
2026-10-10 15:35:21 +02:00

1101 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 { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
import { pushLayer, removeLayer } from "../lib/layerStack";
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;
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"));
// 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");
});
// 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.7", 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();
});
});