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();