// @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; let session: { metadata: MediaMetadata | null; setActionHandler: (a: string, h: Handler | null) => void; setPositionState: ReturnType; }; 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 = {}) => 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); }); });