New mediaSession.ts wraps the standard Media Session API - shared by Player.ts and MusicBar.ts, which can never both be active at once so there is no risk of them fighting over this one global. Metadata/ position/playback-state hook into each players own existing single source of truth (updatePlayPauseBtn/updateProgressBar, setPlayIcon/ updatePositionDisplay) instead of adding parallel call sites. Play/ pause/seek are wired once and read isHost/conn fresh on every OS-triggered call, so they keep working through a live host handoff; a non-host play/pause routes through the same requestPause/ requestResume the request toolbar already uses, and seek is gated on isHost the same way the in-page seek bar already is. Next is handled separately since a registered nexttrack handler is what makes the OS show that control at all - its presence tracks whatever the in-page equivalent already tracks (isHost && hasNextQueued for video, a vote skip for music). Cleared entirely on leaving a party/page, or just to idle (metadata/state only) when a listen partys queue runs empty but the party itself is still open. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
123 lines
5.8 KiB
TypeScript
123 lines
5.8 KiB
TypeScript
// 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.
|
|
}
|
|
}
|