- 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.
1101 lines
43 KiB
TypeScript
1101 lines
43 KiB
TypeScript
// @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();
|
||
});
|
||
});
|