Add spotify queue
Deploy HEOS panel / deploy (push) Successful in 30s

This commit is contained in:
franzz committed 2026-10-20 15:44:56 +02:00
1 parent 6c76121863
commit 6bee9bd9a3
13 files changed
+230 -5

No files matched your search

+91
View File
@@ -127,6 +127,10 @@ els('.card[data-room]').forEach((node) => {
artist: el('[data-role="artist"]', node),
progress: el('[data-role="progress"]', node),
steps: els('.step', node),
queue: el('[data-role="queue"]', node),
queueList: el('[data-role="queue-list"]', node),
queueFor: null, // the song the queue on show was fetched behind
queueLoading: false,
volume: null,
playState: null,
track: null, // {song, artist, image} while something is loaded
@@ -162,6 +166,9 @@ els('.card[data-room]').forEach((node) => {
// the next poll does not try it again until the track changes.
room.cover.addEventListener('error', () => { room.cover.hidden = true; });
// Asked for afresh on every unfold: songs queued from the Spotify app in
// the meantime would not otherwise turn up until the track changed.
if (room.queue) room.queue.addEventListener('toggle', () => { if (room.queue.open) loadQueue(room); });
if (room.toggle) room.toggle.addEventListener('click', () => setGrouped(key, !room.grouped));
if (room.prev) room.prev.addEventListener('click', () => skipTrack(key, 'previous'));
room.play.addEventListener('click', () => togglePlay(key));
@@ -226,9 +233,93 @@ function paintRoom(room) {
paintVolume(room);
paintTransport(room);
paintSources(room);
paintQueue(room);
placeRoom(room);
}
/* --- Spotify queue: what comes next, folded at the foot of a room's card.
It costs a Spotify call, so it is only fetched while unfolded -- on the
unfold, and again whenever the song changes under it. ------------------ */
function queueKey(room) {
return room.track ? `${room.spotifyAccount}|${room.track.song}|${room.track.artist}` : room.spotifyAccount;
}
function paintQueue(room) {
if (!room.queue) return;
// Shown for the same stream the transport buttons are: one of your accounts'.
const shown = room.available && room.onSpotify && Boolean(room.spotifyAccount) && !room.grouped;
room.queue.hidden = !shown;
if (!shown) {
// Folded again for next time, rather than reopening on a stale list.
room.queue.open = false;
room.queueFor = null;
return;
}
if (room.queue.open && room.queueFor !== queueKey(room)) loadQueue(room);
}
async function loadQueue(room) {
if (room.queueLoading) return;
room.queueLoading = true;
const wanted = queueKey(room);
room.queueFor = wanted;
// Only an empty box says it is loading; a list already there stays put
// until the new one replaces it.
if (!room.queueList.children.length) renderQueueLoading(room);
try {
const data = await api(`/api/spotify/queue?target=${encodeURIComponent(room.key)}`);
renderQueue(room, data.queue || []);
} catch (error) {
renderQueueNote(room, error.message);
room.queueFor = null; // so the next poll tries again
} finally {
room.queueLoading = false;
}
// The song moved on while this was in flight.
if (room.queueFor !== null && room.queueFor !== queueKey(room)) paintQueue(room);
}
function renderQueueNote(room, text) {
const note = document.createElement('li');
note.className = 'queue-note';
note.textContent = text;
room.queueList.replaceChildren(note);
}
/* Font Awesome's ellipsis, one path per dot so panel.css can blink them in turn. */
function renderQueueLoading(room) {
const note = document.createElement('li');
note.className = 'queue-note';
note.setAttribute('aria-label', 'Loading');
note.innerHTML = '<svg class="queue-loading" viewBox="0 0 448 512" aria-hidden="true">'
+ '<path d="M0 256a56 56 0 1 1 112 0 56 56 0 1 1 -112 0z"/>'
+ '<path d="M168 256a56 56 0 1 1 112 0 56 56 0 1 1 -112 0z"/>'
+ '<path d="M336 256a56 56 0 1 1 112 0 56 56 0 1 1 -112 0z"/></svg>';
room.queueList.replaceChildren(note);
}
function renderQueue(room, items) {
if (!items.length) {
renderQueueNote(room, 'Nothing queued');
return;
}
room.queueList.replaceChildren(...items.map((item) => {
const row = document.createElement('li');
row.className = 'queue-item';
row.innerHTML = '<img class="cover" alt="" loading="lazy"><div class="track"><span class="song"></span><span class="artist"></span></div><span class="queue-time"></span>';
const cover = el('.cover', row);
if (item.image) {
cover.addEventListener('error', () => { cover.style.visibility = 'hidden'; });
cover.src = item.image;
}
el('.song', row).textContent = item.song;
el('.artist', row).textContent = item.artist || '';
el('.artist', row).hidden = !item.artist;
el('.queue-time', row).textContent = item.duration_ms ? clock(Math.floor(item.duration_ms / 1000)) : '';
return row;
}));
}
/* Song, artist, cover and progress, each only when there is one. The
cover's src is only touched when the track changes, so a poll never makes
it flicker. */