watchparty/client/js/views/musicPlayer.js
Nicolas efaa7f55f0
All checks were successful
Publier l'image Docker / publish (push) Successful in 7s
Add music: Artist/Album/Track browsing + participative listen-party queue
The last deferred piece from the original spec. Chosen permission model
(asked, not assumed): anyone can add a track to the shared queue any time;
anyone can pause/resume directly; skipping the current track needs a
strict majority vote of currently connected members. No host for
playback - a genuinely different shape of shared state than video's "host
drives, everyone follows", which is why it's a separate service
(server/src/musicQueueService.js, MusicQueueService) rather than more
branches in partyManager.js/socketHandler.js's existing video-shaped logic.

Server:
- musicQueueService.js: owns a music party's queue/current-track/pause/
  vote-skip state. Server-authoritative timing (no client is "the host"
  reporting position) - a wall-clock startedUtc plus the track's known
  duration is the only state; auto-advance is one setTimeout scheduled
  for exactly when that runs out, not any client reporting "it ended"
  (which could race across several players finishing near-simultaneously
  with no host to arbitrate). attachIo(io, onTrackChanged), called once
  at startup like PartyManager's own wiring, lets it broadcast
  "queueUpdated" from inside that timer's callback, which has no
  socket/request in scope at all.
- The timer is the one new leak surface this adds, given how much this
  project has already had to fix real leaks - destroy(party) clears it,
  wired into both of partyManager.js's existing destruction paths
  (leave's last-member case, removeIdleParties' sweep).
- Party gained mediaType: 'video'|'music' (inferred server-side from the
  clicked item's Emby Type - the client never has to know or say which).
- Three new catalog endpoints (Artists -> Albums -> Tracks), same
  reasoning as the existing Shows -> Seasons -> Episodes: a real Emby
  endpoint per level, not generic recursive /Items guessing.
- Audio streams through the *same* /master.m3u8 route as video (Emby's
  PlaybackInfo negotiation isn't video-specific) - the one difference is
  the hand-built-fallback path prefix (/Audio/ vs /Videos/), which the
  client tags via a mediaType=audio query hint rather than costing an
  extra Emby round trip to detect server-side.
- Adding a track is REST (POST /api/parties/:partyId/tracks), not a
  socket event like voteSkip/musicPlaystate - it needs to work from the
  player's inline browse-and-add panel regardless of realtime-connection
  specifics, and MusicQueueService's own broadcast reaches everyone
  connected either way.

Client:
- player.js's renderPartyPlayer is now a small dispatcher: fetches the
  party once, then delegates to renderVideoPlayer or the new
  renderMusicPlayer (views/musicPlayer.js) based on mediaType. Can't be
  async despite awaiting that fetch - route()'s contract needs its
  cleanup function back synchronously (router.js's `typeof cleanup ===
  'function'` check would silently skip a Promise, reintroducing the
  exact leak class already fixed once) - solved with a mutable box
  returned immediately, filled in once the fetch resolves.
- musicPlayer.js: not full-viewport/theater-mode like the video player
  (no image to declutter around, sidebar stays up) - which is what makes
  its own inline Artist -> Album -> Track browser work for "add tracks
  while listening" without navigating away and losing player state or
  threading party context through query params across three route levels.
- New views/artist.js, views/album.js (library.js's music branch renders
  an artist grid instead of the old placeholder). tiles.js's DRILL_DOWN
  table generalizes the old single seriesHref special-case now that
  there are three drill-down targets, not two.
- party.js: addTrackToQueue (REST) alongside voteSkip/setMusicPaused
  (socket) on the existing connectParty() connection object.

Real bug found while testing, not just the mock's fault: applyMusicState
used to update the queue list/vote button *after* the current-track
branch, which calls loadTrack() - and loadTrack()'s hls.js setup can
throw. Since JS doesn't roll back earlier statements when a later one
throws but does abort everything after it, an exception there took the
unrelated queue list and vote tally down with it, and on the first render
escaped all the way to the dispatcher's catch, which alerts and bounces
back to /home - discarding the whole view over what's really just "this
one track's audio failed to load". Fixed by reordering (queue/vote/icon
updates - which can't fail - before the track-loading branch, mirroring
how player.js's onItemChanged already updates title/next-button before
calling loadVideo) and wrapping loadTrack's hls.js setup in its own
try/catch so a failure there logs and stops instead of propagating.

Verified server-side directly against the real shipped modules (not
reimplemented mocks): 18 checks against musicQueueService.js alone (auto
-start, queueing, vote-skip majority, pause/resume elapsed-time math, a
real setTimeout auto-advance, timer cleanup on destroy - instrumented
setTimeout/clearTimeout counting, same technique as the earlier client
-side leak tests) and 7 more through socketHandler.js + partyManager.js
together (no-ops on a video party, a non-host pausing/resuming, a
two-vote majority skip, the resulting broadcast really reaching the
room). Client-side against the real local server with mocked fetch/Hls/
io: the full browsing chain, party creation, the dispatcher choosing the
right player (and still choosing video when mediaType is absent - back
-compat), now-playing/vote-tally/queue rendering and updates including a
live track change, play/pause and vote-skip emitting the right events,
the inline browse-and-add panel POSTing the right payload, and the crash
fix re-confirmed fixed via the same broken scenario that first found it.

Not verified: the real Emby round trip for any of this (Artists/albums
-tracks response shapes, audio PlaybackInfo/TranscodingUrl behavior,
actual playback quality) - same limit as everything else in this project
tested without live Emby access.

docs/architecture.md has the full writeup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 01:49:22 +02:00

338 lines
15 KiB
JavaScript

import { getLibraries, getArtists, getArtistAlbums, getAlbumTracks, audioStreamUrl } from '../embyApi.js';
import { leaveParty, connectParty, addTrackToQueue } from '../party.js';
import { navigate } from '../router.js';
import { escapeHtml, formatDuration, colorForName, initialsFor } from '../tiles.js';
/**
* Music half of the party route (see player.js's dispatcher). Deliberately
* NOT full-viewport/theater-mode like the video player: there's no image
* to declutter around, so the sidebar stays up and this renders as a
* normal page - which also means "browse and add a track" can happen
* inline, right here, instead of navigating away and losing player state.
*
* Permission model (chosen, not assumed - see session history): anyone can
* add a track to the shared queue at any time; anyone can pause/resume;
* skipping the current track needs a strict majority vote of currently
* connected members. No host-only controls at all - see
* server/src/musicQueueService.js for the full reasoning and the
* server-authoritative auto-advance timer that drives this without any
* single client having to be "in charge".
*/
export function renderMusicPlayer(root, partyId, party) {
root.innerHTML = `
<div class="pageHeader">
<h1>Écoute partagée</h1>
<button class="linklike" id="leaveBtn">Quitter</button>
</div>
<div id="presenceOverlay" class="presenceOverlay musicPresence"></div>
<div class="nowPlaying" id="nowPlaying">
<img id="trackImage" class="nowPlayingImage" alt="" onerror="this.classList.add('noImage')" />
<div class="nowPlayingInfo">
<strong id="trackName">-</strong>
<span class="hint" id="trackArtist"></span>
<div class="nowPlayingControls">
<button class="iconBtn" id="playPauseBtn" title="Lecture / pause" aria-label="Lecture / pause">&#9654;</button>
<span id="trackPosition" class="hint">0:00 / 0:00</span>
<button class="linklike" id="voteSkipBtn">Passer (vote)</button>
</div>
</div>
</div>
<audio id="audio"></audio>
<div class="pageHeader">
<h2>File d'attente</h2>
<button class="linklike" id="toggleBrowseBtn">Ajouter des morceaux</button>
</div>
<div id="queueList" class="trackList"><p class="hint">Rien en attente.</p></div>
<div id="browsePanel" class="browsePanel hide"></div>`;
const audio = root.querySelector('#audio');
const presenceOverlay = root.querySelector('#presenceOverlay');
const trackImage = root.querySelector('#trackImage');
const trackName = root.querySelector('#trackName');
const trackArtist = root.querySelector('#trackArtist');
const playPauseBtn = root.querySelector('#playPauseBtn');
const trackPosition = root.querySelector('#trackPosition');
const voteSkipBtn = root.querySelector('#voteSkipBtn');
const queueList = root.querySelector('#queueList');
const browsePanel = root.querySelector('#browsePanel');
let conn = null;
let hls = null;
let currentItemId = null;
let currentDurationSeconds = 0;
let suppressLocalEvents = false; // avoid feeding our own correction back as a new event - same pattern as player.js
let positionTicker = null;
let latestMembers = [];
// Same HLS pipeline as the video player (player.js's loadVideo) - Emby's
// PlaybackInfo negotiation isn't video-specific, so its TranscodingUrl
// for a music track just as plausibly comes back HLS-shaped, and most
// browsers (everything but Safari) have no native .m3u8 support at all -
// an <audio src="...m3u8"> alone would silently just not play anywhere
// but Safari. `elapsedSeconds`/`isPaused` are applied once the manifest
// is actually parsed, not immediately after setting src - setting
// currentTime before a fresh source has loaded metadata is unreliable
// and gets silently reset by most browsers.
function loadTrack(track, elapsedSeconds, isPaused) {
currentItemId = track.itemId;
currentDurationSeconds = track.durationSeconds || 0;
trackImage.src = track.itemImageUrl || '';
trackImage.classList.remove('noImage');
trackName.textContent = track.itemName || '';
trackArtist.textContent = [track.artistName, track.albumName].filter(Boolean).join(' - ');
if (hls) {
hls.destroy();
hls = null;
}
const src = audioStreamUrl(track.itemId);
const resume = () => {
suppressLocalEvents = true;
audio.currentTime = elapsedSeconds || 0;
if (!isPaused) audio.play().catch(() => {});
setTimeout(() => { suppressLocalEvents = false; }, 250);
};
// Caught, not left to propagate: this runs from inside applyMusicState,
// which every "queueUpdated" push calls - an uncaught throw here would
// abort that entire handler (and, the first time it happens, the
// player's whole synchronous init - see player.js's dispatcher), taking
// the queue list and vote button down with a problem that's really
// just "this one track's audio failed to load". A skip vote is still
// this feature's actual recovery path (see musicQueueService.js) - this
// just needs to not crash on the way there.
try {
if (window.Hls && window.Hls.isSupported()) {
hls = new window.Hls();
hls.on(window.Hls.Events.MANIFEST_PARSED, resume);
hls.on(window.Hls.Events.ERROR, (event, data) => {
if (data.fatal) console.error('[watchparty musicPlayer] HLS error', data);
});
hls.loadSource(src);
hls.attachMedia(audio);
} else if (audio.canPlayType('application/vnd.apple.mpegurl')) {
audio.src = src;
audio.addEventListener('loadedmetadata', resume, { once: true });
} else {
console.error('[watchparty musicPlayer] no HLS support and hls.js unavailable');
}
} catch (err) {
console.error('[watchparty musicPlayer] failed to load track audio', err);
}
}
function applyMusicState(music) {
// Queue/vote-button/play-icon updates happen first, unconditionally -
// deliberately BEFORE the track-loading branch below, which touches
// hls.js and could throw (a bad manifest, a network hiccup). Same
// ordering the video player already uses (see onItemChanged in
// player.js, which updates the title/next-button before calling
// loadVideo): a media-loading failure should never also take the
// queue list and vote tally down with it, since they're not actually
// related to whether THIS particular track's audio loaded.
playPauseBtn.textContent = music.current && !music.current.isPaused ? '⏸' : '▶';
playPauseBtn.disabled = !music.current;
voteSkipBtn.disabled = !music.current;
// "needed" mirrors the exact math musicQueueService.js uses server-side
// (strict majority of currently connected members) - computed here from
// the member list the "members" event already provides, rather than
// MusicQueueService's own broadcast repeating that count on every
// single queue change (see its getState()'s own comment on this).
const connectedCount = latestMembers.filter((m) => m.connected).length || 1;
const needed = Math.floor(connectedCount / 2) + 1;
voteSkipBtn.textContent = music.current ? `Passer (vote) - ${music.skipVotes}/${needed}` : 'Passer (vote)';
queueList.innerHTML = music.queue.length
? music.queue.map((t) => `
<div class="trackRow">
<span class="trackName">${escapeHtml(t.itemName)}</span>
<span class="hint">${escapeHtml([t.artistName, t.albumName].filter(Boolean).join(' - '))}</span>
<span class="trackDuration">${t.durationSeconds ? formatDuration(t.durationSeconds) : ''}</span>
<span class="hint">Ajouté par ${escapeHtml(t.addedByUserName)}</span>
</div>`).join('')
: '<p class="hint">Rien en attente.</p>';
if (!music.current) {
currentItemId = null;
trackName.textContent = 'Rien ne joue pour le moment.';
trackArtist.textContent = '';
trackImage.src = '';
if (hls) { hls.destroy(); hls = null; }
audio.pause();
audio.removeAttribute('src');
} 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.
suppressLocalEvents = true;
if (Math.abs(audio.currentTime - music.current.elapsedSeconds) > 2) {
audio.currentTime = music.current.elapsedSeconds;
}
if (music.current.isPaused && !audio.paused) audio.pause();
if (!music.current.isPaused && audio.paused) audio.play().catch(() => {});
setTimeout(() => { suppressLocalEvents = false; }, 250);
}
}
function sendLocalPlaystate() {
if (suppressLocalEvents || !conn) return;
conn.setMusicPaused(audio.paused);
}
audio.addEventListener('play', sendLocalPlaystate);
audio.addEventListener('pause', sendLocalPlaystate);
playPauseBtn.addEventListener('click', () => {
if (!conn) return;
conn.setMusicPaused(!audio.paused);
});
voteSkipBtn.addEventListener('click', () => { if (conn) conn.voteSkip(); });
// Ticks the visible position label between server updates - the same
// "derive it client-side from a snapshot" approach as the home page's
// party-elapsed-time labels, rather than the server pushing this every
// second for every listener.
positionTicker = setInterval(() => {
if (!currentItemId) return;
trackPosition.textContent = `${formatDuration(audio.currentTime)} / ${formatDuration(currentDurationSeconds)}`;
}, 500);
function renderMembers(members) {
latestMembers = members;
presenceOverlay.innerHTML = members.map((m) => `
<div class="avatar" style="background:${colorForName(m.userName)}" title="${escapeHtml(m.userName)}">
${escapeHtml(initialsFor(m.userName))}
</div>`).join('');
}
// --- Inline "add tracks" browser: artist -> album -> tracks, entirely
// within this page (see class comment for why not a route navigation). ---
let browsing = false;
root.querySelector('#toggleBrowseBtn').addEventListener('click', () => {
browsing = !browsing;
browsePanel.classList.toggle('hide', !browsing);
if (browsing) loadMusicLibraries();
});
async function loadMusicLibraries() {
browsePanel.innerHTML = '<p class="hint">Chargement...</p>';
try {
const libraries = (await getLibraries()).filter((l) => l.collectionType === 'music');
if (!libraries.length) {
browsePanel.innerHTML = '<p class="hint">Aucune bibliothèque musicale.</p>';
return;
}
// Almost always exactly one - skip straight to its artists rather
// than making the common case click through a one-item list.
if (libraries.length === 1) {
loadArtists(libraries[0].id);
} else {
browsePanel.innerHTML = renderBrowseList(libraries.map((l) => ({ id: l.id, name: l.name })), null);
wireBrowseList((id) => loadArtists(id));
}
} catch (err) {
browsePanel.innerHTML = '<p class="error">Impossible de charger les bibliothèques.</p>';
}
}
async function loadArtists(libraryId) {
browsePanel.innerHTML = '<p class="hint">Chargement...</p>';
try {
const artists = await getArtists(libraryId);
browsePanel.innerHTML = renderBrowseList(artists.map((a) => ({ id: a.id, name: a.name })), null);
wireBrowseList((id) => loadAlbums(id));
} catch (err) {
browsePanel.innerHTML = '<p class="error">Impossible de charger les artistes.</p>';
}
}
async function loadAlbums(artistId) {
browsePanel.innerHTML = '<p class="hint">Chargement...</p>';
try {
const albums = await getArtistAlbums(artistId);
browsePanel.innerHTML = renderBrowseList(albums.map((a) => ({ id: a.id, name: a.year ? `${a.name} (${a.year})` : a.name })), () => loadMusicLibraries());
wireBrowseList((id) => loadTracks(id));
} catch (err) {
browsePanel.innerHTML = '<p class="error">Impossible de charger les albums.</p>';
}
}
async function loadTracks(albumId) {
browsePanel.innerHTML = '<p class="hint">Chargement...</p>';
try {
const tracks = await getAlbumTracks(albumId);
browsePanel.innerHTML = `
<button class="linklike browseBack">&larr; Retour</button>
${tracks.map((t) => `
<div class="trackRow">
<span class="trackIndex">${t.indexNumber != null ? t.indexNumber : ''}</span>
<span class="trackName">${escapeHtml(t.name)}</span>
<span class="trackDuration">${t.durationTicks ? formatDuration(t.durationTicks / 10000000) : ''}</span>
<button class="addTrackBtn" data-track="${encodeURIComponent(JSON.stringify({
itemId: t.id, itemName: t.name, artistName: t.artistName, albumName: t.albumName,
itemImageUrl: t.imageUrl, durationTicks: t.durationTicks
}))}">Ajouter</button>
</div>`).join('')}`;
browsePanel.querySelector('.browseBack').addEventListener('click', () => loadMusicLibraries());
browsePanel.querySelectorAll('.addTrackBtn').forEach((btn) => {
btn.addEventListener('click', async () => {
btn.disabled = true;
const originalLabel = btn.textContent;
try {
await addTrackToQueue(partyId, JSON.parse(decodeURIComponent(btn.dataset.track)));
btn.textContent = 'Ajouté !';
setTimeout(() => { btn.textContent = originalLabel; btn.disabled = false; }, 1500);
} catch (err) {
alert(err.message);
btn.disabled = false;
}
});
});
} catch (err) {
browsePanel.innerHTML = '<p class="error">Impossible de charger les morceaux.</p>';
}
}
function renderBrowseList(items, onBack) {
const back = onBack ? '<button class="linklike browseBack">&larr; Retour</button>' : '';
const body = items.length
? items.map((i) => `<button class="linklike browseItem" data-id="${escapeHtml(i.id)}">${escapeHtml(i.name)}</button>`).join('')
: '<p class="hint">Rien à afficher.</p>';
return `${back}<div class="browseList">${body}</div>`;
}
function wireBrowseList(onPick) {
const back = browsePanel.querySelector('.browseBack');
if (back) back.addEventListener('click', () => loadMusicLibraries());
browsePanel.querySelectorAll('.browseItem').forEach((btn) => {
btn.addEventListener('click', () => onPick(btn.dataset.id));
});
}
// --- Init ---
applyMusicState(party.music);
conn = connectParty(partyId, {
onMembers: renderMembers,
onQueueUpdated: applyMusicState,
onError: (message) => alert(message),
onClose: () => {}
});
root.querySelector('#leaveBtn').addEventListener('click', () => navigate('/home'));
return function cleanup() {
if (positionTicker) clearInterval(positionTicker);
if (hls) hls.destroy();
audio.pause();
audio.removeAttribute('src');
if (conn) conn.leave();
leaveParty(partyId).catch(() => {});
};
}