Add spotify integration
Deploy HEOS panel / deploy (push) Successful in 25s

This commit is contained in:
2026-09-15 22:07:43 +02:00
parent 6a0f1fa5e8
commit 7c19ff9096
17 changed files with 810 additions and 44 deletions
+52 -6
View File
@@ -42,8 +42,10 @@ async function api(path, body) {
}
let toastTimer;
function toast(message) {
/* Red by default, since most toasts report trouble; 'ok' for a confirmation. */
function toast(message, kind = 'error') {
ui.toast.textContent = message;
ui.toast.classList.toggle('ok', kind === 'ok');
ui.toast.hidden = false;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { ui.toast.hidden = true; }, 4000);
@@ -62,9 +64,12 @@ els('.room').forEach((node) => {
toggleLabel: el('[data-role="group-label"]', node),
play: el('[data-role="play"]', node),
next: el('[data-role="next"]', node),
spotify: el('.spotify-row', node), // absent with no Spotify account set up
steps: els('.step', node),
volume: null,
playState: null,
onSpotify: false,
spotifyAccount: null, // the account playing here, whose button gets a border
grouped: false,
available: false,
taps: 0, // button presses not yet sent
@@ -95,12 +100,24 @@ function paintRoom(room) {
room.play.disabled = !room.available || room.playState === null;
room.play.setAttribute(
'aria-label', `${room.node.querySelector('h2').textContent}: ${playing ? 'pause' : 'play'}`);
// Grouped, transport belongs to the AVR's card, not this one -- pressing
// either here would still work (it shares the group's transport) but
// only invites confusion about which card is actually in charge of it.
room.play.hidden = room.grouped;
room.next.hidden = !playing || room.grouped;
// Play/pause and next only mean something for a Spotify stream: an AVR
// input has no queue to pause or skip, and starting Spotify is what the
// account buttons are for. Grouped, transport belongs to the AVR's card,
// not this one -- pressing either here would still work (it shares the
// group's transport) but only invites confusion about which card is
// actually in charge of it.
const transport = room.onSpotify && !room.grouped;
room.play.hidden = !transport;
room.next.hidden = !transport || !playing;
room.next.disabled = !room.available;
// Resuming stays on offer while grouped: only the speakers have Spotify
// buttons, so hiding them here would leave none at all.
if (room.spotify) {
room.spotify.hidden = !room.available;
els('.spotify', room.spotify).forEach((button) => {
button.setAttribute('aria-pressed', String(button.dataset.account === room.spotifyAccount));
});
}
room.toggle.disabled = !room.available;
room.toggle.classList.toggle('busy', room.busy);
@@ -265,6 +282,33 @@ ui.splitAll.addEventListener('click', async () => {
}
});
/* --- Spotify: one button per account, asking the card's own Connect
receiver to resume that account, instead of always connecting to it
from the Spotify app -------------------------------------------------- */
els('.spotify').forEach((button) => {
button.addEventListener('click', () => resumeSpotify(button));
});
async function resumeSpotify(button) {
const { target, account } = button.dataset;
const who = button.querySelector('span').textContent;
button.disabled = true;
try {
const data = await api('/api/spotify/resume', { target, account });
toast(`Resuming ${who}'s Spotify on ${data.device}`, 'ok');
const room = rooms[target];
room.spotifyAccount = account; // show it straight away; the refresh below confirms it
paintRoom(room);
// HEOS takes a moment to notice the new stream; look again once it
// has, so play/pause turns up without waiting for the next poll.
setTimeout(refresh, 1500);
} catch (error) {
toast(error.message);
} finally {
button.disabled = false;
}
}
/* --- the input picker -------------------------------------------------- */
ui.inputButton.addEventListener('click', openSheet);
el('[data-role="scrim"]').addEventListener('click', closeSheet);
@@ -317,6 +361,8 @@ function render(state) {
// Do not stomp on a volume the user is in the middle of changing.
if (!room.taps && !room.inflight) room.volume = incoming.volume;
if (!room.playBusy) room.playState = incoming.play_state;
room.onSpotify = Boolean(incoming.spotify);
room.spotifyAccount = incoming.spotify_account || null;
paintRoom(room);
});
paintGrouping();
+21 -7
View File
@@ -45,7 +45,8 @@ button {
touch-action: manipulation;
}
svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
/* Every icon is a Font Awesome glyph: a solid shape, filled, never stroked. */
svg { width: 22px; height: 22px; fill: currentColor; }
.app {
max-width: 520px;
@@ -109,9 +110,9 @@ svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width:
width: 78px; height: 62px;
border-radius: 18px;
background: var(--raised);
font-size: 30px; font-weight: 500; line-height: 1;
display: grid; place-items: center;
}
.step svg { width: 20px; height: 20px; }
.step:active { background: var(--accent); transform: scale(.96); }
.step:disabled { opacity: .4; }
@@ -132,11 +133,6 @@ svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width:
.transport:active { background: var(--accent); transform: scale(.97); }
.transport:disabled { opacity: .5; }
.transport svg { width: 19px; height: 19px; }
/* A play triangle wants filling; the pause bars are drawn with the stroke
everything else in here uses. */
.transport .icon-play { fill: currentColor; stroke: none; }
.transport .icon-pause { stroke-width: 2.4; }
.transport[data-role="next"] svg { fill: currentColor; stroke: none; }
/* Which icon shows is a class on the button, not `hidden` on the svg:
`hidden` is an HTMLElement property and SVGElement does not inherit it,
@@ -160,6 +156,22 @@ svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width:
.toggle:disabled { opacity: .5; }
.toggle.busy { opacity: .6; }
/* --- Spotify: one resume button per account ----------------------------- */
.spotify-row { display: flex; gap: 10px; }
.spotify {
flex: 1; min-width: 0;
height: 50px; border-radius: 16px;
background: var(--raised);
display: flex; align-items: center; justify-content: center; gap: 9px;
font-size: 15px; font-weight: 550;
}
.spotify svg { flex: 0 0 auto; color: #fff; }
.spotify span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.spotify:active { transform: scale(.985); }
.spotify:disabled { opacity: .5; }
/* The account playing on this room right now. */
.spotify[aria-pressed="true"] { border: 2px solid var(--ink); }
/* --- source card ------------------------------------------------------ */
.source-button {
display: flex; align-items: center; gap: 12px;
@@ -192,6 +204,7 @@ svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width:
background: none; border: 1px solid var(--edge); color: var(--muted);
}
.joined .toggle .dot { display: none; }
.joined .spotify { height: 40px; font-size: 13px; font-weight: 500; }
.joined .toggle:active { background: var(--raised); color: var(--ink); }
/* --- misc ------------------------------------------------------------- */
@@ -242,6 +255,7 @@ svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width:
background: #2b1f24; border: 1px solid #52303a; color: #ffd9d4;
font-size: 14px; box-shadow: 0 10px 30px rgba(0, 0, 0, .45);
}
.toast.ok { background: #172a21; border-color: #2a5540; color: #c9f7df; }
@media (prefers-reduced-motion: reduce) {
* { animation: none !important; transition: none !important; }