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.
This commit is contained in:
@@ -0,0 +1,367 @@
|
||||
// @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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user