Cast a listen party too, and fix a slow/freezing catalog
Three separate reports handled together. Cast was video-only by design (docs/architecture.md flagged it as video-parties-only for v1) - asked for directly, and turned out to need almost nothing new server-side: CastTokenService and the 4 streaming routes' sessionForStream fallback were already keyed on nothing more specific than partyId. cast.ts's localDriver only needed its parameter widened from HTMLVideoElement to HTMLMediaElement - both already share the same currentTime/paused/duration/play()/pause() surface. MusicBar.ts gained the same activeDriver indirection Player.ts already has, with one real difference from the video case: a listen party's queue advances far more often than a video party changes items, so dropping the cast on every track (the video path's own choice there) would have made this worse than not having it - a track change while casting instead calls startCast() again, reusing the already-connected session (a cache-busting query param added since the content URL itself never changes between tracks - the server alone decides what's current). Falls back to local playback if a re-cast throws, same recovery instinct loadTrack's own hls.js try/catch already has. The queue popup: reported separately, a fixed 480px width and a header that scrolled away with the list - both leftover CSS from before the row shape was reworked, never revisited since. Width cap dropped; overflow/max-height moved off the outer .musicBarQueue (which wraps the header too) down onto #queueList alone. The catalog: still too slow, the front freezing while it loads. CatalogService.getLibraryItems was asking Emby for Overview on every one of up to 300 items and never using it (views/library's tiles only ever show a poster/name/year) - dropped. getArtists had no Limit at all, unlike every sibling query. Separately, tiles.ts's shared render path built one HTML string for a whole batch and inserted it via a single innerHTML write - fine for a 16-item carousel, a real hitch for a 300-tile grid. renderTilesInChunks inserts the same tileHtml output 40 at a time across requestAnimationFrame callbacks instead; wireTileButtons switched to one delegated listener so a tile from a later chunk is still clickable. Verified: full typecheck/test/build on both projects (122/122 server tests). The chunked-render mechanism was verified directly - a synthetic 300-item list against a real DOM confirms every item lands and a button in the last chunk still fires through the delegated listener. Not verified: no real Chromecast or Emby login reachable from this sandbox, so the actual on-TV re-cast round trip and the before/after catalog latency against a real large library are both unconfirmed - same standing gap this project already flags for its other casting work. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
c00585101b
commit
e89a81a3f1
9 changed files with 467 additions and 80 deletions
37
README.md
37
README.md
|
|
@ -132,14 +132,15 @@ assumes, how a real transcode's seek handling feels once several
|
|||
viewers depend on the exact same one).
|
||||
|
||||
Casting to a Chromecast (see docs/architecture.md) is the newest,
|
||||
least-verified piece: the security-critical parts were checked against
|
||||
the real running server directly (the party-stream/burn routes reject a
|
||||
request with no auth exactly as before, and now also reject an invalid
|
||||
`castToken`, both a plain 401) and the Cast SDK itself was confirmed to
|
||||
load and initialize in a real browser, but there's neither a real
|
||||
Chromecast nor Emby login credentials in this environment to take it
|
||||
any further - the actual on-TV playback and the cast button's own
|
||||
live behavior are unconfirmed.
|
||||
least-verified piece, for video and music alike: the security-critical
|
||||
parts were checked against the real running server directly (the
|
||||
party-stream/burn routes reject a request with no auth exactly as
|
||||
before, and now also reject an invalid `castToken`, both a plain 401)
|
||||
and the Cast SDK itself was confirmed to load and initialize in a real
|
||||
browser, but there's neither a real Chromecast nor Emby login
|
||||
credentials in this environment to take it any further - the actual
|
||||
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
|
||||
|
||||
|
|
@ -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
|
||||
cacheable (Emby's own Cache-Control/ETag were silently dropped) —
|
||||
see docs/architecture.md
|
||||
- [x] Cast a party's video to a Chromecast (Default Media Receiver),
|
||||
with a play/pause/seek remote on the sender's own screen — see
|
||||
docs/architecture.md
|
||||
- [x] Cast a party's video *or* listen-party music to a Chromecast
|
||||
(Default Media Receiver), with a play/pause/seek remote on the
|
||||
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
|
||||
album page's row grid) and its missing album art (a track almost
|
||||
never has its own image — now falls back to its album's) — see
|
||||
docs/architecture.md
|
||||
album page's row grid), its missing album art (a track almost
|
||||
never has its own image — now falls back to its album's), and,
|
||||
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/
|
||||
fullscreen, one bar instead of three floating pieces) and added a
|
||||
real fullscreen toggle — fullscreen now targets the whole player,
|
||||
|
|
|
|||
|
|
@ -3,12 +3,13 @@
|
|||
* 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
|
||||
* types, same pattern as hls.js/socket.io). Exposes a small PlaybackDriver
|
||||
* interface with two implementations - one over the local <video>, one
|
||||
* over CAF's RemotePlayer/RemotePlayerController - so Player.ts's own sync
|
||||
* logic (sendLocalState/applyRemoteState) can read/write "the player"
|
||||
* without caring which one is actually driving playback right now. See
|
||||
* docs/architecture.md's "Casting to a Chromecast" section for the full
|
||||
* design and why this is the only new abstraction that was needed.
|
||||
* interface with two implementations - one over the local <video>/<audio>,
|
||||
* one over CAF's RemotePlayer/RemotePlayerController - so Player.ts's and
|
||||
* musicBar/MusicBar.ts's own sync logic (sendLocalState/applyRemoteState,
|
||||
* applyMusicState) can read/write "the player" without caring which one is
|
||||
* actually driving playback right now. See docs/architecture.md's "Casting
|
||||
* 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
|
||||
|
|
@ -24,16 +25,18 @@ export interface PlaybackDriver {
|
|||
pause(): void;
|
||||
}
|
||||
|
||||
/** The local <video>, wrapped in the same shape a cast driver uses -
|
||||
* trivial, since a <video> element already looks like this. */
|
||||
export function localDriver(video: HTMLVideoElement): PlaybackDriver {
|
||||
/** The local <video> or <audio>, wrapped in the same shape a cast driver
|
||||
* uses - trivial, since a media element already looks like this either
|
||||
* 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 {
|
||||
get currentTime() { return video.currentTime; },
|
||||
set currentTime(seconds: number) { video.currentTime = seconds; },
|
||||
get paused() { return video.paused; },
|
||||
get duration() { return video.duration || 0; },
|
||||
play() { video.play().catch(() => { }); },
|
||||
pause() { video.pause(); }
|
||||
get currentTime() { return media.currentTime; },
|
||||
set currentTime(seconds: number) { media.currentTime = seconds; },
|
||||
get paused() { return media.paused; },
|
||||
get duration() { return media.duration || 0; },
|
||||
play() { media.play().catch(() => { }); },
|
||||
pause() { media.pause(); }
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -79,25 +79,49 @@
|
|||
|
||||
.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
|
||||
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 {
|
||||
max-width: 480px;
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
margin: 0 16px;
|
||||
padding: 14px 14px 0;
|
||||
background: var(--bg-header);
|
||||
border: 1px solid rgba(255, 255, 255, .08);
|
||||
border-bottom: none;
|
||||
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) {
|
||||
.musicBarRow { justify-content: 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
|
||||
|
|
|
|||
|
|
@ -21,6 +21,14 @@
|
|||
|
||||
<span id="trackPosition" class="hint musicBarPosition">0:00 / 0:00</span>
|
||||
<button class="iconBtn" id="playPauseBtn" title="Lecture / pause" aria-label="Lecture / pause">▶</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="toggleQueueBtn">File d'attente</button>
|
||||
<button class="linklike" id="leaveBtn">Quitter</button>
|
||||
|
|
|
|||
|
|
@ -3,10 +3,12 @@ import '../styles/trackList.css';
|
|||
import './MusicBar.css';
|
||||
import html from './MusicBar.html';
|
||||
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 { colorForName, escapeHtml, formatDuration, initialsFor } from '../tiles';
|
||||
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
|
||||
|
|
@ -53,6 +55,21 @@ let currentItemId: string | null = null;
|
|||
let currentDurationSeconds = 0;
|
||||
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;
|
||||
// "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[] = [];
|
||||
// Cached purely so renderMembers (a member joining/leaving) can recompute
|
||||
// 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 playPauseBtn = barEl.querySelector('#playPauseBtn') as HTMLButtonElement;
|
||||
const castBtn = barEl.querySelector('#castBtn') as HTMLButtonElement;
|
||||
const voteSkipBtn = barEl.querySelector('#voteSkipBtn') as HTMLButtonElement;
|
||||
const toggleQueueBtn = barEl.querySelector('#toggleQueueBtn')!;
|
||||
const queuePanel = barEl.querySelector('#musicBarQueue')!;
|
||||
const leaveBtn = barEl.querySelector('#leaveBtn')!;
|
||||
|
||||
activeDriver = localDriver(audio);
|
||||
|
||||
audio.addEventListener('play', () => { if (!suppressLocalEvents && conn) conn.setMusicPaused(false); });
|
||||
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());
|
||||
toggleQueueBtn.addEventListener('click', () => queuePanel.classList.toggle('hide'));
|
||||
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
|
||||
// server updates, same "derive it client-side from a snapshot" approach
|
||||
// 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).
|
||||
positionTicker = window.setInterval(() => {
|
||||
if (!currentItemId) return;
|
||||
const currentTime = activeDriver!.currentTime;
|
||||
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;
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
@ -151,6 +185,13 @@ export function leaveMusicParty(): void {
|
|||
const partyId = activePartyId;
|
||||
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; }
|
||||
const audio = barEl!.querySelector('#barAudio') as HTMLAudioElement;
|
||||
audio.pause();
|
||||
|
|
@ -168,18 +209,113 @@ export function leaveMusicParty(): void {
|
|||
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
|
||||
// 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 loadTrack(track: MusicCurrentPublic, elapsedSeconds: number, isPaused: boolean): void {
|
||||
/**
|
||||
* Casts (or re-casts) `activePartyId`'s current shared stream - the same
|
||||
* partyStreamUrl every listener's own <audio> already points at (see
|
||||
* loadTrack), so this never opens a second Emby stream, same guarantee
|
||||
* as Player.ts's video casting. Called once by startCasting to begin a
|
||||
* session, and again by loadTrack for every later track change: CAF
|
||||
* sessions support loading new media into an already-connected session
|
||||
* without a fresh device picker (that's what requestSession() resolving
|
||||
* immediately for an existing session is for - see cast.ts), which is
|
||||
* exactly what a listen party needs and the video player never did
|
||||
* (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;
|
||||
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 trackName = barEl!.querySelector('#trackName')!;
|
||||
const trackArtist = barEl!.querySelector('#trackArtist')!;
|
||||
|
|
@ -191,6 +327,34 @@ function loadTrack(track: MusicCurrentPublic, elapsedSeconds: number, isPaused:
|
|||
trackName.textContent = track.itemName || '';
|
||||
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; }
|
||||
|
||||
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;
|
||||
|
||||
const playPauseBtn = barEl!.querySelector('#playPauseBtn') as HTMLButtonElement;
|
||||
const castBtn = barEl!.querySelector('#castBtn') as HTMLButtonElement;
|
||||
const queueList = barEl!.querySelector('#queueList')!;
|
||||
const trackName = barEl!.querySelector('#trackName')!;
|
||||
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).
|
||||
playPauseBtn.textContent = music.current && !music.current.isPaused ? '⏸' : '▶';
|
||||
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();
|
||||
|
||||
// Its own .queueRow, NOT views/album's shared .trackRow (see
|
||||
|
|
@ -292,19 +460,20 @@ function applyMusicState(music: MusicPublicState): void {
|
|||
trackArtist.textContent = '';
|
||||
trackImage.src = '';
|
||||
if (hls) { hls.destroy(); hls = null; }
|
||||
audio.pause();
|
||||
audio.removeAttribute('src');
|
||||
activeDriver!.pause();
|
||||
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%';
|
||||
} else if (music.current.itemId !== currentItemId) {
|
||||
loadTrack(music.current, music.current.elapsedSeconds, music.current.isPaused);
|
||||
} 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;
|
||||
if (Math.abs(audio.currentTime - music.current.elapsedSeconds) > 2) {
|
||||
audio.currentTime = music.current.elapsedSeconds;
|
||||
if (Math.abs(activeDriver!.currentTime - music.current.elapsedSeconds) > 2) {
|
||||
activeDriver!.currentTime = music.current.elapsedSeconds;
|
||||
}
|
||||
if (music.current.isPaused && !audio.paused) audio.pause();
|
||||
if (!music.current.isPaused && audio.paused) audio.play().catch(() => {});
|
||||
if (music.current.isPaused && !activeDriver!.paused) activeDriver!.pause();
|
||||
if (!music.current.isPaused && activeDriver!.paused) activeDriver!.play();
|
||||
setTimeout(() => { suppressLocalEvents = false; }, 250);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,14 +67,21 @@ export function tileHtml(item: ItemDto): string {
|
|||
}
|
||||
|
||||
/**
|
||||
* Wires up every "Lancer une watch party" button inside `container`.
|
||||
* `onStart(itemId)` does the actual work (create the party + navigate) -
|
||||
* kept as a caller-supplied callback so this module doesn't need to
|
||||
* depend on api/party.ts/router.ts itself.
|
||||
* Wires up every "Lancer une watch party" button inside `container`, now
|
||||
* and for any tile added to it later. `onStart(itemId)` does the actual
|
||||
* work (create the party + navigate) - kept as a caller-supplied callback
|
||||
* 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 {
|
||||
container.querySelectorAll<HTMLButtonElement>('.startPartyBtn').forEach((btn) => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
container.addEventListener('click', async (e) => {
|
||||
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>('.startPartyBtn');
|
||||
if (!btn || btn.disabled) return;
|
||||
e.preventDefault();
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Démarrage...';
|
||||
|
|
@ -86,7 +93,36 @@ export function wireTileButtons(container: ParentNode, onStart: (itemId: string)
|
|||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { getArtists, getLibraries, getLibraryItems } from '../../api/embyApi';
|
|||
import { createParty } from '../../api/party';
|
||||
import { navigate } from '../../router';
|
||||
import type { RouteParams } from '../../router';
|
||||
import { tileHtml, wireSearchFilter, wireTileButtons } from '../../tiles';
|
||||
import { renderTilesInChunks, wireSearchFilter, wireTileButtons } from '../../tiles';
|
||||
import { LOADING_HINT } from '../../loading';
|
||||
|
||||
/**
|
||||
|
|
@ -55,7 +55,7 @@ export async function renderLibrary(root: HTMLElement, params: RouteParams): Pro
|
|||
return;
|
||||
}
|
||||
|
||||
grid.innerHTML = items.map(tileHtml).join('');
|
||||
renderTilesInChunks(grid, items);
|
||||
wireTileButtons(grid, async (itemId) => {
|
||||
const party = await createParty(itemId);
|
||||
navigate(`/party/${party.partyId}`);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Emby token" that the rest of the app is built around.
|
||||
|
||||
**Scoped to video parties only** - the spec's every concrete reference
|
||||
(the player, the party-stream/burn routes, the burn-in fallback) is
|
||||
video-specific, and `MusicBar.ts` has no comparable control surface
|
||||
today. Casting a listen-party would be a fast follow, not this.
|
||||
**Scoped to video parties for v1** - the spec's every concrete reference
|
||||
(the player, the party-stream/burn routes, the burn-in fallback) was
|
||||
video-specific, and `MusicBar.ts` had no comparable control surface at
|
||||
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
|
||||
`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
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
Asked for directly - a UI rework of the video player, "in both normal
|
||||
|
|
|
|||
|
|
@ -199,7 +199,11 @@ class CatalogServiceClass {
|
|||
// structure, same reasoning as Shows -> Seasons -> Episodes above.
|
||||
|
||||
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.');
|
||||
return (data.Items || []).map((a) => this.toItemDto(a, session.serverId));
|
||||
}
|
||||
|
|
@ -230,11 +234,19 @@ class CatalogServiceClass {
|
|||
}
|
||||
|
||||
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({
|
||||
ParentId: libraryId,
|
||||
Recursive: 'false', // top-level: Movies as movies, TV as Series tiles (not every episode)
|
||||
SortBy: 'SortName',
|
||||
Fields: 'Overview,ProductionYear,AlbumId',
|
||||
Fields: 'ProductionYear,AlbumId',
|
||||
ImageTypeLimit: '1',
|
||||
Limit: '300'
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue