From 6582ad855be4b7fefa109cad9191381b1842b28e Mon Sep 17 00:00:00 2001 From: Nicolas Date: Thu, 17 Sep 2026 23:41:39 +0200 Subject: [PATCH] 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 --- README.md | 3 + client/src/mediaSession.ts | 123 ++++++++++++++++++++++++++++++ client/src/musicBar/MusicBar.ts | 35 ++++++++- client/src/views/player/Player.ts | 51 +++++++++++-- docs/architecture.md | 85 +++++++++++++++++++++ 5 files changed, 286 insertions(+), 11 deletions(-) create mode 100644 client/src/mediaSession.ts diff --git a/README.md b/README.md index e862c07..f6fbb57 100644 --- a/README.md +++ b/README.md @@ -392,6 +392,9 @@ Then put it behind your existing reverse proxy like everything else - [x] A mobile banner nudging an install of the PWA — the real Chrome/Android install prompt, manual instructions on iOS Safari (no such prompt exists there) — see docs/architecture.md +- [x] Linked whatever's playing (video or music) to the OS's own media + controls (Windows' SMTC, macOS' Now Playing, ...) — title/artwork, + play/pause/seek/next — see docs/architecture.md - [ ] Try a full party end-to-end with two real accounts (still the main gap) - [ ] Try casting to a real Chromecast (only the SDK's own bootstrap is confirmed here — no real device/session) diff --git a/client/src/mediaSession.ts b/client/src/mediaSession.ts new file mode 100644 index 0000000..c511072 --- /dev/null +++ b/client/src/mediaSession.ts @@ -0,0 +1,123 @@ +// 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. + } +} diff --git a/client/src/musicBar/MusicBar.ts b/client/src/musicBar/MusicBar.ts index f71a039..bf228e2 100644 --- a/client/src/musicBar/MusicBar.ts +++ b/client/src/musicBar/MusicBar.ts @@ -10,6 +10,7 @@ import { showToast } from '../toast'; import type { MusicCurrentPublic, MusicPublicState, PartyPublicState, SocketMember } from '../types'; import { initCast, localDriver, onCastAvailabilityChanged, onCastSessionEnded, startCast, stopCast } from '../cast'; import type { PlaybackDriver } from '../cast'; +import { clearMediaSession, setMediaSessionActionHandlers, setMediaSessionIdle, setMediaSessionMetadata, setMediaSessionNextAction, setMediaSessionPlaybackState, setMediaSessionPositionState } from '../mediaSession'; /** * Persistent, app-lifetime "listen party" bar - mounted ONCE by Shell.ts @@ -162,14 +163,18 @@ export function initMusicBar(root: HTMLElement): void { // as Player.ts's progressBar. let isDraggingProgress = false; + function seekTo(seconds: number): void { + if (currentDurationSeconds <= 0) return; + activeDriver!.currentTime = Math.min(Math.max(0, seconds), currentDurationSeconds); + updatePositionDisplay(); + conn?.seekMusic(activeDriver!.currentTime); + } + function seekFromPointerEvent(e: PointerEvent): void { if (currentDurationSeconds <= 0) return; const rect = trackProgress.getBoundingClientRect(); const fraction = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width)); - const seconds = fraction * currentDurationSeconds; - activeDriver!.currentTime = seconds; - updatePositionDisplay(); - conn?.seekMusic(seconds); + seekTo(fraction * currentDurationSeconds); } trackProgress.addEventListener('pointerdown', (e) => { @@ -184,6 +189,15 @@ export function initMusicBar(root: HTMLElement): void { trackProgress.addEventListener('pointerup', () => { isDraggingProgress = false; }); trackProgress.addEventListener('pointercancel', () => { isDraggingProgress = false; }); + // Wired once, like Player.ts's own equivalent - no host concept here + // (see activeDriver's own comment) so, unlike there, play/pause never + // need to branch into a request-instead-of-act path. + setMediaSessionActionHandlers({ + onPlay: () => conn?.setMusicPaused(false), + onPause: () => conn?.setMusicPaused(true), + onSeek: seekTo + }); + // Visible to any member the moment a Chromecast is on the network - not // gated on anything party-related, same as Player.ts's own castBtn // (see its comment: casting is available to any member, and this bar @@ -219,6 +233,7 @@ function updatePositionDisplay(): void { // than its own pointer-seek wiring: dragging is one tap (expand the // panel) away, see #mobileMiniBar's own click handler. (barEl!.querySelector('#miniProgressFill') as HTMLElement).style.width = percent; + setMediaSessionPositionState(currentDurationSeconds, currentTime); } /** @@ -283,6 +298,7 @@ export function leaveMusicParty(): void { currentItemId = null; lastMusicState = null; latestMembers = []; + clearMediaSession(); conn?.leave(); conn = null; @@ -336,6 +352,7 @@ function setPlayIcon(isPlaying: boolean): void { const icon = isPlaying ? PAUSE_ICON_SVG : PLAY_ICON_SVG; (barEl!.querySelector('#playPauseBtn') as HTMLButtonElement).innerHTML = icon; (barEl!.querySelector('#miniPlayPauseBtn') as HTMLButtonElement).innerHTML = icon; + setMediaSessionPlaybackState(isPlaying); } /** Same pairing as setPlayIcon above, for the track metadata shown in @@ -429,6 +446,10 @@ function loadTrack(track: MusicCurrentPublic, elapsedSeconds: number, isPaused: currentItemId = track.itemId; currentDurationSeconds = track.durationSeconds || 0; setTrackMeta(track.itemImageUrl || '', track.itemName || '', [track.artistName, track.albumName].filter(Boolean).join(' - ')); + // Artist/album kept separate here, unlike setTrackMeta's own combined + // display line above - the OS's own now-playing UI has real fields for + // both, no need to pre-join them into one string for it. + setMediaSessionMetadata(track.itemName || '', track.artistName || '', track.albumName || '', track.itemImageUrl || ''); if (isCasting) { castTrack([track.itemName, track.artistName].filter(Boolean).join(' - ')).catch((err) => { @@ -538,6 +559,11 @@ function applyMusicState(music: MusicPublicState): void { // "disable, don't just silently no-op" treatment as playPauseBtn above. castBtn.disabled = !music.current && !isCasting; updateVoteSkipLabel(); + // A *registered* nexttrack handler is what makes the OS show the + // control at all (see setMediaSessionNextAction's own comment) - same + // "disable, don't just silently no-op" case as playPauseBtn/castBtn + // above, just with no disabled state of its own to set instead. + setMediaSessionNextAction(music.current ? () => conn?.voteSkip() : null); // Its own .queueRow, NOT views/album's shared .trackRow (see // styles/trackList.css) - that one is a CSS grid whose 4 columns @@ -561,6 +587,7 @@ function applyMusicState(music: MusicPublicState): void { if (!music.current) { currentItemId = null; setTrackMeta('', 'Rien ne joue pour le moment.', ''); + setMediaSessionIdle(); if (hls) { hls.destroy(); hls = null; } activeDriver!.pause(); audio.removeAttribute('src'); // harmless no-op while casting - the local element is already srcless then, see startCasting diff --git a/client/src/views/player/Player.ts b/client/src/views/player/Player.ts index b0ab655..a380a01 100644 --- a/client/src/views/player/Player.ts +++ b/client/src/views/player/Player.ts @@ -13,6 +13,7 @@ import type { BurnInChangedMsg, ItemChangedMsg, PartyPublicState, PauseRequested import { joinMusicParty, leaveMusicParty } from '../../musicBar/MusicBar'; import { initCast, localDriver, onCastAvailabilityChanged, onCastSessionEnded, startCast, stopCast } from '../../cast'; import type { PlaybackDriver } from '../../cast'; +import { clearMediaSession, setMediaSessionActionHandlers, setMediaSessionMetadata, setMediaSessionNextAction, setMediaSessionPlaybackState, setMediaSessionPositionState } from '../../mediaSession'; const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns const DRIFT_TOLERANCE_SECONDS = 1.5; @@ -414,6 +415,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli * onDriverStateChanged below. */ function updatePlayPauseBtn() { playPauseBtn.innerHTML = activeDriver.paused ? PLAY_BTN_ICON_SVG : PAUSE_BTN_ICON_SVG; + setMediaSessionPlaybackState(!activeDriver.paused); } /** #muteBtn - deliberately reads/writes `video.muted` directly, never @@ -437,6 +439,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli const current = activeDriver.currentTime; progressFill.style.width = duration > 0 ? `${Math.min(100, (current / duration) * 100)}%` : '0%'; timeLabel.textContent = duration > 0 ? `${formatDuration(current)} / ${formatDuration(duration)}` : ''; + setMediaSessionPositionState(duration, current); } /** @@ -517,13 +520,23 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli let isDraggingProgress = false; + /** Host-only in effect (both call sites already gate on isHost before + * reaching here - the progress bar's own pointerdown handler below, + * and the Media Session seekto action) - this itself just clamps and + * applies, same shape as MusicBar.ts's own seekTo. */ + function seekTo(seconds: number) { + const duration = activeDriver.duration; + if (!duration) return; + activeDriver.currentTime = Math.min(Math.max(0, seconds), duration); + onDriverStateChanged(); + } + function seekFromPointerEvent(e: PointerEvent) { const duration = activeDriver.duration; if (!duration) return; const rect = progressBar.getBoundingClientRect(); const fraction = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width)); - activeDriver.currentTime = fraction * duration; - onDriverStateChanged(); + seekTo(fraction * duration); } // Host-only (checked here, not just via the readOnly class - that one @@ -662,6 +675,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli settingsWrap.classList.remove('hide'); requestToolbar.classList.add('hide'); // the host acts directly - no requests to make once promoted mid-session nextBtn.classList.toggle('hide', !hasNextQueued); + setMediaSessionNextAction(hasNextQueued ? goToNextItem : null); playPauseBtn.classList.remove('hide'); progressBar.classList.remove('readOnly'); updateAmbientNotice(); @@ -1038,8 +1052,10 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli async function onItemChanged(msg: ItemChangedMsg) { itemTitleEl.textContent = msg.itemName || ''; + setMediaSessionMetadata(msg.itemName || '', '', '', `/api/catalog/image/${msg.itemId}`); hasNextQueued = !!msg.hasNext; nextBtn.classList.toggle('hide', !(isHost && hasNextQueued)); + setMediaSessionNextAction(isHost && hasNextQueued ? goToNextItem : null); // A new item's own stream indices/subtitle pick have nothing to do // with the old one's - PartyService.advanceQueue already resets this // server-side too (see its own comment); this just keeps this client @@ -1187,22 +1203,42 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli // cooldown (mirroring the server's own independent per-request // throttle in SocketGateway) purely so a rapid double-click doesn't // even bother emitting the second request. - requestPauseBtn.addEventListener('click', () => { + function requestPause() { const now = Date.now(); if (now < pauseRequestCooldownUntil || !conn) return; pauseRequestCooldownUntil = now + REQUEST_COOLDOWN_MS; conn.requestPause(); - }); + } - requestResumeBtn.addEventListener('click', () => { + function requestResume() { const now = Date.now(); if (now < resumeRequestCooldownUntil || !conn) return; resumeRequestCooldownUntil = now + REQUEST_COOLDOWN_MS; conn.requestResume(); - }); + } + + requestPauseBtn.addEventListener('click', requestPause); + requestResumeBtn.addEventListener('click', requestResume); + + function goToNextItem() { + conn?.next(); + } async function init() { itemTitleEl.textContent = party.itemName || ''; + setMediaSessionMetadata(party.itemName || '', '', '', `/api/catalog/image/${party.itemId}`); + // Play/pause/seek are wired exactly once - see setMediaSessionActionHandlers' + // own comment for why they never need re-wiring on a host handoff. + // "next" isn't included here: unlike play/pause (always meaningful, + // just routed differently for a follower - see requestPause/Resume + // above), a *registered* nexttrack handler is what makes Windows show + // the control at all, so it has to track hasNextQueued/isHost the + // same way #nextBtn's own visibility does - see becomeHost/onItemChanged. + setMediaSessionActionHandlers({ + onPlay: () => { if (isHost) activeDriver.play(); else requestResume(); }, + onPause: () => { if (isHost) activeDriver.pause(); else requestPause(); }, + onSeek: (seconds) => { if (isHost) seekTo(seconds); } + }); hasNextQueued = !!party.hasNext; updateAmbientNotice(); requestToolbar.classList.toggle('hide', party.youAreHost); @@ -1245,7 +1281,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli }); } - nextBtn.addEventListener('click', () => { conn?.next(); }); + nextBtn.addEventListener('click', goToNextItem); (root.querySelector('#leaveBtn') as HTMLButtonElement).addEventListener('click', () => navigate('/home')); init(); @@ -1260,6 +1296,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli if (requestBannerTimer) clearTimeout(requestBannerTimer); clearControlsHideTimer(); if (hls) hls.destroy(); + clearMediaSession(); document.removeEventListener('click', onDocumentClick); document.removeEventListener('fullscreenchange', onFullscreenChange); playerWrap.removeEventListener('pointermove', showControls); diff --git a/docs/architecture.md b/docs/architecture.md index 2f2969c..e491416 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4330,6 +4330,91 @@ iOS-flavored copy with no install button. A same-event dispatch into a real ≥900px-wide `