Cast a listen party too, and fix a slow/freezing catalog #1

Merged
kyuno merged 2 commits from feedback/queue-cast-catalog-perf into master 2026-09-15 22:39:48 +02:00
10 changed files with 473 additions and 80 deletions

View file

@ -5,6 +5,9 @@
# - push sur `master` -> tags `latest` + `<sha court>` # - push sur `master` -> tags `latest` + `<sha court>`
# - push d'un tag `vX.Y.Z` -> tags `X.Y.Z`, `X.Y`, `latest` # - push d'un tag `vX.Y.Z` -> tags `X.Y.Z`, `X.Y`, `latest`
# - déclenchement manuel possible (Actions -> Run workflow) # - déclenchement manuel possible (Actions -> Run workflow)
# - PR vers `master` -> job `test` seul (voir `if` du job `publish` -
# sans ça, une PR depuis une branche non fusionnée pousserait quand même
# une vraie image sur le registre, `latest` compris)
# #
# Repris de mediaDrop (.forgejo/workflows/publish-docker.yml) - mêmes # Repris de mediaDrop (.forgejo/workflows/publish-docker.yml) - mêmes
# prérequis côté instance : # prérequis côté instance :
@ -31,6 +34,8 @@ on:
push: push:
branches: [master] branches: [master]
tags: ['v*'] tags: ['v*']
pull_request:
branches: [master]
workflow_dispatch: workflow_dispatch:
permissions: permissions:
@ -85,6 +90,7 @@ jobs:
publish: publish:
needs: test needs: test
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: docker:27-cli image: docker:27-cli

View file

@ -132,14 +132,15 @@ assumes, how a real transcode's seek handling feels once several
viewers depend on the exact same one). viewers depend on the exact same one).
Casting to a Chromecast (see docs/architecture.md) is the newest, Casting to a Chromecast (see docs/architecture.md) is the newest,
least-verified piece: the security-critical parts were checked against least-verified piece, for video and music alike: the security-critical
the real running server directly (the party-stream/burn routes reject a parts were checked against the real running server directly (the
request with no auth exactly as before, and now also reject an invalid party-stream/burn routes reject a request with no auth exactly as
`castToken`, both a plain 401) and the Cast SDK itself was confirmed to before, and now also reject an invalid `castToken`, both a plain 401)
load and initialize in a real browser, but there's neither a real and the Cast SDK itself was confirmed to load and initialize in a real
Chromecast nor Emby login credentials in this environment to take it browser, but there's neither a real Chromecast nor Emby login
any further - the actual on-TV playback and the cast button's own credentials in this environment to take it any further - the actual
live behavior are unconfirmed. on-TV playback, the cast button's own live behavior, and (for music) a
re-cast actually surviving a real track change are all unconfirmed.
## Project layout ## Project layout
@ -301,13 +302,21 @@ Then put it behind your existing reverse proxy like everything else
- [x] Fixed catalog browsing feeling slow: image thumbnails were never - [x] Fixed catalog browsing feeling slow: image thumbnails were never
cacheable (Emby's own Cache-Control/ETag were silently dropped) — cacheable (Emby's own Cache-Control/ETag were silently dropped) —
see docs/architecture.md see docs/architecture.md
- [x] Cast a party's video to a Chromecast (Default Media Receiver), - [x] Cast a party's video *or* listen-party music to a Chromecast
with a play/pause/seek remote on the sender's own screen — see (Default Media Receiver), with a play/pause/seek remote on the
docs/architecture.md sender's own screen — music reuses the same `PlaybackDriver`/
`CastTokenService` the video case introduced, and re-casts
in place on every track change instead of dropping the session
the way an item change does for video — see docs/architecture.md
- [x] Fixed the listen-party queue's broken layout (it was reusing an - [x] Fixed the listen-party queue's broken layout (it was reusing an
album page's row grid) and its missing album art (a track almost album page's row grid), its missing album art (a track almost
never has its own image — now falls back to its album's) — see never has its own image — now falls back to its album's), and,
docs/architecture.md reported later, its fixed 480px width and a header that scrolled
away with the queue — see docs/architecture.md
- [x] Fixed a slow, freeze-prone catalog: a library grid's query fetched
an unused `Overview` field for every item, its artist list had no
cap at all, and up to 300 tiles were inserted in one synchronous
pass — see docs/architecture.md
- [x] Reworked the video player's bottom bar (episode/request actions/ - [x] Reworked the video player's bottom bar (episode/request actions/
fullscreen, one bar instead of three floating pieces) and added a fullscreen, one bar instead of three floating pieces) and added a
real fullscreen toggle — fullscreen now targets the whole player, real fullscreen toggle — fullscreen now targets the whole player,

View file

@ -3,12 +3,13 @@
* to the Cast Application Framework SDK directly (a CDN global, loaded via * to the Cast Application Framework SDK directly (a CDN global, loaded via
* client/src/index.html's <script async> - see global.d.ts for its ambient * client/src/index.html's <script async> - see global.d.ts for its ambient
* types, same pattern as hls.js/socket.io). Exposes a small PlaybackDriver * types, same pattern as hls.js/socket.io). Exposes a small PlaybackDriver
* interface with two implementations - one over the local <video>, one * interface with two implementations - one over the local <video>/<audio>,
* over CAF's RemotePlayer/RemotePlayerController - so Player.ts's own sync * one over CAF's RemotePlayer/RemotePlayerController - so Player.ts's and
* logic (sendLocalState/applyRemoteState) can read/write "the player" * musicBar/MusicBar.ts's own sync logic (sendLocalState/applyRemoteState,
* without caring which one is actually driving playback right now. See * applyMusicState) can read/write "the player" without caring which one is
* docs/architecture.md's "Casting to a Chromecast" section for the full * actually driving playback right now. See docs/architecture.md's "Casting
* design and why this is the only new abstraction that was needed. * to a Chromecast" section for the full design and why this is the only
* new abstraction that was needed.
*/ */
/** What Player.ts's sync logic needs from a player, whether that's the /** What Player.ts's sync logic needs from a player, whether that's the
@ -24,16 +25,18 @@ export interface PlaybackDriver {
pause(): void; pause(): void;
} }
/** The local <video>, wrapped in the same shape a cast driver uses - /** The local <video> or <audio>, wrapped in the same shape a cast driver
* trivial, since a <video> element already looks like this. */ * uses - trivial, since a media element already looks like this either
export function localDriver(video: HTMLVideoElement): PlaybackDriver { * way (HTMLMediaElement is their shared base - see Player.ts for the
* video case, musicBar/MusicBar.ts for audio). */
export function localDriver(media: HTMLMediaElement): PlaybackDriver {
return { return {
get currentTime() { return video.currentTime; }, get currentTime() { return media.currentTime; },
set currentTime(seconds: number) { video.currentTime = seconds; }, set currentTime(seconds: number) { media.currentTime = seconds; },
get paused() { return video.paused; }, get paused() { return media.paused; },
get duration() { return video.duration || 0; }, get duration() { return media.duration || 0; },
play() { video.play().catch(() => { }); }, play() { media.play().catch(() => { }); },
pause() { video.pause(); } pause() { media.pause(); }
}; };
} }

View file

@ -79,25 +79,49 @@
.musicBarPosition { font-variant-numeric: tabular-nums; white-space: nowrap; } .musicBarPosition { font-variant-numeric: tabular-nums; white-space: nowrap; }
/* #castBtn while actively casting - same "clearly-on" treatment as
views/player/Player.css's own .iconBtn[aria-pressed="true"] (ID-scoped
here rather than a shared .iconBtn rule since #playPauseBtn's own
"iconBtn" class in this bar has no matching style at all - see
MusicBar.html - and never sets aria-pressed, so this couldn't touch it
either way, but there's no shared iconBtn.css to hang a rule off yet). */
#castBtn[aria-pressed="true"] {
color: #ff9800;
background: rgba(255, 152, 0, .18);
}
/* Pops up ABOVE the bar (same pattern as Player.css's .settingsPanel above /* Pops up ABOVE the bar (same pattern as Player.css's .settingsPanel above
its gear button) - collapsed by default, see MusicBar.ts's toggleQueueBtn its gear button) - collapsed by default, see MusicBar.ts's toggleQueueBtn
handler. */ handler. Spans the full bar width (no max-width) rather than a fixed
popup box - a party's queue can run long, and a narrow column wastes
the width a desktop viewport already has going spare.
display:flex/flex-direction:column here (rather than on .pageHeader/
#queueList individually) is what lets #queueList be the only child
that scrolls below - see #queueList's own comment. */
.musicBarQueue { .musicBarQueue {
max-width: 480px;
max-height: 50vh; max-height: 50vh;
overflow-y: auto;
margin: 0 16px; margin: 0 16px;
padding: 14px 14px 0; padding: 14px 14px 0;
background: var(--bg-header); background: var(--bg-header);
border: 1px solid rgba(255, 255, 255, .08); border: 1px solid rgba(255, 255, 255, .08);
border-bottom: none; border-bottom: none;
border-radius: 10px 10px 0 0; border-radius: 10px 10px 0 0;
display: flex;
flex-direction: column;
}
.musicBarQueue .pageHeader { flex-shrink: 0; }
/* The only scrolling region inside .musicBarQueue - "File d'attente"
above it stays put instead of scrolling away with the tracks. */
#queueList {
overflow-y: auto;
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.musicBarRow { justify-content: center; } .musicBarRow { justify-content: center; }
.musicBarInfo { margin-right: 0; flex-basis: 100%; order: -1; text-align: center; } .musicBarInfo { margin-right: 0; flex-basis: 100%; order: -1; text-align: center; }
.musicBarQueue { max-width: none; margin: 0; border-radius: 0; } .musicBarQueue { margin: 0; border-radius: 0; }
} }
/* One queue entry - a plain flex row (image, then a shrinking/truncating /* One queue entry - a plain flex row (image, then a shrinking/truncating

View file

@ -21,6 +21,14 @@
<span id="trackPosition" class="hint musicBarPosition">0:00 / 0:00</span> <span id="trackPosition" class="hint musicBarPosition">0:00 / 0:00</span>
<button class="iconBtn" id="playPauseBtn" title="Lecture / pause" aria-label="Lecture / pause">&#9654;</button> <button class="iconBtn" id="playPauseBtn" title="Lecture / pause" aria-label="Lecture / pause">&#9654;</button>
<button class="iconBtn hide" id="castBtn" title="Caster sur un Chromecast" aria-label="Caster sur un Chromecast" aria-pressed="false">
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<rect x="1" y="4" width="22" height="16" rx="2" fill="none" stroke="currentColor" stroke-width="2"></rect>
<path d="M4 15a5 5 0 0 1 5 5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path>
<path d="M4 11a9 9 0 0 1 9 9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path>
<circle cx="4" cy="20" r="1.5" fill="currentColor"></circle>
</svg>
</button>
<button class="linklike" id="voteSkipBtn">Passer (vote)</button> <button class="linklike" id="voteSkipBtn">Passer (vote)</button>
<button class="linklike" id="toggleQueueBtn">File d'attente</button> <button class="linklike" id="toggleQueueBtn">File d'attente</button>
<button class="linklike" id="leaveBtn">Quitter</button> <button class="linklike" id="leaveBtn">Quitter</button>

View file

@ -3,10 +3,12 @@ import '../styles/trackList.css';
import './MusicBar.css'; import './MusicBar.css';
import html from './MusicBar.html'; import html from './MusicBar.html';
import { partyStreamUrl } from '../api/embyApi'; import { partyStreamUrl } from '../api/embyApi';
import { connectParty, leaveParty } from '../api/party'; import { connectParty, issueCastToken, leaveParty, revokeCastToken } from '../api/party';
import type { PartyConnection } from '../api/party'; import type { PartyConnection } from '../api/party';
import { colorForName, escapeHtml, formatDuration, initialsFor } from '../tiles'; import { colorForName, escapeHtml, formatDuration, initialsFor } from '../tiles';
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 type { PlaybackDriver } from '../cast';
/** /**
* Persistent, app-lifetime "listen party" bar - mounted ONCE by Shell.ts * Persistent, app-lifetime "listen party" bar - mounted ONCE by Shell.ts
@ -53,6 +55,21 @@ let currentItemId: string | null = null;
let currentDurationSeconds = 0; let currentDurationSeconds = 0;
let suppressLocalEvents = false; // avoid feeding our own correction back as a new event - same pattern as Player.ts/MusicPlayer.ts let suppressLocalEvents = false; // avoid feeding our own correction back as a new event - same pattern as Player.ts/MusicPlayer.ts
let positionTicker: number | null = null; let positionTicker: number | null = null;
// "The player" every read/write below actually targets - the local
// <audio> by default, swapped for a Chromecast's RemotePlayer while
// casting (see startCasting/cast.ts). Set for real once initMusicBar has
// an <audio> element to wrap; null only before that first call. Unlike
// Player.ts, there's no host gate on any of this - a music party has no
// host at all (see the queueList comment in MusicBar.html), so casting
// here is exactly as symmetric as play/pause already is: whoever casts
// is just routing their own copy of the shared playback through a TV/
// speaker instead of this tab, same as plugging in headphones would.
let activeDriver: PlaybackDriver | null = null;
let isCasting = false;
// Held between issueCastToken (startCasting) and revokeCastToken
// (stopCasting) so a track change mid-cast (see loadTrack) can reload
// the Chromecast's media without minting a fresh token for every track.
let castToken: string | null = null;
let latestMembers: SocketMember[] = []; let latestMembers: SocketMember[] = [];
// Cached purely so renderMembers (a member joining/leaving) can recompute // Cached purely so renderMembers (a member joining/leaving) can recompute
// the vote-skip label on its own - "votes needed" depends on the // the vote-skip label on its own - "votes needed" depends on the
@ -77,18 +94,34 @@ export function initMusicBar(root: HTMLElement): void {
const audio = barEl.querySelector('#barAudio') as HTMLAudioElement; const audio = barEl.querySelector('#barAudio') as HTMLAudioElement;
const playPauseBtn = barEl.querySelector('#playPauseBtn') as HTMLButtonElement; const playPauseBtn = barEl.querySelector('#playPauseBtn') as HTMLButtonElement;
const castBtn = barEl.querySelector('#castBtn') as HTMLButtonElement;
const voteSkipBtn = barEl.querySelector('#voteSkipBtn') as HTMLButtonElement; const voteSkipBtn = barEl.querySelector('#voteSkipBtn') as HTMLButtonElement;
const toggleQueueBtn = barEl.querySelector('#toggleQueueBtn')!; const toggleQueueBtn = barEl.querySelector('#toggleQueueBtn')!;
const queuePanel = barEl.querySelector('#musicBarQueue')!; const queuePanel = barEl.querySelector('#musicBarQueue')!;
const leaveBtn = barEl.querySelector('#leaveBtn')!; const leaveBtn = barEl.querySelector('#leaveBtn')!;
activeDriver = localDriver(audio);
audio.addEventListener('play', () => { if (!suppressLocalEvents && conn) conn.setMusicPaused(false); }); audio.addEventListener('play', () => { if (!suppressLocalEvents && conn) conn.setMusicPaused(false); });
audio.addEventListener('pause', () => { if (!suppressLocalEvents && conn) conn.setMusicPaused(true); }); audio.addEventListener('pause', () => { if (!suppressLocalEvents && conn) conn.setMusicPaused(true); });
playPauseBtn.addEventListener('click', () => conn?.setMusicPaused(!audio.paused)); playPauseBtn.addEventListener('click', () => conn?.setMusicPaused(!activeDriver!.paused));
voteSkipBtn.addEventListener('click', () => conn?.voteSkip()); voteSkipBtn.addEventListener('click', () => conn?.voteSkip());
toggleQueueBtn.addEventListener('click', () => queuePanel.classList.toggle('hide')); toggleQueueBtn.addEventListener('click', () => queuePanel.classList.toggle('hide'));
leaveBtn.addEventListener('click', () => leaveMusicParty()); leaveBtn.addEventListener('click', () => leaveMusicParty());
// Visible to any member the moment a Chromecast is on the network - not
// gated on anything party-related, same as Player.ts's own castBtn
// (see its comment: casting is available to any member, and this bar
// is only in the DOM/visible at all once a party is actually joined,
// so there's nothing extra to gate here). Both subscriptions are
// page-lifetime, never unsubscribed - see initMusicBar's own header
// comment: this whole module is wired once at boot and never torn
// down, unlike a router-driven view.
initCast();
onCastAvailabilityChanged((available) => castBtn.classList.toggle('hide', !available));
onCastSessionEnded(endCastAndResync);
castBtn.addEventListener('click', () => { if (isCasting) endCastAndResync(); else startCasting(); });
// Ticks the visible position label AND the progress bar's fill between // Ticks the visible position label AND the progress bar's fill between
// server updates, same "derive it client-side from a snapshot" approach // server updates, same "derive it client-side from a snapshot" approach
// as Home's party-elapsed labels and the old MusicPlayer.ts - runs // as Home's party-elapsed labels and the old MusicPlayer.ts - runs
@ -96,10 +129,11 @@ export function initMusicBar(root: HTMLElement): void {
// no-op while !currentItemId). // no-op while !currentItemId).
positionTicker = window.setInterval(() => { positionTicker = window.setInterval(() => {
if (!currentItemId) return; if (!currentItemId) return;
const currentTime = activeDriver!.currentTime;
const position = barEl!.querySelector('#trackPosition')!; const position = barEl!.querySelector('#trackPosition')!;
position.textContent = `${formatDuration(audio.currentTime)} / ${formatDuration(currentDurationSeconds)}`; position.textContent = `${formatDuration(currentTime)} / ${formatDuration(currentDurationSeconds)}`;
const fill = barEl!.querySelector('#trackProgressFill') as HTMLElement; const fill = barEl!.querySelector('#trackProgressFill') as HTMLElement;
fill.style.width = currentDurationSeconds > 0 ? `${Math.min(100, (audio.currentTime / currentDurationSeconds) * 100)}%` : '0%'; fill.style.width = currentDurationSeconds > 0 ? `${Math.min(100, (currentTime / currentDurationSeconds) * 100)}%` : '0%';
}, 500); }, 500);
} }
@ -151,6 +185,13 @@ export function leaveMusicParty(): void {
const partyId = activePartyId; const partyId = activePartyId;
activePartyId = null; activePartyId = null;
// Actually ends the Chromecast session (not just a local driver swap) -
// leaving the party means there's no more shared state left to cast at
// all, unlike just navigating between browsing pages while it plays on
// (see Player.ts's own cleanup for why THAT case deliberately leaves an
// active cast running).
if (isCasting) stopCasting();
if (hls) { hls.destroy(); hls = null; } if (hls) { hls.destroy(); hls = null; }
const audio = barEl!.querySelector('#barAudio') as HTMLAudioElement; const audio = barEl!.querySelector('#barAudio') as HTMLAudioElement;
audio.pause(); audio.pause();
@ -168,18 +209,113 @@ export function leaveMusicParty(): void {
leaveParty(partyId).catch(() => {}); leaveParty(partyId).catch(() => {});
} }
// Same HLS pipeline as the video player and the old MusicPlayer.ts - /**
// Emby's PlaybackInfo negotiation isn't video-specific, so a music * Casts (or re-casts) `activePartyId`'s current shared stream - the same
// track's TranscodingUrl just as plausibly comes back HLS-shaped, and * partyStreamUrl every listener's own <audio> already points at (see
// most browsers besides Safari have no native .m3u8 support at all. * loadTrack), so this never opens a second Emby stream, same guarantee
// Party-shared now too (PartyStreamService), same as the video player - * as Player.ts's video casting. Called once by startCasting to begin a
// every listener points at the same partyStreamUrl(activePartyId), one * session, and again by loadTrack for every later track change: CAF
// negotiated Emby session for the whole listen party instead of each * sessions support loading new media into an already-connected session
// listener's own independent one. There's no audio-track/quality picker * without a fresh device picker (that's what requestSession() resolving
// here to worry about (never existed for music), so unlike the video * immediately for an existing session is for - see cast.ts), which is
// player this needed no new UI at all. * exactly what a listen party needs and the video player never did
function loadTrack(track: MusicCurrentPublic, elapsedSeconds: number, isPaused: boolean): void { * (there, any item change just drops casting entirely and starts over
* locally - see Player.ts's loadVideo - judged not worth mirroring here
* given how often a queue actually advances, far more often than a video
* party changes items). `_` cache-busts the request: the URL itself never
* encodes which track is "current" (the server decides that - see
* partyStreamUrl's own comment), so without this a Chromecast receiver
* that happened to cache the previous track's manifest would have no
* reason to ever re-fetch it.
*/
async function castTrack(title: string): Promise<void> {
const contentUrl = `${location.origin}${partyStreamUrl(activePartyId!)}?castToken=${encodeURIComponent(castToken!)}&_=${Date.now()}`;
activeDriver = await startCast(contentUrl, title, onCastStateChanged);
}
/** The Chromecast's own remote/on-screen controls changed play/pause or
* seeked - broadcast it exactly like the local <audio>'s native play/
* pause listeners already do (see initMusicBar), since casting here has
* no host gate to check first (see activeDriver's own comment). */
function onCastStateChanged(): void {
const playPauseBtn = barEl!.querySelector('#playPauseBtn') as HTMLButtonElement;
playPauseBtn.textContent = activeDriver!.paused ? '▶' : '⏸';
if (!suppressLocalEvents) conn?.setMusicPaused(activeDriver!.paused);
}
/**
* Starts casting - available to any listener, not just... there is no
* "just", a listen party has no host (see activeDriver's own comment).
* Mints a short-lived castToken the same way Player.ts does (
* CastTokenService, scoped to this party+member) and silences the local
* <audio> (nothing left for this device to usefully play while the TV
* does), then hands off to castTrack for the actual load.
*/
async function startCasting(): Promise<void> {
const partyId = activePartyId;
if (!partyId || isCasting || !lastMusicState?.current) return;
const track = lastMusicState.current;
try {
const { token } = await issueCastToken(partyId);
castToken = token;
await castTrack([track.itemName, track.artistName].filter(Boolean).join(' - '));
if (hls) { hls.destroy(); hls = null; }
const audio = barEl!.querySelector('#barAudio') as HTMLAudioElement;
suppressLocalEvents = true;
audio.pause();
setTimeout(() => { suppressLocalEvents = false; }, 250);
isCasting = true;
(barEl!.querySelector('#castBtn') as HTMLButtonElement).setAttribute('aria-pressed', 'true');
} catch (err) {
// The user cancelling the device picker rejects the same way a real
// failure does - neither is worth an alert(), just keep listening
// locally, same as any other "the thing the user tried didn't work"
// path in this app (see loadTrack's own catch for the same instinct).
console.error('[watchparty musicBar] cast failed', err);
castToken = null;
revokeCastToken(partyId).catch(() => {});
}
}
/** Pure teardown - drops back to the local driver and ends the actual
* Chromecast session/token, but does NOT resume local playback itself
* (see endCastAndResync, the only other caller that needs that, and
* leaveMusicParty, which doesn't - it's about to tear the local <audio>
* down too). */
function stopCasting(): void {
if (!isCasting) return;
isCasting = false;
const audio = barEl!.querySelector('#barAudio') as HTMLAudioElement; const audio = barEl!.querySelector('#barAudio') as HTMLAudioElement;
activeDriver = localDriver(audio);
(barEl!.querySelector('#castBtn') as HTMLButtonElement).setAttribute('aria-pressed', 'false');
stopCast();
if (activePartyId) revokeCastToken(activePartyId).catch(() => {});
castToken = null;
}
/** Used by both the cast button's own "stop casting" click and a session
* ending on its own (the receiver's remote, the TV losing power) -
* captures wherever the TV left off before tearing the cast driver
* down, then resumes local playback from there via the same loadTrack
* path a genuine track change already uses. */
function endCastAndResync(): void {
if (!isCasting) return;
const resumeSeconds = activeDriver!.currentTime;
const wasPlaying = !activeDriver!.paused;
stopCasting();
if (lastMusicState?.current) loadTrack(lastMusicState.current, resumeSeconds, !wasPlaying);
}
/**
* A new track became current - called from applyMusicState, whether
* that's a genuine queue advance or this client only just joined. Always
* updates the displayed metadata; which of the two very differently-
* shaped things actually produces sound is a separate decision
* (isCasting) delegated to castTrack/loadTrackLocal below.
*/
function loadTrack(track: MusicCurrentPublic, elapsedSeconds: number, isPaused: boolean): void {
const trackImage = barEl!.querySelector('#trackImage') as HTMLImageElement; const trackImage = barEl!.querySelector('#trackImage') as HTMLImageElement;
const trackName = barEl!.querySelector('#trackName')!; const trackName = barEl!.querySelector('#trackName')!;
const trackArtist = barEl!.querySelector('#trackArtist')!; const trackArtist = barEl!.querySelector('#trackArtist')!;
@ -191,6 +327,34 @@ function loadTrack(track: MusicCurrentPublic, elapsedSeconds: number, isPaused:
trackName.textContent = track.itemName || ''; trackName.textContent = track.itemName || '';
trackArtist.textContent = [track.artistName, track.albumName].filter(Boolean).join(' - '); trackArtist.textContent = [track.artistName, track.albumName].filter(Boolean).join(' - ');
if (isCasting) {
castTrack([track.itemName, track.artistName].filter(Boolean).join(' - ')).catch((err) => {
// The Chromecast dropping mid-party (network hiccup, the TV losing
// power without CAF noticing yet) must not strand the party unable
// to advance - fall back to local playback, same recovery instinct
// as the try/catch around hls.js below.
console.error('[watchparty musicBar] failed to (re)cast the new track, falling back to local playback', err);
stopCasting();
loadTrackLocal(track, elapsedSeconds, isPaused);
});
return;
}
loadTrackLocal(track, elapsedSeconds, isPaused);
}
// Same HLS pipeline as the video player and the old MusicPlayer.ts -
// Emby's PlaybackInfo negotiation isn't video-specific, so a music
// track's TranscodingUrl just as plausibly comes back HLS-shaped, and
// most browsers besides Safari have no native .m3u8 support at all.
// Party-shared now too (PartyStreamService), same as the video player -
// every listener points at the same partyStreamUrl(activePartyId), one
// negotiated Emby session for the whole listen party instead of each
// listener's own independent one. There's no audio-track/quality picker
// here to worry about (never existed for music), so unlike the video
// player this needed no new UI at all.
function loadTrackLocal(track: MusicCurrentPublic, elapsedSeconds: number, isPaused: boolean): void {
const audio = barEl!.querySelector('#barAudio') as HTMLAudioElement;
if (hls) { hls.destroy(); hls = null; } if (hls) { hls.destroy(); hls = null; }
const src = partyStreamUrl(activePartyId!); // loadTrack is only ever called while a party is active - see applyMusicState/joinMusicParty const src = partyStreamUrl(activePartyId!); // loadTrack is only ever called while a party is active - see applyMusicState/joinMusicParty
@ -254,6 +418,7 @@ function applyMusicState(music: MusicPublicState): void {
lastMusicState = music; lastMusicState = music;
const playPauseBtn = barEl!.querySelector('#playPauseBtn') as HTMLButtonElement; const playPauseBtn = barEl!.querySelector('#playPauseBtn') as HTMLButtonElement;
const castBtn = barEl!.querySelector('#castBtn') as HTMLButtonElement;
const queueList = barEl!.querySelector('#queueList')!; const queueList = barEl!.querySelector('#queueList')!;
const trackName = barEl!.querySelector('#trackName')!; const trackName = barEl!.querySelector('#trackName')!;
const trackArtist = barEl!.querySelector('#trackArtist')!; const trackArtist = barEl!.querySelector('#trackArtist')!;
@ -265,6 +430,9 @@ function applyMusicState(music: MusicPublicState): void {
// own comment on why - same ordering as Player.ts/the old MusicPlayer.ts). // own comment on why - same ordering as Player.ts/the old MusicPlayer.ts).
playPauseBtn.textContent = music.current && !music.current.isPaused ? '⏸' : '▶'; playPauseBtn.textContent = music.current && !music.current.isPaused ? '⏸' : '▶';
playPauseBtn.disabled = !music.current; playPauseBtn.disabled = !music.current;
// Nothing for startCasting to actually cast with an empty queue - same
// "disable, don't just silently no-op" treatment as playPauseBtn above.
castBtn.disabled = !music.current && !isCasting;
updateVoteSkipLabel(); updateVoteSkipLabel();
// Its own .queueRow, NOT views/album's shared .trackRow (see // Its own .queueRow, NOT views/album's shared .trackRow (see
@ -292,19 +460,20 @@ function applyMusicState(music: MusicPublicState): void {
trackArtist.textContent = ''; trackArtist.textContent = '';
trackImage.src = ''; trackImage.src = '';
if (hls) { hls.destroy(); hls = null; } if (hls) { hls.destroy(); hls = null; }
audio.pause(); activeDriver!.pause();
audio.removeAttribute('src'); audio.removeAttribute('src'); // harmless no-op while casting - the local element is already srcless then, see startCasting
(barEl!.querySelector('#trackProgressFill') as HTMLElement).style.width = '0%'; (barEl!.querySelector('#trackProgressFill') as HTMLElement).style.width = '0%';
} else if (music.current.itemId !== currentItemId) { } else if (music.current.itemId !== currentItemId) {
loadTrack(music.current, music.current.elapsedSeconds, music.current.isPaused); loadTrack(music.current, music.current.elapsedSeconds, music.current.isPaused);
} else { } else {
// Same track still playing - just resync pause state/position, don't reload the source. // Same track still playing - just resync pause state/position via
// whichever driver is actually active, don't reload the source.
suppressLocalEvents = true; suppressLocalEvents = true;
if (Math.abs(audio.currentTime - music.current.elapsedSeconds) > 2) { if (Math.abs(activeDriver!.currentTime - music.current.elapsedSeconds) > 2) {
audio.currentTime = music.current.elapsedSeconds; activeDriver!.currentTime = music.current.elapsedSeconds;
} }
if (music.current.isPaused && !audio.paused) audio.pause(); if (music.current.isPaused && !activeDriver!.paused) activeDriver!.pause();
if (!music.current.isPaused && audio.paused) audio.play().catch(() => {}); if (!music.current.isPaused && activeDriver!.paused) activeDriver!.play();
setTimeout(() => { suppressLocalEvents = false; }, 250); setTimeout(() => { suppressLocalEvents = false; }, 250);
} }
} }

View file

@ -67,28 +67,64 @@ export function tileHtml(item: ItemDto): string {
} }
/** /**
* Wires up every "Lancer une watch party" button inside `container`. * Wires up every "Lancer une watch party" button inside `container`, now
* `onStart(itemId)` does the actual work (create the party + navigate) - * and for any tile added to it later. `onStart(itemId)` does the actual
* kept as a caller-supplied callback so this module doesn't need to * work (create the party + navigate) - kept as a caller-supplied callback
* depend on api/party.ts/router.ts itself. * so this module doesn't need to depend on api/party.ts/router.ts itself.
*
* One delegated listener on `container` itself, not one per button - a
* plain per-button `addEventListener` loop only ever catches whatever's
* already in the DOM at call time, which renderTilesInChunks (below)
* deliberately isn't: its later chunks land after this runs, and a
* direct-bound listener would silently never cover them.
*/ */
export function wireTileButtons(container: ParentNode, onStart: (itemId: string) => Promise<void>): void { export function wireTileButtons(container: ParentNode, onStart: (itemId: string) => Promise<void>): void {
container.querySelectorAll<HTMLButtonElement>('.startPartyBtn').forEach((btn) => { container.addEventListener('click', async (e) => {
btn.addEventListener('click', async (e) => { const btn = (e.target as HTMLElement).closest<HTMLButtonElement>('.startPartyBtn');
e.preventDefault(); if (!btn || btn.disabled) return;
btn.disabled = true; e.preventDefault();
btn.textContent = 'Démarrage...'; btn.disabled = true;
try { btn.textContent = 'Démarrage...';
await onStart(btn.dataset.itemId as string); try {
} catch (err) { await onStart(btn.dataset.itemId as string);
alert((err as Error).message); } catch (err) {
btn.disabled = false; alert((err as Error).message);
btn.textContent = 'Lancer une watch party'; btn.disabled = false;
} btn.textContent = 'Lancer une watch party';
}); }
}); });
} }
/**
* Renders `items` into `container` a chunk at a time, across animation
* frames, instead of one `innerHTML` write for the whole list - a library
* grid can hand back up to 300 tiles at once (CatalogService.getLibraryItems/
* getArtists), and building + inserting/laying out ~300 posters in a
* single synchronous task is exactly what a "the page freezes while it
* loads" report looks like on a slower device. Splitting the same HTML
* across a few chunks keeps every individual task well under a frame's
* budget, so the tab stays responsive (and the loading spinner keeps
* animating) while it works through the rest.
*
* Uses `insertAdjacentHTML`, not repeated `innerHTML +=` (which would
* re-parse and re-render everything already inserted on every chunk).
* Safe to pair with wireTileButtons/wireSearchFilter called once right
* after this returns - both already tolerate tiles that land later (see
* their own comments).
*/
export function renderTilesInChunks(container: HTMLElement, items: ItemDto[], chunkSize = 40): void {
container.innerHTML = '';
let i = 0;
function renderNextChunk(): void {
const chunk = items.slice(i, i + chunkSize);
if (!chunk.length) return;
container.insertAdjacentHTML('beforeend', chunk.map(tileHtml).join(''));
i += chunkSize;
if (i < items.length) requestAnimationFrame(renderNextChunk);
}
renderNextChunk();
}
/** /**
* Wires a search <input> to show/hide `itemSelector` elements by name, * Wires a search <input> to show/hide `itemSelector` elements by name,
* live as you type - client-side only, filtering whatever's already * live as you type - client-side only, filtering whatever's already

View file

@ -3,7 +3,7 @@ import { getArtists, getLibraries, getLibraryItems } from '../../api/embyApi';
import { createParty } from '../../api/party'; import { createParty } from '../../api/party';
import { navigate } from '../../router'; import { navigate } from '../../router';
import type { RouteParams } from '../../router'; import type { RouteParams } from '../../router';
import { tileHtml, wireSearchFilter, wireTileButtons } from '../../tiles'; import { renderTilesInChunks, wireSearchFilter, wireTileButtons } from '../../tiles';
import { LOADING_HINT } from '../../loading'; import { LOADING_HINT } from '../../loading';
/** /**
@ -55,7 +55,7 @@ export async function renderLibrary(root: HTMLElement, params: RouteParams): Pro
return; return;
} }
grid.innerHTML = items.map(tileHtml).join(''); renderTilesInChunks(grid, items);
wireTileButtons(grid, async (itemId) => { wireTileButtons(grid, async (itemId) => {
const party = await createParty(itemId); const party = await createParty(itemId);
navigate(`/party/${party.partyId}`); navigate(`/party/${party.partyId}`);

View file

@ -2412,10 +2412,15 @@ relay: it can't carry the sender's httpOnly cookie at all, so it needed
its own narrow way in without weakening "the server holds the only real its own narrow way in without weakening "the server holds the only real
Emby token" that the rest of the app is built around. Emby token" that the rest of the app is built around.
**Scoped to video parties only** - the spec's every concrete reference **Scoped to video parties for v1** - the spec's every concrete reference
(the player, the party-stream/burn routes, the burn-in fallback) is (the player, the party-stream/burn routes, the burn-in fallback) was
video-specific, and `MusicBar.ts` has no comparable control surface video-specific, and `MusicBar.ts` had no comparable control surface at
today. Casting a listen-party would be a fast follow, not this. the time. Extended to music too shortly after, once asked for directly -
see "Casting a listen party" below for what that actually took (turned
out to be almost entirely client-side: every piece of server support
below - `CastTokenService`, the 4 streaming routes' `sessionForStream`
fallback - was already media-type-agnostic, scoped to a `partyId`, never
a video-specific assumption).
**`CastTokenService`** (new, same shape as `AuthService`: an in-memory **`CastTokenService`** (new, same shape as `AuthService`: an in-memory
`Map`, opaque `crypto.randomUUID()` keys, TTL-checked on read) issues a `Map`, opaque `crypto.randomUUID()` keys, TTL-checked on read) issues a
@ -2520,6 +2525,112 @@ output cleanly are all unconfirmed, the same way this project already
flags its other can't-verify-without-the-real-thing assumptions. Worth flags its other can't-verify-without-the-real-thing assumptions. Worth
trying with a real device once deployed. trying with a real device once deployed.
## Casting a listen party
Asked for directly, after the video case above already shipped and was
confirmed working end to end: let a listen party cast too. The server
side needed nothing new at all - `CastTokenService`, the two
`cast-token` routes, and `sessionForStream`'s fallback on the 4 streaming
routes were all already keyed on nothing more specific than `partyId`;
`partyStreamUrl(partyId)` was already exactly what `musicBar/MusicBar
.ts`'s own `<audio>` pointed at. This turned into an almost entirely
client-side feature: `cast.ts`'s `localDriver` only needed its parameter
type widened from `HTMLVideoElement` to `HTMLMediaElement` (every member
it reads - `currentTime`/`paused`/`duration`/`play()`/`pause()` - is
already on the shared `HTMLMediaElement` base both elements implement),
and `MusicBar.ts` grew the same `activeDriver` indirection `Player.ts`
already has.
The one genuine design question was host-gating, and the answer is that
there isn't any to port: a listen party has no host at all (see "One
shared Emby stream per party" below) - any member's play/pause already
applies party-wide with no arbitration, the same symmetric model this
extends unchanged. Casting a track is exactly as symmetric: whoever
starts one is just routing their own copy of the already-shared audio
through a TV/speaker instead of this tab, same as plugging in
headphones would be, and the Chromecast's own on-screen controls feed
back into `setMusicPaused` exactly like the local `<audio>`'s native
play/pause events already do.
The other real difference from the video case: a video party's item
changes rarely enough that casting simply drops back to local playback
on any reload (see above) - "re-cast, one click" was an acceptable cost
there. A listen party's queue advances far more often, so dropping the
cast on every track would have made this a much worse feature than not
having it. Instead, a track change while casting calls the exact same
`startCast()` again, reusing the *already-connected* session -
`requestSession()` resolving immediately for a session that's still
open, no fresh device picker, is exactly what this needs and the video
path never had reason to exercise. The one wrinkle: `partyStreamUrl`
never encodes which track is playing (the server alone decides that),
so reusing the identical URL string for a new load gained a `_=Date.now()`
cache-buster - without it, a receiver that cached the previous track's
manifest would have had no reason to ever re-fetch it. If a re-cast
throws (the TV dropped mid-party, a network hiccup), it falls back to
local playback rather than stranding the party silently stuck - the same
recovery instinct `loadTrack`'s existing try/catch around hls.js already
has for the same reason.
Leaving the party while casting ends the Chromecast session outright
(`stopCast()`, not just a driver swap back to local) - unlike Player.ts's
route cleanup, which deliberately leaves a cast running when you merely
navigate away from the page, actually leaving a listen party means there
is no more shared state left to cast at all.
Verified: full `typecheck`/`build` on the client (this app's client has
no test runner - see `client/package.json` - so client-side logic here
is typecheck+build+code-review only, consistent with the rest of this
codebase's client/server verification split). **Not verified**: no real
Chromecast or Emby login reachable from this sandbox, same standing gap
as the video case above - the actual re-cast-on-track-change round trip
needs a real device to confirm end to end.
## The catalog API being slow, and the front freezing while it loads
Reported directly, still true after an earlier round already fixed
image caching (see `EmbyClient.proxyResponse`'s own comment on that).
Two separate, compounding causes, both traced by reading the actual
request shapes rather than guessing:
`CatalogService.getLibraryItems` - the one query behind a whole library
grid, up to 300 items - asked Emby for `Overview` on every item and
never used it: `views/library`'s tiles only ever render a poster, name,
and year (`tiles.ts`'s `tileHtml`). Emby happily serializes a full
synopsis for all 300 anyway, and the client happily `JSON.parse`s all of
it, purely to discard it unread - real, wasted latency on both the Emby
round trip and the response parse. Dropped from that query's `Fields`
(kept where it's actually shown - `getSeriesEpisodes`/
`getPlaylistItemsRaw`, read by `views/series`/`views/playlist`).
`getArtists` (a music library's own top-level grid) had no `Limit` at
all, unlike every sibling query - given a large enough library, an
unbounded artist list compounds the second cause below without even
Emby's own side being slow.
The literal "freeze" was separate from raw latency: `tiles.ts`'s shared
render path built one HTML string for an entire batch of items and
assigned it via a single `innerHTML` write - fine for a home carousel
(`Limit: 16`), but a real, felt hitch on slower devices for a library
grid's up-to-300 (or previously-unbounded artist list). Fixed with
`renderTilesInChunks` - the same tiles, the same `tileHtml`, just
inserted 40 at a time across `requestAnimationFrame` callbacks instead
of one synchronous pass, so no single task blocking the main thread
grows with the list size. The one thing this couldn't stay naive about:
`wireTileButtons` used to attach one `click` listener per button at
call time, which would have silently never covered a tile that hadn't
rendered yet - switched to one delegated listener on the container
instead, which correctly covers whatever's in the DOM at click time
regardless of when it was inserted.
Verified: full `typecheck`/`test`/`build` on both projects (server
tests unaffected - neither changed query has a test asserting its exact
`Fields` string). The chunked-render mechanism itself was verified
directly - a synthetic 300-item list rendered against a real DOM in the
browser confirms every item lands and that a button in the *last* chunk
is still clickable through the delegated listener (the exact failure
mode a naive per-button-at-call-time wiring would have hit). **Not
verified**: no Emby login reachable from this sandbox to measure the
actual before/after latency against a real, large library.
## Fixing the listen-party queue's layout and its missing album art ## Fixing the listen-party queue's layout and its missing album art
Reported directly: the music bar's "File d'attente" popup looked broken, Reported directly: the music bar's "File d'attente" popup looked broken,
@ -2577,6 +2688,21 @@ to see the queue/card layout itself, so those were checked visually
against a mockup built from this app's own real theme tokens/typefaces against a mockup built from this app's own real theme tokens/typefaces
rather than a live instance. rather than a live instance.
**Follow-up, reported later**: the queue popup should span the bar's
full width, not sit in a fixed 480px column, and scrolling a long queue
scrolled its "File d'attente" header away with it. Both were the same
kind of oversight as the original layout bug above - CSS carried over
from when this was a small popup, not re-examined once the row shape
changed. The width cap (`.musicBarQueue`'s `max-width: 480px`) was
simply dropped; the scroll region was the real fix - `overflow-y`/
`max-height` sat on `.musicBarQueue` itself, which wraps *both*
`.pageHeader` and `#queueList`, so scrolling the list scrolled the
header with it. Moved the scroll region down to `#queueList` alone
(`.musicBarQueue` becomes a plain flex column, header first with
`flex-shrink: 0`), the standard sticky-header-over-scrollable-body
split - the header now stays exactly where clicking "File d'attente"
put it regardless of queue length.
## Reworking the player: a unified bottom bar, and a fullscreen that doesn't hide it ## Reworking the player: a unified bottom bar, and a fullscreen that doesn't hide it
Asked for directly - a UI rework of the video player, "in both normal Asked for directly - a UI rework of the video player, "in both normal

View file

@ -199,7 +199,11 @@ class CatalogServiceClass {
// structure, same reasoning as Shows -> Seasons -> Episodes above. // structure, same reasoning as Shows -> Seasons -> Episodes above.
async getArtists(session: EmbySession, libraryId: string): Promise<ItemDto[]> { async getArtists(session: EmbySession, libraryId: string): Promise<ItemDto[]> {
const params = new URLSearchParams({ ParentId: libraryId, UserId: session.userId, SortBy: 'SortName' }); // Limit added for the same reason as getLibraryItems's own - unbounded
// on a large music library, this used to hand back every artist at
// once for the client to render in a single pass (see tiles.ts's
// renderTilesInChunks for the render-side half of that fix).
const params = new URLSearchParams({ ParentId: libraryId, UserId: session.userId, SortBy: 'SortName', Limit: '300' });
const data = await this.fetchJson<EmbyItemsResponse>(`/Artists?${params}`, session, 'Impossible de récupérer les artistes.'); const data = await this.fetchJson<EmbyItemsResponse>(`/Artists?${params}`, session, 'Impossible de récupérer les artistes.');
return (data.Items || []).map((a) => this.toItemDto(a, session.serverId)); return (data.Items || []).map((a) => this.toItemDto(a, session.serverId));
} }
@ -230,11 +234,19 @@ class CatalogServiceClass {
} }
async getLibraryItems(session: EmbySession, libraryId: string): Promise<ItemDto[]> { async getLibraryItems(session: EmbySession, libraryId: string): Promise<ItemDto[]> {
// Fields deliberately excludes Overview - a library grid only ever
// renders tileHtml's poster/name/year (views/library, tiles.ts), which
// never reads it. Traced here after "the catalog API is too slow, the
// front freezes while it loads" - Emby happily serializes a full
// synopsis for all 300 items, and the client happily JSON.parses all
// of it, entirely to be thrown away unread. Contrast getSeriesEpisodes/
// getPlaylistItemsRaw, which keep Overview because their own views
// (Series/Playlist) actually display it.
const params = new URLSearchParams({ const params = new URLSearchParams({
ParentId: libraryId, ParentId: libraryId,
Recursive: 'false', // top-level: Movies as movies, TV as Series tiles (not every episode) Recursive: 'false', // top-level: Movies as movies, TV as Series tiles (not every episode)
SortBy: 'SortName', SortBy: 'SortName',
Fields: 'Overview,ProductionYear,AlbumId', Fields: 'ProductionYear,AlbumId',
ImageTypeLimit: '1', ImageTypeLimit: '1',
Limit: '300' Limit: '300'
}); });