// System Media Transport Controls integration - Windows' own media-key // popup/taskbar overlay (also macOS' Now Playing widget, Android's // notification) - see docs/architecture.md. A thin wrapper around the // standard Media Session API, not a new abstraction of its own: every // function here is a one-line guard (`'mediaSession' in navigator`) // around the exact browser call, since Firefox has never implemented // this at all and this safely no-ops everywhere else it's missing too. // Shared by views/player/Player.ts (video) and musicBar/MusicBar.ts // (a listen party) - the two can never be active at once (starting/ // joining either already leaves the other first), so there's no risk // of them fighting over this single global. export interface MediaSessionActions { onPlay: () => void; onPause: () => void; /** Host-only for video (Player.ts checks isHost itself before acting - * this just delivers the OS's own seek gesture), unrestricted for * music (no host concept there). */ onSeek?: (seconds: number) => void; } /** Title/artist/album/art shown in the OS's own now-playing UI - artist * and album are meaningless for a video (pass '' for both), matching * how a movie has no equivalent fields in this app's own UI either. */ export function setMediaSessionMetadata(title: string, artist: string, album: string, artworkUrl: string): void { if (!('mediaSession' in navigator)) return; navigator.mediaSession.metadata = new MediaMetadata({ title, artist, album, artwork: artworkUrl ? [{ src: artworkUrl, sizes: '512x512', type: 'image/jpeg' }] : [] }); } export function setMediaSessionPlaybackState(playing: boolean): void { if (!('mediaSession' in navigator)) return; navigator.mediaSession.playbackState = playing ? 'playing' : 'paused'; } /** The OS's own scrubber - harmless to skip (not every browser/OS * combination renders one) but adds real functionality where it does. * Chrome throws if `duration` isn't a positive finite number, or if * `position` doesn't satisfy `0 <= position <= duration` - both * momentarily true the instant an item/track changes but the ticker * driving this hasn't caught up yet, so this is caught rather than * guarded field-by-field (the next tick corrects it either way). */ export function setMediaSessionPositionState(duration: number, position: number): void { if (!('mediaSession' in navigator) || !navigator.mediaSession.setPositionState) return; if (!(duration > 0) || !isFinite(duration)) return; try { navigator.mediaSession.setPositionState({ duration, position: Math.max(0, Math.min(position, duration)), playbackRate: 1 }); } catch { // See this function's own comment - a transient mismatch, not a real error. } } /** * Wired once per party lifetime (Player.ts's init(), MusicBar.ts's * initMusicBar()) - the handlers themselves close over whatever local * state they need (isHost, conn, ...) and read it fresh on every * OS-triggered call, so this never needs re-wiring just because that * state changed later (see Player.ts's own isHost, which does exactly * that mid-session on a host handoff). */ export function setMediaSessionActionHandlers(actions: MediaSessionActions): void { if (!('mediaSession' in navigator)) return; navigator.mediaSession.setActionHandler('play', actions.onPlay); navigator.mediaSession.setActionHandler('pause', actions.onPause); if (actions.onSeek) { const onSeek = actions.onSeek; navigator.mediaSession.setActionHandler('seekto', (details) => { if (details.seekTime != null) onSeek(details.seekTime); }); } } /** Shows/hides the OS's own "next" control - called whenever this app's * own equivalent (Player.ts's #nextBtn, a queue advance in general) * would also change visibility. A *registered* handler is what makes * Windows show the button at all, regardless of whether clicking it * would currently do anything - so this has to track "is there * actually a next" the same way the in-page button does, not just be * set once and left there. */ export function setMediaSessionNextAction(onNext: (() => void) | null): void { if (!('mediaSession' in navigator)) return; try { navigator.mediaSession.setActionHandler('nexttrack', onNext); } catch { // An unrecognized action name on an older browser - safe to skip. } } /** Drops the OS's own now-playing card without touching the play/pause/ * seek handlers underneath it - for "still in the party, just nothing * queued right now" (MusicBar.ts's own `!music.current`), where those * handlers stay meaningful (playback can resume the moment a track * does) and so shouldn't need re-wiring the instant one does. `null`, * not an empty MediaMetadata - that's the documented way to tell the * OS there's genuinely nothing loaded, not a track with a blank title. */ export function setMediaSessionIdle(): void { if (!('mediaSession' in navigator)) return; navigator.mediaSession.metadata = null; navigator.mediaSession.playbackState = 'none'; } /** Called when leaving a party/party page entirely - an OS-level "now * playing" card that outlives the actual party would be actively * wrong, not just stale, so this clears every piece the functions * above ever set (not just the now-playing card - see * setMediaSessionIdle above for that alone) rather than leaving it to * be silently overwritten by whatever starts next. */ export function clearMediaSession(): void { if (!('mediaSession' in navigator)) return; setMediaSessionIdle(); try { navigator.mediaSession.setActionHandler('play', null); navigator.mediaSession.setActionHandler('pause', null); navigator.mediaSession.setActionHandler('nexttrack', null); navigator.mediaSession.setActionHandler('seekto', null); } catch { // Same tolerance as setMediaSessionNextAction above. } }