Plex player: a finger's tap on the picture only shows or hides the controls, the hide timer starts with playback, a double-tap on a side third skips 10 s, the seek bar is a 2.75rem touch-none hit area with its head shown on touch. The row is play/pause, Back by position, Next only with a next item, mute, time, tracks, a fixed ⋯ (settings, media info, stats, shortcuts, download) and fullscreen; Stop, Restart and the ±10 s buttons are gone. A started card shows the video card's Continue + Restart pair (one shared MediaResumeButtons, beside the picture's button), and its menu lists the restart; the player opens that item at 0 (fromStart). YouTube modal: the description opens from an ⓘ, the queue arrows sit inside the screen.
368 lines
13 KiB
TypeScript
368 lines
13 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// The Plex player's controls on touch and the C5 control set (DESIGN "Players", E28 S3f2): a tap on
|
|
// the picture only shows or hides the controls, a double-tap on a side third skips 10 s, Back goes by
|
|
// position, Next exists only with a next item, the rare tools sit behind a fixed ⋯, and the card's
|
|
// Restart opens the player at 0. jsdom has no media pipeline: the <video> is stubbed as in
|
|
// PlexPlayer.session.dom.test.tsx, and where the player asks a session to start is the observable.
|
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
|
|
import { render, cleanup, waitFor, screen, fireEvent, act } from "@testing-library/react";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
|
|
vi.mock("react-i18next", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
|
return {
|
|
...actual,
|
|
useTranslation: () => ({
|
|
t: (key: string, o?: { n?: number }) => (o?.n != null ? `${key}:${o.n}` : key),
|
|
i18n: { language: "en" },
|
|
}),
|
|
};
|
|
});
|
|
|
|
import PlexPlayer from "./PlexPlayer";
|
|
import { api, type PlexItemDetail } from "../lib/api";
|
|
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
|
|
|
|
type Media = HTMLMediaElement & { _t?: number; _paused?: boolean };
|
|
|
|
beforeAll(() => {
|
|
class Stub {
|
|
observe() {}
|
|
unobserve() {}
|
|
disconnect() {}
|
|
takeRecords() {
|
|
return [];
|
|
}
|
|
}
|
|
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
|
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
|
// jsdom has no PointerEvent, and the player tells a finger from a mouse by `pointerType`.
|
|
class PointerEventStub extends MouseEvent {
|
|
pointerType: string;
|
|
constructor(type: string, init: PointerEventInit = {}) {
|
|
super(type, init);
|
|
this.pointerType = init.pointerType ?? "mouse";
|
|
}
|
|
}
|
|
globalThis.PointerEvent ??= PointerEventStub as unknown as typeof PointerEvent;
|
|
Object.defineProperty(HTMLMediaElement.prototype, "currentTime", {
|
|
configurable: true,
|
|
get(this: Media) {
|
|
return this._t ?? 0;
|
|
},
|
|
set(this: Media, v: number) {
|
|
this._t = v;
|
|
},
|
|
});
|
|
Object.defineProperty(HTMLMediaElement.prototype, "paused", {
|
|
configurable: true,
|
|
get(this: Media) {
|
|
return this._paused ?? true;
|
|
},
|
|
});
|
|
Object.defineProperty(HTMLMediaElement.prototype, "load", {
|
|
configurable: true,
|
|
value(this: Media) {
|
|
this._t = 0;
|
|
},
|
|
});
|
|
Object.defineProperty(HTMLMediaElement.prototype, "seekable", {
|
|
configurable: true,
|
|
get: () => ({ length: 0, end: () => 0 }),
|
|
});
|
|
Object.defineProperty(HTMLMediaElement.prototype, "textTracks", {
|
|
configurable: true,
|
|
get: () => Object.assign([], { addEventListener() {}, removeEventListener() {} }),
|
|
});
|
|
});
|
|
|
|
let play: ReturnType<typeof vi.fn>;
|
|
let pause: ReturnType<typeof vi.fn>;
|
|
beforeEach(() => {
|
|
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "7");
|
|
play = vi.fn(function (this: Media) {
|
|
this._paused = false;
|
|
this.dispatchEvent(new Event("play"));
|
|
return Promise.resolve();
|
|
});
|
|
pause = vi.fn(function (this: Media) {
|
|
this._paused = true;
|
|
this.dispatchEvent(new Event("pause"));
|
|
});
|
|
Object.defineProperty(HTMLMediaElement.prototype, "play", { configurable: true, value: play });
|
|
Object.defineProperty(HTMLMediaElement.prototype, "pause", { configurable: true, value: pause });
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.restoreAllMocks();
|
|
vi.useRealTimers();
|
|
sessionStorage.clear();
|
|
localStorage.clear();
|
|
});
|
|
|
|
const item = (id: string, position = 0, next: string | null = null): PlexItemDetail =>
|
|
({
|
|
id,
|
|
title: `Title ${id}`,
|
|
type: "movie",
|
|
duration_seconds: 3000,
|
|
status: "new",
|
|
position_seconds: position,
|
|
next_id: next,
|
|
prev_id: null,
|
|
audio_streams: [{ ord: 0, label: "English", language: "en", default: true }],
|
|
subtitle_streams: [],
|
|
markers: [],
|
|
}) as unknown as PlexItemDetail;
|
|
|
|
const direct = (id: string) =>
|
|
({ mode: "direct", url: `/media/${id}`, start_s: 0 }) as Awaited<
|
|
ReturnType<typeof api.plexSession>
|
|
>;
|
|
|
|
function mount(opts: {
|
|
details: Record<string, PlexItemDetail>;
|
|
queue?: string[];
|
|
first?: string;
|
|
fromStart?: boolean;
|
|
}) {
|
|
vi.spyOn(api, "plexItem").mockImplementation((id: string) => Promise.resolve(opts.details[id]!));
|
|
const session = vi
|
|
.spyOn(api, "plexSession")
|
|
.mockImplementation((id: string) => Promise.resolve(direct(id)));
|
|
vi.spyOn(api, "plexProgress").mockResolvedValue(undefined as never);
|
|
vi.spyOn(api, "plexSubtitleStatus").mockResolvedValue({ state: "preparing", code: null, gen: 0 });
|
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
const first = opts.first ?? opts.queue?.[0] ?? Object.keys(opts.details)[0]!;
|
|
render(
|
|
<QueryClientProvider client={qc}>
|
|
<PlexPlayer itemId={first} onClose={() => {}} queue={opts.queue} fromStart={opts.fromStart} />
|
|
</QueryClientProvider>,
|
|
);
|
|
return { session };
|
|
}
|
|
|
|
const video = () => document.querySelector("video") as Media;
|
|
|
|
/** Which item the player last asked a session for, and where in it. */
|
|
const lastStart = (session: { mock: { calls: unknown[][] } }) =>
|
|
session.mock.calls[session.mock.calls.length - 1]?.slice(0, 2);
|
|
|
|
async function playingAt(src: string, t: number) {
|
|
await waitFor(() => expect(video()?.getAttribute("src")).toBe(src));
|
|
const v = video();
|
|
v._t = t;
|
|
act(() => void v.dispatchEvent(new Event("timeupdate")));
|
|
}
|
|
|
|
/** The bottom band (the seek bar and the row): hidden = faded AND out of the pointer's way. */
|
|
const bandHidden = () =>
|
|
screen
|
|
.getByLabelText("plex.player.playPause")
|
|
.closest("[class*='bottom-0']")!
|
|
.className.includes("opacity-0");
|
|
|
|
/** A press on the picture, from a finger or a mouse, at `x` of a 900px-wide picture. */
|
|
function pressPicture(pointerType: "touch" | "mouse", x = 450) {
|
|
const v = video();
|
|
vi.spyOn(v, "getBoundingClientRect").mockReturnValue({
|
|
left: 0,
|
|
top: 0,
|
|
width: 900,
|
|
height: 500,
|
|
right: 900,
|
|
bottom: 500,
|
|
x: 0,
|
|
y: 0,
|
|
toJSON: () => ({}),
|
|
});
|
|
fireEvent.pointerDown(v, { pointerType });
|
|
fireEvent.click(v, { clientX: x, clientY: 250 });
|
|
}
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
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")).toBeTruthy();
|
|
|
|
pressPicture("touch", 800);
|
|
await waitFor(() => expect(lastStart(session)).toEqual(["A", 1520]));
|
|
expect(screen.getByText("plex.player.tapForward:20")).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);
|
|
});
|
|
|
|
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")).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
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",
|
|
];
|
|
for (const r of rows) expect(screen.getByRole("button", { name: r }), r).toBeTruthy();
|
|
// A row closes the menu and opens its tool: Settings → the tuning panel.
|
|
fireEvent.click(screen.getByRole("button", { name: "plex.player.settings" }));
|
|
expect(screen.queryByRole("button", { name: "plex.player.stats.title" })).toBeNull();
|
|
expect(screen.getByLabelText("plex.player.more").getAttribute("aria-expanded")).toBe("true");
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|