Compare commits
No commits in common. "071184e3c68417dec7c9efe11114c9ab1aeb6466" and "9cb25f4dc091fc6c960561974d110c46bd7cdb64" have entirely different histories.
071184e3c6
...
9cb25f4dc0
5 changed files with 11 additions and 286 deletions
|
|
@ -392,9 +392,6 @@ 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)
|
||||||
|
|
|
||||||
|
|
@ -1,123 +0,0 @@
|
||||||
// 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,7 +10,6 @@ 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
|
||||||
|
|
@ -163,18 +162,14 @@ 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));
|
||||||
seekTo(fraction * currentDurationSeconds);
|
const seconds = fraction * currentDurationSeconds;
|
||||||
|
activeDriver!.currentTime = seconds;
|
||||||
|
updatePositionDisplay();
|
||||||
|
conn?.seekMusic(seconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
trackProgress.addEventListener('pointerdown', (e) => {
|
trackProgress.addEventListener('pointerdown', (e) => {
|
||||||
|
|
@ -189,15 +184,6 @@ 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
|
||||||
|
|
@ -233,7 +219,6 @@ 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -298,7 +283,6 @@ export function leaveMusicParty(): void {
|
||||||
currentItemId = null;
|
currentItemId = null;
|
||||||
lastMusicState = null;
|
lastMusicState = null;
|
||||||
latestMembers = [];
|
latestMembers = [];
|
||||||
clearMediaSession();
|
|
||||||
|
|
||||||
conn?.leave();
|
conn?.leave();
|
||||||
conn = null;
|
conn = null;
|
||||||
|
|
@ -352,7 +336,6 @@ 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
|
||||||
|
|
@ -446,10 +429,6 @@ 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) => {
|
||||||
|
|
@ -559,11 +538,6 @@ 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
|
||||||
|
|
@ -587,7 +561,6 @@ 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,7 +13,6 @@ 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;
|
||||||
|
|
@ -415,7 +414,6 @@ 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
|
||||||
|
|
@ -439,7 +437,6 @@ 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -520,23 +517,13 @@ 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));
|
||||||
seekTo(fraction * duration);
|
activeDriver.currentTime = 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
|
||||||
|
|
@ -675,7 +662,6 @@ 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();
|
||||||
|
|
@ -1052,10 +1038,8 @@ 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
|
||||||
|
|
@ -1203,42 +1187,22 @@ 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.
|
||||||
function requestPause() {
|
requestPauseBtn.addEventListener('click', () => {
|
||||||
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();
|
||||||
}
|
});
|
||||||
|
|
||||||
function requestResume() {
|
requestResumeBtn.addEventListener('click', () => {
|
||||||
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);
|
||||||
|
|
@ -1281,7 +1245,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
nextBtn.addEventListener('click', goToNextItem);
|
nextBtn.addEventListener('click', () => { conn?.next(); });
|
||||||
(root.querySelector('#leaveBtn') as HTMLButtonElement).addEventListener('click', () => navigate('/home'));
|
(root.querySelector('#leaveBtn') as HTMLButtonElement).addEventListener('click', () => navigate('/home'));
|
||||||
|
|
||||||
init();
|
init();
|
||||||
|
|
@ -1296,7 +1260,6 @@ 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,91 +4330,6 @@ 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