// What is playing, told to the operating system through the Media Session API: the lock screen, the // notification shade, a headset's buttons and the keyboard's media keys show the title and the // picture and control the player — play, pause, skip back and forward, and a seek from the OS's own // progress bar. One definition for the Plex, library and shared-link players. // // The YouTube player is not one of them, by measurement: the browser routes the OS controls to the // frame that plays the sound, which is YouTube's own iframe, and that iframe already says its title, // channel and picture and takes play/pause/seek itself. Ours, set on the page, would never be shown. // // It is also what lets a browser's own "background playback" keep the sound going when the page is // backgrounded — how youtube.com stays playing on Brave iOS. The sound stays on a plain media element // for that reason: routing it through the Web Audio API (`createMediaElementSource`) pulls it out of // the pipeline that background playback hooks into, and iOS then suspends the AudioContext — which // STOPPED playback instead of keeping it alive (measured on iPad Brave). import { useEffect, useEffectEvent } from "react"; export interface MediaSessionInfo { title: string; /** The line under the title: a channel, a show, an uploader. */ artist?: string | null; /** A picture of it (same-origin or absolute URL). */ artwork?: string | null; } /** How the OS's controls drive a player whose playhead is not simply its element's (a Plex * transcode starts its stream at the resume point, and a seek outside it restarts the stream). */ export interface MediaSessionControls { play(): void; pause(): void; seekTo(seconds: number): void; /** Where it is, in the item's own seconds. */ position(): number; /** How long the item is; 0 while unknown. */ duration(): number; } /** The OS's skip back / forward step when it does not say one. */ export const SESSION_SKIP_S = 10; /** The controls of a plain media element: its own clock, its own seek. */ function elementControls(media: HTMLMediaElement): MediaSessionControls { return { play: () => void media.play().catch(() => {}), pause: () => media.pause(), seekTo: (s) => { media.currentTime = s; }, position: () => media.currentTime, duration: () => (Number.isFinite(media.duration) ? media.duration : 0), }; } // The events after which the OS's progress bar is re-anchored. Between them it runs on by itself at // the stated rate, so nothing is sent per frame. const POSITION_EVENTS = [ "loadedmetadata", "durationchange", "play", "pause", "seeked", "ratechange", ] as const; const ACTIONS = ["play", "pause", "seekbackward", "seekforward", "seekto"] as const; // The API's throwing calls, each in a module helper of its own: a `try` inside the hook makes the // React Compiler skip it (see CLAUDE.md's async-handler row). /** The metadata, told to the OS — or null when this browser refuses it (then no session at all, * rather than half of one). */ function claim(session: MediaSession, init: MediaMetadataInit): MediaMetadata | null { try { const metadata = new MediaMetadata(init); session.metadata = metadata; return metadata; } catch { return null; } } function handle( session: MediaSession, action: MediaSessionAction, handler: MediaSessionActionHandler | null, ): void { try { session.setActionHandler(action, handler); } catch { /* an action this browser does not know */ } } function setPosition(session: MediaSession, state?: MediaPositionState): void { try { session.setPositionState(state); } catch { /* a position the browser refuses (it checks position <= duration itself), or nothing to clear */ } } /** * Tell the OS that `media` plays `info`, for as long as both are given; `controls` (default: the * element's own) is what its buttons do. Cleared on unmount, unless another player has already * claimed the session since. */ export function useMediaSession( media: HTMLMediaElement | null, info: MediaSessionInfo | null | undefined, controls?: MediaSessionControls, ): void { const title = info?.title ?? null; const artist = info?.artist ?? ""; const artwork = info?.artwork ?? null; // Read when an action arrives, not bound at the effect: the Plex player's controls are fresh // closures every render, and re-telling the OS on each render would be churn. const ctl = useEffectEvent( (): MediaSessionControls | null => controls ?? (media ? elementControls(media) : null), ); useEffect(() => { const session = typeof navigator !== "undefined" ? navigator.mediaSession : undefined; if (!session || !media || title == null || typeof MediaMetadata === "undefined") return; const metadata = claim(session, { title, artist, artwork: artwork ? [{ src: new URL(artwork, document.baseURI).href }] : [], }); if (!metadata) return; const skip = (dir: -1 | 1) => (d: MediaSessionActionDetails) => { const c = ctl(); if (!c) return; const end = c.duration() || Infinity; c.seekTo(Math.min(end, Math.max(0, c.position() + dir * (d.seekOffset ?? SESSION_SKIP_S)))); }; handle(session, "play", () => ctl()?.play()); handle(session, "pause", () => ctl()?.pause()); handle(session, "seekbackward", skip(-1)); handle(session, "seekforward", skip(1)); handle(session, "seekto", (d) => { if (d.seekTime != null) ctl()?.seekTo(d.seekTime); }); const anchor = () => { const c = ctl(); const duration = c?.duration() ?? 0; if (!c || !(duration > 0)) return; setPosition(session, { duration, position: Math.min(duration, Math.max(0, c.position())), playbackRate: media.playbackRate || 1, }); }; for (const type of POSITION_EVENTS) media.addEventListener(type, anchor); anchor(); return () => { for (const type of POSITION_EVENTS) media.removeEventListener(type, anchor); if (session.metadata !== metadata) return; // another player has the session now session.metadata = null; for (const action of ACTIONS) handle(session, action, null); setPosition(session); }; }, [media, title, artist, artwork]); }