1 parent
6c76121863
commit
6bee9bd9a3
13 files changed
+230
-5
No files matched your search
@@ -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. */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user