Files
siftlode/frontend/src/components/PlexPlayer.controls.dom.test.tsx
T
peter 89e65ca331 feat(design): players on touch and the C5 control set, restart on the card (E28 S3f2)
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.
2026-09-27 02:12:54 +02:00

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");
});
});