Files
siftlode/frontend/src/lib/mediaKeys.dom.test.ts
T
peter 59b27467ab feat(player): the OS media controls, the basic keys and resume for the library and shared-link players
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.
2026-10-06 23:55:41 +02:00

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