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.
125 lines
4.6 KiB
TypeScript
125 lines
4.6 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// The OS's media controls: what is playing (title, line, picture), the buttons driving the player —
|
|
// through ITS controls when it has its own clock and seek (the Plex player), else the element's —
|
|
// the progress bar anchored to the player's position, and a clean hand-over: unmounting clears only
|
|
// a session that is still ours.
|
|
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
|
import { renderHook, cleanup } from "@testing-library/react";
|
|
|
|
import { SESSION_SKIP_S, useMediaSession, type MediaSessionControls } from "./mediaSession";
|
|
|
|
type Handler = (d: MediaSessionActionDetails) => void;
|
|
let handlers: Map<string, Handler | null>;
|
|
let session: {
|
|
metadata: MediaMetadata | null;
|
|
setActionHandler: (a: string, h: Handler | null) => void;
|
|
setPositionState: ReturnType<typeof vi.fn>;
|
|
};
|
|
|
|
beforeEach(() => {
|
|
handlers = new Map();
|
|
session = {
|
|
metadata: null,
|
|
setActionHandler: (a, h) => void handlers.set(a, h),
|
|
setPositionState: vi.fn(),
|
|
};
|
|
Object.defineProperty(navigator, "mediaSession", { value: session, configurable: true });
|
|
vi.stubGlobal(
|
|
"MediaMetadata",
|
|
class {
|
|
constructor(public init: MediaMetadataInit) {}
|
|
},
|
|
);
|
|
});
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
const act = (name: string, d: Partial<MediaSessionActionDetails> = {}) =>
|
|
handlers.get(name)?.({ action: name as MediaSessionAction, ...d });
|
|
|
|
function controls(position = 100, duration = 3000): MediaSessionControls {
|
|
return {
|
|
play: vi.fn(),
|
|
pause: vi.fn(),
|
|
seekTo: vi.fn(),
|
|
position: () => position,
|
|
duration: () => duration,
|
|
};
|
|
}
|
|
|
|
describe("useMediaSession", () => {
|
|
it("tells the OS what is playing, with the picture as an absolute URL", () => {
|
|
const media = document.createElement("video");
|
|
renderHook(() =>
|
|
useMediaSession(media, { title: "Pilot", artist: "Westworld", artwork: "/api/plex/image/9" }),
|
|
);
|
|
const init = (session.metadata as unknown as { init: MediaMetadataInit }).init;
|
|
expect(init.title).toBe("Pilot");
|
|
expect(init.artist).toBe("Westworld");
|
|
expect(init.artwork?.[0]?.src).toBe(new URL("/api/plex/image/9", document.baseURI).href);
|
|
});
|
|
|
|
it("drives the player through its own controls, skips clamped to the item", () => {
|
|
const media = document.createElement("video");
|
|
const c = controls(5, 3000);
|
|
renderHook(() => useMediaSession(media, { title: "T" }, c));
|
|
act("play");
|
|
act("pause");
|
|
expect(c.play).toHaveBeenCalledOnce();
|
|
expect(c.pause).toHaveBeenCalledOnce();
|
|
act("seekbackward");
|
|
expect(c.seekTo).toHaveBeenLastCalledWith(0);
|
|
act("seekforward", { seekOffset: 30 });
|
|
expect(c.seekTo).toHaveBeenLastCalledWith(35);
|
|
act("seekto", { seekTime: 1234 });
|
|
expect(c.seekTo).toHaveBeenLastCalledWith(1234);
|
|
});
|
|
|
|
it("anchors the OS's progress bar to the player's own position, not the element's clock", () => {
|
|
const media = document.createElement("video");
|
|
renderHook(() => useMediaSession(media, { title: "T" }, controls(1800, 3000)));
|
|
media.dispatchEvent(new Event("seeked"));
|
|
expect(session.setPositionState).toHaveBeenLastCalledWith({
|
|
duration: 3000,
|
|
position: 1800,
|
|
playbackRate: 1,
|
|
});
|
|
});
|
|
|
|
it("without controls of its own, plays and seeks the element", () => {
|
|
const media = document.createElement("video");
|
|
const play = vi.spyOn(media, "play").mockResolvedValue();
|
|
Object.defineProperty(media, "duration", { value: 600, configurable: true });
|
|
renderHook(() => useMediaSession(media, { title: "T" }));
|
|
act("play");
|
|
expect(play).toHaveBeenCalledOnce();
|
|
act("seekforward");
|
|
expect(media.currentTime).toBe(SESSION_SKIP_S);
|
|
});
|
|
|
|
it("clears the session on unmount — unless another player has taken it since", () => {
|
|
const a = document.createElement("video");
|
|
const first = renderHook(() => useMediaSession(a, { title: "A" }));
|
|
first.unmount();
|
|
expect(session.metadata).toBeNull();
|
|
expect(handlers.get("play")).toBeNull();
|
|
|
|
const one = renderHook(() => useMediaSession(a, { title: "A" }));
|
|
const two = renderHook(() => useMediaSession(document.createElement("video"), { title: "B" }));
|
|
one.unmount();
|
|
expect((session.metadata as unknown as { init: MediaMetadataInit }).init.title).toBe("B");
|
|
expect(handlers.get("play")).toBeTypeOf("function");
|
|
two.unmount();
|
|
});
|
|
|
|
it("does nothing without a title or an element", () => {
|
|
renderHook(() => useMediaSession(null, { title: "T" }));
|
|
renderHook(() => useMediaSession(document.createElement("video"), null));
|
|
expect(session.metadata).toBeNull();
|
|
expect(handlers.size).toBe(0);
|
|
});
|
|
});
|