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