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 = `
-
▶
0:00 / 0:00
Passer (vote)
`;
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 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) => `
${escapeHtml(t.itemName)}
${escapeHtml([t.artistName, t.albumName].filter(Boolean).join(' - '))}
${t.durationSeconds ? formatDuration(t.durationSeconds) : ''}
Ajouté par ${escapeHtml(t.addedByUserName)}
`).join('')
: 'Rien en attente.
';
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) => `
${escapeHtml(initialsFor(m.userName))}
`).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 = 'Chargement...
';
try {
const libraries = (await getLibraries()).filter((l) => l.collectionType === 'music');
if (!libraries.length) {
browsePanel.innerHTML = 'Aucune bibliothèque musicale.
';
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 = 'Impossible de charger les bibliothèques.
';
}
}
async function loadArtists(libraryId) {
browsePanel.innerHTML = 'Chargement...
';
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 = 'Impossible de charger les artistes.
';
}
}
async function loadAlbums(artistId) {
browsePanel.innerHTML = 'Chargement...
';
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 = 'Impossible de charger les albums.
';
}
}
async function loadTracks(albumId) {
browsePanel.innerHTML = 'Chargement...
';
try {
const tracks = await getAlbumTracks(albumId);
browsePanel.innerHTML = `
← Retour
${tracks.map((t) => `
${t.indexNumber != null ? t.indexNumber : ''}
${escapeHtml(t.name)}
${t.durationTicks ? formatDuration(t.durationTicks / 10000000) : ''}
Ajouter
`).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 = 'Impossible de charger les morceaux.
';
}
}
function renderBrowseList(items, onBack) {
const back = onBack ? '← Retour ' : '';
const body = items.length
? items.map((i) => `${escapeHtml(i.name)} `).join('')
: 'Rien à afficher.
';
return `${back}${body}
`;
}
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(() => {});
};
}