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. */
+33
View File
@@ -133,6 +133,7 @@ svg { width: 22px; height: 22px; fill: currentColor; }
.ghost { background: transparent; border: 1px solid var(--outline); color: var(--muted); box-shadow: none; }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes blink { 0%, 80%, 100% { opacity: .2; } 40% { opacity: 1; } }
@keyframes rise { from { transform: translateY(100%); opacity: .4; } }
/* Header */
@@ -302,6 +303,38 @@ header .logo { height: 44px; width: auto; display: block; margin: var(--space-xs
.toggle .leave { display: none; }
/* Card - Spotify Queue */
.queue { background: var(--shade); border-radius: var(--radius-lg); }
.queue summary {
display: flex; align-items: center; justify-content: space-between; gap: var(--space-md);
min-height: 44px; padding: 0 var(--space-md);
font-size: var(--text-sm); font-weight: 600; color: var(--muted);
list-style: none; cursor: pointer;
}
.queue summary::-webkit-details-marker { display: none; }
.queue-label { display: flex; align-items: center; gap: var(--space-sm); }
.queue-label svg { width: 16px; height: 16px; }
.queue summary .chevron { width: 16px; height: 16px; transition: transform .2s ease-out; }
.queue[open] summary .chevron { transform: rotate(90deg); }
.queue-list {
list-style: none; margin: 0;
padding: 0 var(--space-md) var(--space-md);
display: flex; flex-direction: column; gap: var(--space-sm);
}
.queue-item { display: flex; align-items: center; gap: var(--space-md); min-width: 0; }
.queue-item .cover { width: 40px; height: 40px; border-radius: 8px; }
.queue-item .track { flex: 1; }
.queue-item .song { font-size: var(--text-sm); }
.queue-item .artist { font-size: var(--text-xs); }
.queue-time { flex: 0 0 auto; font-size: var(--text-xs); color: var(--muted); font-variant-numeric: tabular-nums; }
.queue-note { font-size: var(--text-sm); color: var(--muted); }
.queue-loading { display: block; width: 28px; height: 28px; margin: 0 auto; }
.queue-loading path { animation: blink 1.2s ease-in-out infinite; }
.queue-loading path:nth-child(2) { animation-delay: .2s; }
.queue-loading path:nth-child(3) { animation-delay: .4s; }
/* Card - AVR Input */
.input-select {