Cast a listen party too, and fix a slow/freezing catalog #1
10 changed files with 473 additions and 80 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
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).
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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(); }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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">▶</button>
|
<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="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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue