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>
This commit is contained in:
parent
9cb25f4dc0
commit
6582ad855b
5 changed files with 286 additions and 11 deletions
|
|
@ -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
123
client/src/mediaSession.ts
Normal 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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue