watchparty/client/src/mediaSession.ts
Nicolas 6582ad855b
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
Link video/music playback to the OS own media controls (Windows SMTC, macOS Now Playing, ...)
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>
2026-09-17 23:41:39 +02:00

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.
}
}