watchparty/client/js/views/player.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

398 lines
16 KiB
JavaScript

import { streamUrl, getPlaybackOptions, auth } from '../embyApi.js';
import { getParty, leaveParty, connectParty } from '../party.js';
import { navigate } from '../router.js';
import { escapeHtml, colorForName, initialsFor } from '../tiles.js';
import { renderMusicPlayer } from './musicPlayer.js';
const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns
const DRIFT_TOLERANCE_SECONDS = 1.5;
const PAUSE_REQUEST_COOLDOWN_MS = 5000; // matches the server's own throttle in socketHandler.js
const PAUSE_REQUEST_HIGHLIGHT_MS = 4000; // how long the banner + the orange badge stay up
// "Quality" is really "max transcoding bitrate", the same simplification
// Emby's/Jellyfin's own web clients use - not true adaptive-bitrate
// switching. See docs/architecture.md.
const QUALITY_PRESETS = [
{ label: 'Auto (qualité source)', value: '' },
{ label: '1080p (~8 Mbps)', value: '8000000' },
{ label: '720p (~4 Mbps)', value: '4000000' },
{ label: '480p (~1.5 Mbps)', value: '1500000' }
];
// Plain inline SVG rather than a Unicode glyph (unlike the settings gear)
// specifically so this small a badge stays a crisp two-bar shape regardless
// of the platform's emoji font - pause/play glyphs default to emoji
// presentation on several platforms, which looks muddy at 10px.
const PAUSE_ICON_SVG = `
<svg viewBox="0 0 10 10" width="10" height="10" aria-hidden="true">
<rect x="1.5" y="1" width="2.4" height="8" fill="#fff"></rect>
<rect x="6.1" y="1" width="2.4" height="8" fill="#fff"></rect>
</svg>`;
/**
* Route `/party/:id` - dispatches on the party's mediaType, fetched here
* once, since both renderVideoPlayer and renderMusicPlayer need to know it
* before they can render anything at all.
*
* Has to return its cleanup function *synchronously* (route()'s contract -
* see router.js), but which cleanup function that even is depends on a
* fetch that hasn't resolved yet at the time this returns. Solved with a
* mutable box: an outer cleanup wrapper is handed back immediately, and it
* calls whatever the real cleanup turns out to be once the fetch settles -
* or nothing at all, correctly, if the fetch is still pending or failed
* when it runs (nothing was ever set up in that case - no socket, no
* timer, nothing to leak).
*/
export function renderPartyPlayer(root, params) {
const partyId = params.id;
root.innerHTML = '<p class="hint">Chargement...</p>';
let cleanup = () => {};
let cancelled = false;
getParty(partyId).then((party) => {
if (cancelled) return;
cleanup = (party.mediaType === 'music' ? renderMusicPlayer : renderVideoPlayer)(root, partyId, party) || (() => {});
}).catch((err) => {
if (cancelled) return;
// Full error (not just .message) to the console - this also catches a
// rendering bug in renderVideoPlayer/renderMusicPlayer itself, not just
// a genuine "party not found", so the stack trace matters here.
console.error('[watchparty player] failed to open party', err);
alert(err.message);
navigate('/home');
});
return function outerCleanup() {
cancelled = true;
cleanup();
};
}
/**
* Renders the video half of a party - called by the dispatcher above once
* it already knows (from a fetched `party`) that mediaType isn't 'music'.
* Takes the already-fetched party instead of fetching it itself: the
* dispatcher needs to fetch it anyway just to read mediaType, and
* route()'s contract needs its cleanup function back *synchronously* (see
* router.js) - an async renderVideoPlayer awaiting its own fetch would
* return a Promise instead, which router.js's `typeof cleanup ===
* 'function'` check would silently skip, reintroducing the exact leak
* class this app already spent real effort fixing once.
*/
function renderVideoPlayer(root, partyId, party) {
root.innerHTML = `
<div class="playerWrap">
<video id="video" playsinline></video>
<p id="loadingNotice" class="loadingNotice">Chargement du flux...</p>
<div id="playerError" class="playerError hide">
<p id="playerErrorText"></p>
<button id="playerErrorRetry" class="linklike">Réessayer</button>
</div>
<div id="pauseRequestBanner" class="pauseRequestBanner hide"></div>
<div class="topOverlay">
<div class="actionBar">
<span id="itemTitle" class="itemTitle"></span>
<span id="members" class="members"></span>
<button id="nextBtn" class="linklike hide">Épisode suivant &rarr;</button>
<div id="settingsWrap" class="settingsWrap hide">
<button id="settingsBtn" class="iconBtn" title="Paramètres de lecture" aria-label="Paramètres de lecture">&#9881;</button>
<div id="settingsPanel" class="settingsPanel hide">
<label>Langue <select id="audioSelect"></select></label>
<label>Sous-titres <select id="subtitleSelect"><option value="">Aucun</option></select></label>
<label>Qualité <select id="qualitySelect"></select></label>
</div>
</div>
<button id="leaveBtn" class="linklike">Quitter</button>
</div>
<div id="presenceOverlay" class="presenceOverlay"></div>
</div>
<p id="followerNotice" class="followerNotice hide">L'hôte contrôle la lecture.</p>
</div>`;
const video = root.querySelector('#video');
const loadingNotice = root.querySelector('#loadingNotice');
const errorBox = root.querySelector('#playerError');
const errorText = root.querySelector('#playerErrorText');
const itemTitleEl = root.querySelector('#itemTitle');
const membersEl = root.querySelector('#members');
const presenceOverlay = root.querySelector('#presenceOverlay');
const pauseBanner = root.querySelector('#pauseRequestBanner');
const nextBtn = root.querySelector('#nextBtn');
const followerNotice = root.querySelector('#followerNotice');
const settingsWrap = root.querySelector('#settingsWrap');
const settingsBtn = root.querySelector('#settingsBtn');
const settingsPanel = root.querySelector('#settingsPanel');
const audioSelect = root.querySelector('#audioSelect');
const subtitleSelect = root.querySelector('#subtitleSelect');
const qualitySelect = root.querySelector('#qualitySelect');
qualitySelect.innerHTML = QUALITY_PRESETS.map((q) => `<option value="${q.value}">${q.label}</option>`).join('');
// Named (not inline-anonymous) specifically so cleanup() below can remove
// this exact listener - document.addEventListener is a page-lifetime
// target, so an anonymous one added on every visit to this route would
// never be reachable to remove, and would pile up across visits. This was
// the actual leak: every join-a-party-then-navigate-away-without-"Quitter"
// used to leave one of these (plus the heartbeat interval, the socket,
// and the hls.js instance) running forever - see router.js's cleanup hook.
function onDocumentClick(e) {
if (!settingsWrap.contains(e.target)) settingsPanel.classList.add('hide');
}
document.addEventListener('click', onDocumentClick);
settingsBtn.addEventListener('click', (e) => {
e.stopPropagation();
settingsPanel.classList.toggle('hide');
});
let isHost = false;
let hasNextQueued = false;
let conn = null;
let hls = null;
let currentItemId = null;
let suppressLocalEvents = false; // avoid feeding our own correction back as a new event
let heartbeat = null;
let pauseRequestCooldownUntil = 0;
let pauseBannerTimer = null;
function showError(message, detail) {
loadingNotice.classList.add('hide');
errorText.textContent = message;
errorBox.classList.remove('hide');
console.error('[watchparty player]', message, detail || '');
}
function clearError() {
errorBox.classList.add('hide');
}
function applyRemoteState(msg) {
suppressLocalEvents = true;
const targetSeconds = msg.positionTicks / TICKS_PER_SECOND;
if (Math.abs(video.currentTime - targetSeconds) > DRIFT_TOLERANCE_SECONDS) {
video.currentTime = targetSeconds;
}
if (msg.isPaused && !video.paused) video.pause();
if (!msg.isPaused && video.paused) video.play().catch(() => {});
setTimeout(() => { suppressLocalEvents = false; }, 250);
}
function sendLocalState() {
if (!isHost || suppressLocalEvents || !conn) return;
conn.sendPlaystate(video.paused, Math.round(video.currentTime * TICKS_PER_SECOND));
}
/**
* Promotes this client to host - called either at init (if we already
* were) or on a live "hostChanged" event (the previous host left and the
* server picked us next - see socketHandler.js's applyLeave). Safe to
* call more than once: the early return covers the normal case, and
* addEventListener with the same function reference is a no-op if already
* attached, so even a re-entrant call can't double up the play/pause/
* seeked listeners. The heartbeat is guarded separately since setInterval
* has no such built-in idempotence - calling it twice would leak the
* first interval's id.
*/
function becomeHost(notify) {
if (isHost) return;
isHost = true;
video.controls = true;
settingsWrap.classList.remove('hide');
followerNotice.classList.add('hide');
nextBtn.classList.toggle('hide', !hasNextQueued);
video.addEventListener('play', sendLocalState);
video.addEventListener('pause', sendLocalState);
video.addEventListener('seeked', sendLocalState);
video.addEventListener('ended', () => { if (hasNextQueued) conn.next(); });
if (!heartbeat) heartbeat = setInterval(sendLocalState, 3000);
if (notify) alert("L'hôte précédent a quitté la party - tu es maintenant l'hôte.");
}
async function loadTrackOptions(itemId) {
try {
const options = await getPlaybackOptions(itemId);
audioSelect.innerHTML = options.audioTracks.length
? options.audioTracks.map((t) => `<option value="${t.index}">${escapeHtml(t.label)}</option>`).join('')
: '<option value="">Piste par défaut</option>';
subtitleSelect.innerHTML = '<option value="">Aucun</option>' +
options.subtitleTracks.map((t) => `<option value="${t.index}">${escapeHtml(t.label)}</option>`).join('');
} catch (err) {
console.error('[watchparty player] playback-options failed', err);
// Non-fatal: playback can still work with Emby's own defaults, just without a picker.
}
}
function loadVideo(itemId, resumeSeconds, wasPlaying) {
currentItemId = itemId;
clearError();
loadingNotice.classList.remove('hide');
if (hls) {
hls.destroy();
hls = null;
}
const src = streamUrl(itemId, {
AudioStreamIndex: audioSelect.value,
SubtitleStreamIndex: subtitleSelect.value,
MaxStreamingBitrate: qualitySelect.value
});
const resume = () => {
loadingNotice.classList.add('hide');
if (resumeSeconds) video.currentTime = resumeSeconds;
if (wasPlaying) video.play().catch(() => {});
};
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) return;
showError(
"Erreur de lecture (HLS) - le flux n'a pas pu être chargé depuis Emby.",
data
);
});
hls.loadSource(src);
hls.attachMedia(video);
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
// Safari plays HLS natively - no hls.js involved, so wire the plain
// <video> element's own error event instead.
video.src = src;
video.addEventListener('loadedmetadata', resume, { once: true });
video.addEventListener('error', () => {
showError('Erreur de lecture vidéo.', video.error);
});
} else {
showError("Ce navigateur ne sait pas lire de flux HLS et hls.js n'a pas pu se charger.");
}
}
// Changing language/subtitles/quality means a fresh transcode (Emby bakes
// the selection in server-side - see index.js) - reload at the same
// position instead of restarting from zero.
function reloadWithCurrentOptions() {
loadVideo(currentItemId, video.currentTime, !video.paused);
}
[audioSelect, subtitleSelect, qualitySelect].forEach((sel) => {
sel.addEventListener('change', () => {
settingsPanel.classList.add('hide');
reloadWithCurrentOptions();
});
});
root.querySelector('#playerErrorRetry').addEventListener('click', () => {
if (currentItemId) loadVideo(currentItemId, video.currentTime, true);
});
async function onItemChanged(msg) {
itemTitleEl.textContent = msg.itemName || '';
hasNextQueued = !!msg.hasNext;
nextBtn.classList.toggle('hide', !(isHost && hasNextQueued));
await loadTrackOptions(msg.itemId);
loadVideo(msg.itemId, 0, true);
}
// Presence overlay: one semi-transparent circle per member, initials only
// (no photos - Emby accounts don't reliably have one). Your own circle
// doubles as the "request a pause" button when you're not the host - the
// host already has direct controls, so their own circle is just presence.
// The pause-badge <span> is always in the markup, just hidden by
// .avatarPauseRequested's CSS - see onPauseRequested(), which is the only
// thing that ever shows it.
function renderMembers(members) {
membersEl.textContent = members
.map((m) => m.userName + (m.isHost ? ' (hôte)' : ''))
.join(', ');
presenceOverlay.innerHTML = members.map((m) => {
const isMe = m.userName === auth.userName;
const clickable = isMe && !m.isHost;
return `
<div
class="avatar${clickable ? ' avatarClickable' : ''}"
style="background:${colorForName(m.userName)}"
data-user-id="${escapeHtml(m.userId)}"
title="${escapeHtml(m.userName)}${clickable ? ' - demander une pause' : ''}"
>${escapeHtml(initialsFor(m.userName))}<span class="avatarPauseBadge">${PAUSE_ICON_SVG}</span></div>`;
}).join('');
if (isHost) return; // the host pauses directly, no need for the request affordance
const mine = presenceOverlay.querySelector('.avatarClickable');
if (mine) {
mine.addEventListener('click', () => {
const now = Date.now();
if (now < pauseRequestCooldownUntil || !conn) return;
pauseRequestCooldownUntil = now + PAUSE_REQUEST_COOLDOWN_MS;
conn.requestPause();
});
}
}
// Broadcast to everyone in the party, including the requester and the
// host (see socketHandler.js) - so this always flags the right circle no
// matter whose screen it renders on.
function onPauseRequested(msg) {
pauseBanner.textContent = `${msg.userName} demande une pause`;
pauseBanner.classList.remove('hide');
if (pauseBannerTimer) clearTimeout(pauseBannerTimer);
pauseBannerTimer = setTimeout(() => pauseBanner.classList.add('hide'), PAUSE_REQUEST_HIGHLIGHT_MS);
const avatar = presenceOverlay.querySelector(`.avatar[data-user-id="${CSS.escape(msg.userId)}"]`);
if (avatar) {
avatar.classList.add('avatarPauseRequested');
setTimeout(() => avatar.classList.remove('avatarPauseRequested'), PAUSE_REQUEST_HIGHLIGHT_MS);
}
}
async function init() {
itemTitleEl.textContent = party.itemName || '';
hasNextQueued = !!party.hasNext;
followerNotice.classList.toggle('hide', party.youAreHost);
await loadTrackOptions(party.itemId);
loadVideo(party.itemId, 0, false);
conn = connectParty(partyId, {
onMembers: renderMembers,
onPlaystate: applyRemoteState,
onItemChanged,
onHostChanged: () => becomeHost(true),
onPauseRequested,
onError: (message) => alert(message),
onClose: () => {}
});
if (party.youAreHost) becomeHost(false);
}
nextBtn.addEventListener('click', () => { if (conn) conn.next(); });
root.querySelector('#leaveBtn').addEventListener('click', () => navigate('/home'));
init();
// Called by router.js right before it renders whatever route comes next -
// whether that's from clicking "Quitter" (navigate above) or leaving any
// other way (sidebar link, browser back, ...). This is the fix for the
// actual leak: previously, only the "Quitter" button ran any of this.
return function cleanup() {
if (heartbeat) clearInterval(heartbeat);
if (pauseBannerTimer) clearTimeout(pauseBannerTimer);
if (hls) hls.destroy();
document.removeEventListener('click', onDocumentClick);
if (conn) conn.leave();
leaveParty(partyId).catch(() => {});
};
}