/** * Copyright (c) Double Symmetry GmbH * Commercial use requires a license. See https://rntp.dev/pricing */ import { Event } from '../events'; import { PlayerCommand } from '../interfaces/PlayerCommand'; import type { RemoteControlConfig } from '../interfaces/PlayerConfig'; import { uriOf, type ResolvedMediaItem } from './engines/AudioEngine'; export interface MediaSessionActions { play(): void; pause(): void; stop(): void; next(): void; previous(): void; seekTo(position: number): void; seekBy(offset: number): void; emit(event: Event, payload: unknown): void; } type SessionLike = Pick & Partial> & { metadata: unknown; playbackState: string; }; const ALL_ACTIONS = [ 'play', 'pause', 'stop', 'seekto', 'seekforward', 'seekbackward', 'previoustrack', 'nexttrack', ] as const; function defaultSession(): SessionLike | null { return typeof navigator !== 'undefined' && 'mediaSession' in navigator ? (navigator.mediaSession as unknown as SessionLike) : null; } /** * Bridges setCommands/metadata onto the browser MediaSession API: media keys, * lock-screen and notification controls, now-playing widgets. Feature-detected; * a silent no-op where the API is missing. */ export class MediaSessionController { private lastCommands: RemoteControlConfig | null = null; private lastItem: ResolvedMediaItem | null = null; constructor( private actions: MediaSessionActions, private session: SessionLike | null = defaultSession() ) {} setCommands(config: RemoteControlConfig): void { this.lastCommands = config; if (this.session == null) return; for (const action of ALL_ACTIONS) this.setHandler(action, null); const effective = (cmd: PlayerCommand): 'native' | 'js' => { if (config.handling === 'js') return 'js'; if (config.handling === 'hybrid') { return config.perCommandHandling?.[cmd] ?? 'native'; } return 'native'; }; const dispatch = ( cmd: PlayerCommand, native: () => void, event: Event, payload: () => unknown = () => ({}) ) => { if (effective(cmd) === 'native') native(); else this.actions.emit(event, payload()); }; for (const cmd of config.capabilities) { switch (cmd) { case PlayerCommand.PlayPause: this.setHandler('play', () => dispatch(cmd, () => this.actions.play(), Event.RemotePlay) ); this.setHandler('pause', () => dispatch(cmd, () => this.actions.pause(), Event.RemotePause) ); break; case PlayerCommand.Stop: this.setHandler('stop', () => dispatch(cmd, () => this.actions.stop(), Event.RemoteStop) ); break; case PlayerCommand.Next: this.setHandler('nexttrack', () => dispatch(cmd, () => this.actions.next(), Event.RemoteNext) ); break; case PlayerCommand.Previous: this.setHandler('previoustrack', () => dispatch(cmd, () => this.actions.previous(), Event.RemotePrevious) ); break; case PlayerCommand.Seek: this.setHandler('seekto', (details) => { const position = details?.seekTime ?? 0; dispatch( cmd, () => this.actions.seekTo(position), Event.RemoteSeek, () => ({ position }) ); }); break; case PlayerCommand.SkipForward: this.setHandler('seekforward', (details) => { const interval = details?.seekOffset ?? config.forwardInterval ?? 15; dispatch( cmd, () => this.actions.seekBy(interval), Event.RemoteSkipForward, () => ({ interval }) ); }); break; case PlayerCommand.SkipBackward: this.setHandler('seekbackward', (details) => { const interval = details?.seekOffset ?? config.backwardInterval ?? 15; dispatch( cmd, () => this.actions.seekBy(-interval), Event.RemoteSkipBackward, () => ({ interval }) ); }); break; } } } updateMetadata(item: ResolvedMediaItem | null): void { this.lastItem = item; if (this.session == null) return; if (item == null) { this.session.metadata = null; return; } // MediaMetadata is a browser global; guard for older engines/jsdom. const metadata = typeof MediaMetadata !== 'undefined' ? new MediaMetadata({ title: item.title ?? '', artist: item.artist ?? '', album: item.albumTitle ?? '', artwork: item.artworkUrl != null ? [{ src: uriOf(item.artworkUrl) }] : [], }) : { title: item.title, artist: item.artist, album: item.albumTitle, artwork: item.artworkUrl != null ? [{ src: uriOf(item.artworkUrl) }] : [], }; this.session.metadata = metadata; } updatePlaybackState(state: 'none' | 'paused' | 'playing'): void { if (this.session) this.session.playbackState = state; } updatePositionState(p: { duration: number; position: number; playbackRate: number; }): void { if (this.session?.setPositionState == null) return; if (!Number.isFinite(p.duration) || p.duration < 0) return; this.session.setPositionState({ duration: p.duration, position: Math.min(Math.max(0, p.position), p.duration), playbackRate: p.playbackRate || 1, }); } /** * iOS Safari only respects handlers/metadata set after audio has actually * started — re-apply the latest config on the first 'playing' of a session. */ reassert(): void { if (this.lastCommands) this.setCommands(this.lastCommands); this.updateMetadata(this.lastItem); } destroy(): void { if (this.session == null) return; for (const action of ALL_ACTIONS) this.setHandler(action, null); this.session.metadata = null; this.session.playbackState = 'none'; } private setHandler( action: (typeof ALL_ACTIONS)[number], handler: ((details: any) => void) | null ): void { try { this.session?.setActionHandler(action as MediaSessionAction, handler); } catch { // Some browsers throw for unsupported actions — ignore. } } }