Plex, library and /watch tell the operating system what plays (title, line, picture) and take its
play / pause / skip / seek through one hook (lib/mediaSession); the Plex player drives it with its own
seek and clock, so the lock screen shows the film's position, not the stream's. The YouTube player is
left alone: its iframe already publishes all of it, and the OS routes to the frame that plays.
The library and /watch players get the basic keys (lib/mediaKeys, the keymap's mediaPlayer rows) and
open where the viewer stopped (lib/resume): the library keeps the point on the server, per user and
library item (library_states, POST /api/downloads/{id}/progress, listed back on both listings), the
shared link in this browser. A started library row continues, its menu can start it over, and its
picture carries the cards' progress bar. The resume rule (too early / near the end = from the start)
is one function shared with the YouTube route.
The library dialog opens focused on its panel (Modal initialFocus "player"): on the ✕, Space closed
the film. While the media element itself has the focus, Space and the arrows stay its own controls'
— the browser acts on them whatever a listener does, and one Space played and paused.
131 lines
4.3 KiB
TypeScript
131 lines
4.3 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// The basic keys of a player on the browser's own media element (the library's, a shared link's):
|
|
// Space/k play and pause — but Space on a focused button presses the button — the arrows step 5 s
|
|
// inside the item, m mutes, f fills the screen with a video (an audio file has no picture). While the
|
|
// element itself has the focus, Space and the arrows are its own controls' (they act on them anyway,
|
|
// so a second action from here was a double play/pause and a double step).
|
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
import { renderHook, cleanup } from "@testing-library/react";
|
|
|
|
const fs = vi.hoisted(() => ({ requestFullscreen: vi.fn(), exitFullscreen: vi.fn() }));
|
|
vi.mock("./fullscreen", () => ({
|
|
fullscreenElement: () => null,
|
|
requestFullscreen: fs.requestFullscreen,
|
|
exitFullscreen: fs.exitFullscreen,
|
|
}));
|
|
|
|
import { MEDIA_STEP_S, useMediaKeys } from "./mediaKeys";
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.clearAllMocks();
|
|
document.body.innerHTML = "";
|
|
});
|
|
|
|
function media(tag: "video" | "audio" = "video", duration = 100) {
|
|
const el = document.createElement(tag);
|
|
document.body.append(el);
|
|
let paused = true;
|
|
Object.defineProperty(el, "duration", { value: duration, configurable: true });
|
|
Object.defineProperty(el, "paused", { get: () => paused, configurable: true });
|
|
vi.spyOn(el, "play").mockImplementation(() => {
|
|
paused = false;
|
|
return Promise.resolve();
|
|
});
|
|
vi.spyOn(el, "pause").mockImplementation(() => {
|
|
paused = true;
|
|
});
|
|
return el;
|
|
}
|
|
|
|
function press(key: string, target: EventTarget = document.body): KeyboardEvent {
|
|
const e = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true });
|
|
target.dispatchEvent(e);
|
|
return e;
|
|
}
|
|
|
|
describe("useMediaKeys", () => {
|
|
it("Space and k play and pause, and take the key from the page", () => {
|
|
const el = media();
|
|
renderHook(() => useMediaKeys(el));
|
|
const e = press(" ");
|
|
expect(el.play).toHaveBeenCalledOnce();
|
|
expect(e.defaultPrevented).toBe(true);
|
|
press("k");
|
|
expect(el.pause).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("on the focused element, Space and the arrows are left to its own controls; k still is ours", () => {
|
|
const el = media("video", 100);
|
|
renderHook(() => useMediaKeys(el));
|
|
el.currentTime = 50;
|
|
const space = press(" ", el);
|
|
const arrow = press("ArrowRight", el);
|
|
expect(el.play).not.toHaveBeenCalled();
|
|
expect(el.currentTime).toBe(50);
|
|
expect([space.defaultPrevented, arrow.defaultPrevented]).toEqual([false, false]);
|
|
press("k", el);
|
|
expect(el.play).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("Space on a focused button presses the button instead", () => {
|
|
const el = media();
|
|
const button = document.createElement("button");
|
|
document.body.append(button);
|
|
renderHook(() => useMediaKeys(el));
|
|
const e = press(" ", button);
|
|
expect(el.play).not.toHaveBeenCalled();
|
|
expect(e.defaultPrevented).toBe(false);
|
|
});
|
|
|
|
it("the arrows step inside the item", () => {
|
|
const el = media("video", 100);
|
|
renderHook(() => useMediaKeys(el));
|
|
el.currentTime = 2;
|
|
press("ArrowLeft");
|
|
expect(el.currentTime).toBe(0);
|
|
el.currentTime = 50;
|
|
press("ArrowRight");
|
|
expect(el.currentTime).toBe(50 + MEDIA_STEP_S);
|
|
el.currentTime = 98;
|
|
press("ArrowRight");
|
|
expect(el.currentTime).toBe(100);
|
|
});
|
|
|
|
it("m mutes and unmutes", () => {
|
|
const el = media();
|
|
renderHook(() => useMediaKeys(el));
|
|
press("m");
|
|
expect(el.muted).toBe(true);
|
|
press("m");
|
|
expect(el.muted).toBe(false);
|
|
});
|
|
|
|
it("f fills the screen with a video, and leaves an audio file alone", () => {
|
|
const v = media("video");
|
|
const first = renderHook(() => useMediaKeys(v));
|
|
press("f");
|
|
expect(fs.requestFullscreen).toHaveBeenCalledWith(v);
|
|
first.unmount();
|
|
|
|
const a = media("audio");
|
|
renderHook(() => useMediaKeys(a));
|
|
const e = press("f");
|
|
expect(fs.requestFullscreen).toHaveBeenCalledOnce();
|
|
expect(e.defaultPrevented).toBe(false);
|
|
});
|
|
|
|
it("stays out of a text field, and goes with the player", () => {
|
|
const el = media();
|
|
const input = document.createElement("input");
|
|
document.body.append(input);
|
|
const { unmount } = renderHook(() => useMediaKeys(el));
|
|
press(" ", input);
|
|
expect(el.play).not.toHaveBeenCalled();
|
|
unmount();
|
|
press(" ");
|
|
expect(el.play).not.toHaveBeenCalled();
|
|
});
|
|
});
|