Merge pull request 'Link video/music playback to the OS's own media controls (Windows SMTC, macOS Now Playing, ...)' (#22) from feature/media-session-integration into master
All checks were successful
Publier l'image Docker / test (push) Successful in 27s
Publier l'image Docker / publish (push) Successful in 8s

This commit is contained in:
kyuno 2026-09-17 23:43:32 +02:00
commit 071184e3c6
5 changed files with 286 additions and 11 deletions

View file

@ -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 - [x] A mobile banner nudging an install of the PWA — the real
Chrome/Android install prompt, manual instructions on iOS Safari Chrome/Android install prompt, manual instructions on iOS Safari
(no such prompt exists there) — see docs/architecture.md (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 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 - [ ] Try casting to a real Chromecast (only the SDK's own bootstrap is
confirmed here — no real device/session) confirmed here — no real device/session)

123
client/src/mediaSession.ts Normal file
View file

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

View file

@ -10,6 +10,7 @@ import { showToast } from '../toast';
import type { MusicCurrentPublic, MusicPublicState, PartyPublicState, SocketMember } from '../types'; import type { MusicCurrentPublic, MusicPublicState, PartyPublicState, SocketMember } from '../types';
import { initCast, localDriver, onCastAvailabilityChanged, onCastSessionEnded, startCast, stopCast } from '../cast'; import { initCast, localDriver, onCastAvailabilityChanged, onCastSessionEnded, startCast, stopCast } from '../cast';
import type { PlaybackDriver } 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 * 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. // as Player.ts's progressBar.
let isDraggingProgress = false; 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 { function seekFromPointerEvent(e: PointerEvent): void {
if (currentDurationSeconds <= 0) return; if (currentDurationSeconds <= 0) return;
const rect = trackProgress.getBoundingClientRect(); const rect = trackProgress.getBoundingClientRect();
const fraction = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width)); const fraction = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
const seconds = fraction * currentDurationSeconds; seekTo(fraction * currentDurationSeconds);
activeDriver!.currentTime = seconds;
updatePositionDisplay();
conn?.seekMusic(seconds);
} }
trackProgress.addEventListener('pointerdown', (e) => { trackProgress.addEventListener('pointerdown', (e) => {
@ -184,6 +189,15 @@ export function initMusicBar(root: HTMLElement): void {
trackProgress.addEventListener('pointerup', () => { isDraggingProgress = false; }); trackProgress.addEventListener('pointerup', () => { isDraggingProgress = false; });
trackProgress.addEventListener('pointercancel', () => { 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 // 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 // gated on anything party-related, same as Player.ts's own castBtn
// (see its comment: casting is available to any member, and this bar // (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 // than its own pointer-seek wiring: dragging is one tap (expand the
// panel) away, see #mobileMiniBar's own click handler. // panel) away, see #mobileMiniBar's own click handler.
(barEl!.querySelector('#miniProgressFill') as HTMLElement).style.width = percent; (barEl!.querySelector('#miniProgressFill') as HTMLElement).style.width = percent;
setMediaSessionPositionState(currentDurationSeconds, currentTime);
} }
/** /**
@ -283,6 +298,7 @@ export function leaveMusicParty(): void {
currentItemId = null; currentItemId = null;
lastMusicState = null; lastMusicState = null;
latestMembers = []; latestMembers = [];
clearMediaSession();
conn?.leave(); conn?.leave();
conn = null; conn = null;
@ -336,6 +352,7 @@ function setPlayIcon(isPlaying: boolean): void {
const icon = isPlaying ? PAUSE_ICON_SVG : PLAY_ICON_SVG; const icon = isPlaying ? PAUSE_ICON_SVG : PLAY_ICON_SVG;
(barEl!.querySelector('#playPauseBtn') as HTMLButtonElement).innerHTML = icon; (barEl!.querySelector('#playPauseBtn') as HTMLButtonElement).innerHTML = icon;
(barEl!.querySelector('#miniPlayPauseBtn') as HTMLButtonElement).innerHTML = icon; (barEl!.querySelector('#miniPlayPauseBtn') as HTMLButtonElement).innerHTML = icon;
setMediaSessionPlaybackState(isPlaying);
} }
/** Same pairing as setPlayIcon above, for the track metadata shown in /** 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; currentItemId = track.itemId;
currentDurationSeconds = track.durationSeconds || 0; currentDurationSeconds = track.durationSeconds || 0;
setTrackMeta(track.itemImageUrl || '', track.itemName || '', [track.artistName, track.albumName].filter(Boolean).join(' - ')); 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) { if (isCasting) {
castTrack([track.itemName, track.artistName].filter(Boolean).join(' - ')).catch((err) => { 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. // "disable, don't just silently no-op" treatment as playPauseBtn above.
castBtn.disabled = !music.current && !isCasting; castBtn.disabled = !music.current && !isCasting;
updateVoteSkipLabel(); 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 // Its own .queueRow, NOT views/album's shared .trackRow (see
// styles/trackList.css) - that one is a CSS grid whose 4 columns // styles/trackList.css) - that one is a CSS grid whose 4 columns
@ -561,6 +587,7 @@ function applyMusicState(music: MusicPublicState): void {
if (!music.current) { if (!music.current) {
currentItemId = null; currentItemId = null;
setTrackMeta('', 'Rien ne joue pour le moment.', ''); setTrackMeta('', 'Rien ne joue pour le moment.', '');
setMediaSessionIdle();
if (hls) { hls.destroy(); hls = null; } if (hls) { hls.destroy(); hls = null; }
activeDriver!.pause(); activeDriver!.pause();
audio.removeAttribute('src'); // harmless no-op while casting - the local element is already srcless then, see startCasting audio.removeAttribute('src'); // harmless no-op while casting - the local element is already srcless then, see startCasting

View file

@ -13,6 +13,7 @@ import type { BurnInChangedMsg, ItemChangedMsg, PartyPublicState, PauseRequested
import { joinMusicParty, leaveMusicParty } from '../../musicBar/MusicBar'; import { joinMusicParty, leaveMusicParty } from '../../musicBar/MusicBar';
import { initCast, localDriver, onCastAvailabilityChanged, onCastSessionEnded, startCast, stopCast } from '../../cast'; import { initCast, localDriver, onCastAvailabilityChanged, onCastSessionEnded, startCast, stopCast } from '../../cast';
import type { PlaybackDriver } 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 TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns
const DRIFT_TOLERANCE_SECONDS = 1.5; const DRIFT_TOLERANCE_SECONDS = 1.5;
@ -414,6 +415,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
* onDriverStateChanged below. */ * onDriverStateChanged below. */
function updatePlayPauseBtn() { function updatePlayPauseBtn() {
playPauseBtn.innerHTML = activeDriver.paused ? PLAY_BTN_ICON_SVG : PAUSE_BTN_ICON_SVG; playPauseBtn.innerHTML = activeDriver.paused ? PLAY_BTN_ICON_SVG : PAUSE_BTN_ICON_SVG;
setMediaSessionPlaybackState(!activeDriver.paused);
} }
/** #muteBtn - deliberately reads/writes `video.muted` directly, never /** #muteBtn - deliberately reads/writes `video.muted` directly, never
@ -437,6 +439,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
const current = activeDriver.currentTime; const current = activeDriver.currentTime;
progressFill.style.width = duration > 0 ? `${Math.min(100, (current / duration) * 100)}%` : '0%'; progressFill.style.width = duration > 0 ? `${Math.min(100, (current / duration) * 100)}%` : '0%';
timeLabel.textContent = duration > 0 ? `${formatDuration(current)} / ${formatDuration(duration)}` : ''; 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; 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) { function seekFromPointerEvent(e: PointerEvent) {
const duration = activeDriver.duration; const duration = activeDriver.duration;
if (!duration) return; if (!duration) return;
const rect = progressBar.getBoundingClientRect(); const rect = progressBar.getBoundingClientRect();
const fraction = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width)); const fraction = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
activeDriver.currentTime = fraction * duration; seekTo(fraction * duration);
onDriverStateChanged();
} }
// Host-only (checked here, not just via the readOnly class - that one // 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'); settingsWrap.classList.remove('hide');
requestToolbar.classList.add('hide'); // the host acts directly - no requests to make once promoted mid-session requestToolbar.classList.add('hide'); // the host acts directly - no requests to make once promoted mid-session
nextBtn.classList.toggle('hide', !hasNextQueued); nextBtn.classList.toggle('hide', !hasNextQueued);
setMediaSessionNextAction(hasNextQueued ? goToNextItem : null);
playPauseBtn.classList.remove('hide'); playPauseBtn.classList.remove('hide');
progressBar.classList.remove('readOnly'); progressBar.classList.remove('readOnly');
updateAmbientNotice(); updateAmbientNotice();
@ -1038,8 +1052,10 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
async function onItemChanged(msg: ItemChangedMsg) { async function onItemChanged(msg: ItemChangedMsg) {
itemTitleEl.textContent = msg.itemName || ''; itemTitleEl.textContent = msg.itemName || '';
setMediaSessionMetadata(msg.itemName || '', '', '', `/api/catalog/image/${msg.itemId}`);
hasNextQueued = !!msg.hasNext; hasNextQueued = !!msg.hasNext;
nextBtn.classList.toggle('hide', !(isHost && hasNextQueued)); nextBtn.classList.toggle('hide', !(isHost && hasNextQueued));
setMediaSessionNextAction(isHost && hasNextQueued ? goToNextItem : null);
// A new item's own stream indices/subtitle pick have nothing to do // A new item's own stream indices/subtitle pick have nothing to do
// with the old one's - PartyService.advanceQueue already resets this // with the old one's - PartyService.advanceQueue already resets this
// server-side too (see its own comment); this just keeps this client // 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 // cooldown (mirroring the server's own independent per-request
// throttle in SocketGateway) purely so a rapid double-click doesn't // throttle in SocketGateway) purely so a rapid double-click doesn't
// even bother emitting the second request. // even bother emitting the second request.
requestPauseBtn.addEventListener('click', () => { function requestPause() {
const now = Date.now(); const now = Date.now();
if (now < pauseRequestCooldownUntil || !conn) return; if (now < pauseRequestCooldownUntil || !conn) return;
pauseRequestCooldownUntil = now + REQUEST_COOLDOWN_MS; pauseRequestCooldownUntil = now + REQUEST_COOLDOWN_MS;
conn.requestPause(); conn.requestPause();
}); }
requestResumeBtn.addEventListener('click', () => { function requestResume() {
const now = Date.now(); const now = Date.now();
if (now < resumeRequestCooldownUntil || !conn) return; if (now < resumeRequestCooldownUntil || !conn) return;
resumeRequestCooldownUntil = now + REQUEST_COOLDOWN_MS; resumeRequestCooldownUntil = now + REQUEST_COOLDOWN_MS;
conn.requestResume(); conn.requestResume();
}); }
requestPauseBtn.addEventListener('click', requestPause);
requestResumeBtn.addEventListener('click', requestResume);
function goToNextItem() {
conn?.next();
}
async function init() { async function init() {
itemTitleEl.textContent = party.itemName || ''; 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; hasNextQueued = !!party.hasNext;
updateAmbientNotice(); updateAmbientNotice();
requestToolbar.classList.toggle('hide', party.youAreHost); 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')); (root.querySelector('#leaveBtn') as HTMLButtonElement).addEventListener('click', () => navigate('/home'));
init(); init();
@ -1260,6 +1296,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
if (requestBannerTimer) clearTimeout(requestBannerTimer); if (requestBannerTimer) clearTimeout(requestBannerTimer);
clearControlsHideTimer(); clearControlsHideTimer();
if (hls) hls.destroy(); if (hls) hls.destroy();
clearMediaSession();
document.removeEventListener('click', onDocumentClick); document.removeEventListener('click', onDocumentClick);
document.removeEventListener('fullscreenchange', onFullscreenChange); document.removeEventListener('fullscreenchange', onFullscreenChange);
playerWrap.removeEventListener('pointermove', showControls); playerWrap.removeEventListener('pointermove', showControls);

View file

@ -4330,6 +4330,91 @@ iOS-flavored copy with no install button. A same-event dispatch into a
real ≥900px-wide `<iframe>` confirmed the banner never renders there real ≥900px-wide `<iframe>` confirmed the banner never renders there
at all, regardless of the event firing. at all, regardless of the event firing.
## OS-level media controls (Windows' SMTC, macOS' Now Playing, ...)
Asked for directly: linking whatever's playing to Windows' own media
popup (the one media keys/the volume flyout show) - the standard
Media Session API (`navigator.mediaSession`), which the same code path
also surfaces to macOS' Now Playing widget and Android's notification
for free, on whichever OS/browser combination actually implements it
(Chromium family; Firefox has never implemented it at all).
New `mediaSession.ts`, a thin wrapper - every export is a one-line
`'mediaSession' in navigator` guard around the exact browser call, not
a new abstraction of its own - shared by `views/player/Player.ts`
(video) and `musicBar/MusicBar.ts` (a listen party), which can never
both be active at once (starting/joining either already leaves the
other first - see docs above), so there's no risk of the two fighting
over this one global.
**Metadata/position/playback state** hook into each player's own
existing single-source-of-truth functions rather than adding parallel
call sites everywhere: Player.ts's `updatePlayPauseBtn`/
`updateProgressBar` (already the one place `#playPauseBtn`'s icon and
the seek bar's fill/label get synced, from every driver-changed event,
the heartbeat, and the ticker alike) also call
`setMediaSessionPlaybackState`/`setMediaSessionPositionState` now;
MusicBar.ts's `setPlayIcon`/`updatePositionDisplay` do the same. Title/
artwork update at both places an item/track actually changes
(Player.ts's `init`/`onItemChanged`, MusicBar.ts's `loadTrack`) -
artist/album are left empty for video (meaningless there, same as this
app's own UI already treats them).
**Play/pause/seek** are wired once per party (not re-wired on state
changes) via `setMediaSessionActionHandlers` - the handler functions
themselves close over local state (`isHost`, `conn`, ...) and read it
fresh on every OS-triggered call, the same way any other closure in
either file already does, so a handler set at party-join time keeps
working correctly straight through a live host handoff without needing
to know that happened. Two real permission models, unchanged by this:
video's play/pause is host-only - a non-host's OS-level play/pause
routes to the exact same `requestPause`/`requestResume` (now factored
out so both the in-page buttons and this can call them) the request
toolbar already uses, never touching the video directly - and seeking
is gated on `isHost` the same way the in-page seek bar already is
(factored into a shared `seekTo`, reused by both the pointer handler
and this). Music has neither gate (no host concept at all - see
MusicQueueService's own reasoning), so its handlers act directly.
**"Next"** is handled differently from play/pause/seek because a
*registered* `nexttrack` handler is what makes the OS show that
control at all, regardless of whether clicking it would currently do
anything - so unlike the other three, its presence has to track
whatever the in-page equivalent's own visibility already tracks, via a
separate `setMediaSessionNextAction(fn | null)` called alongside it:
Player.ts's `#nextBtn` (`isHost && hasNextQueued`, in `becomeHost` and
`onItemChanged`) and MusicBar.ts's vote-skip button (`music.current`,
in `applyMusicState` - clicking the OS's own "next" casts one skip
vote, same as the in-page button, not an instant skip).
**Clearing**: `clearMediaSession()` (metadata, playback state, *and*
every action handler) runs on Player.ts's route cleanup and
MusicBar.ts's `leaveMusicParty` - an OS-level "now playing" card that
outlives the actual party would be actively wrong, not stale. A
lighter `setMediaSessionIdle()` (metadata/state only, action handlers
left alone) covers MusicBar.ts's own "queue ran empty but still in the
party" case instead - play/pause/seek stay meaningful the instant
something plays again, so there's nothing to re-wire there.
Verified: full `typecheck`/`build` (client only). `mediaSession.ts`
itself checked directly against the real API in a real Chromium
browser (this sandbox's own): metadata resolves to a genuine
`MediaMetadata` with the right fields and a resolved artwork URL (or
none for an empty one), playback state and position state both apply
without throwing, an invalid (non-positive/`NaN`) duration is silently
skipped rather than reaching the browser call that would throw on it,
action-handler registration doesn't throw with or without `onSeek`
supplied, and both `setMediaSessionIdle`/`clearMediaSession` reset
metadata to `null` and playback state to `'none'`. Player.ts's/
MusicBar.ts's own wiring is one-line calls into already-verified
functions at points each file already treats as its single source of
truth for that piece of state (see above) - not independently
exercised end to end against a live party (would need mocking the
entire socket/party layer for no more real signal than reading the
call sites already gives), and the actual OS-level popup itself - the
whole point - isn't something this sandbox can display or click
regardless. Worth a real Windows/Chrome check once deployed.
## Known limitations ## Known limitations
- Playback always goes through `hls.js` against whatever Emby's - Playback always goes through `hls.js` against whatever Emby's