/* The panel. Every control acts immediately and reconciles with what the speakers report a moment later, because a remote that waits for a network round trip before it looks like it did anything feels broken. */ const STEP = Number(document.documentElement.dataset.step) || 2; const HOST_LABEL = document.documentElement.dataset.host || 'the AVR'; // Where the app is mounted: "/" on its own port, "/heos/" behind a proxy. const BASE = document.documentElement.dataset.base || '/'; const POLL_MS = 5000; const el = (sel, root = document) => root.querySelector(sel); const els = (sel, root = document) => Array.from(root.querySelectorAll(sel)); const ui = { foot: el('[data-role="foot"]'), toast: el('[data-role="toast"]'), refresh: el('[data-role="refresh"]'), splitAll: el('[data-role="split-all"]'), avrStatus: el('[data-role="avr-status"]'), inputButton: el('[data-role="input-button"]'), inputName: el('[data-role="input-name"]'), sheet: el('[data-role="sheet"]'), options: el('[data-role="options"]'), joined: el('[data-role="joined"]'), joinedRooms: el('[data-role="joined-rooms"]'), }; const rooms = {}; let inputs = []; let currentInput = null; /* --- transport -------------------------------------------------------- */ async function api(path, body) { const options = body === undefined ? {} : { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }; const response = await fetch(BASE + path.replace(/^\//, ''), options); let data = {}; try { data = await response.json(); } catch (_) { /* empty or not JSON */ } if (!response.ok) throw new Error(data.error || `${response.status} ${response.statusText}`); return data; } let toastTimer; /* 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); } /* --- rooms ------------------------------------------------------------ */ els('.room').forEach((node) => { const key = node.dataset.room; const room = { key, node, slot: el(`.room-slot[data-slot="${key}"]`), level: el('[data-role="level"]', node), meter: el('[data-role="meter"]', node), actions: el('[data-role="actions"]', node), toggle: el('[data-role="group"]', node), toggleLabel: el('[data-role="group-label"]', node), prev: el('[data-role="prev"]', 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 nowPlaying: el('[data-role="now-playing"]', node), cover: el('[data-role="cover"]', node), song: el('[data-role="song"]', node), artist: el('[data-role="artist"]', node), steps: els('.step', node), volume: null, playState: null, track: null, // {song, artist, image} while something is loaded onSpotify: false, spotifyAccount: null, // the account playing here, whose button gets a border grouped: false, available: false, taps: 0, // button presses not yet sent wanted: null, // a level dragged to, not yet sent dragging: false, inflight: false, busy: false, // a grouping change is in flight playBusy: false, }; rooms[key] = room; room.steps.forEach((button) => { const direction = Number(button.dataset.delta); holdable(button, () => nudge(key, direction)); }); draggable(room); // A cover that will not load (gone, or plain http on an https page) is // dropped rather than left as a broken-image box. Its src stays put, so // the next poll does not try it again until the track changes. room.cover.addEventListener('error', () => { room.cover.hidden = true; }); room.toggle.addEventListener('click', () => setGrouped(key, !room.grouped)); room.prev.addEventListener('click', () => skipTrack(key, 'previous')); room.play.addEventListener('click', () => togglePlay(key)); room.next.addEventListener('click', () => skipTrack(key, 'next')); }); function paintRoom(room) { const known = room.volume !== null && room.volume !== undefined; room.level.textContent = known ? room.volume : '—'; // The fill and the knob riding it both size themselves off this. room.meter.style.setProperty('--level', known ? room.volume : 0); room.node.classList.toggle('offline', !room.available); room.steps.forEach((button) => { button.disabled = !room.available; }); paintTrack(room); const playing = room.playState === 'play'; room.play.classList.toggle('playing', playing); room.play.disabled = !room.available || room.playState === null; room.play.setAttribute( 'aria-label', `${room.node.querySelector('h2').textContent}: ${playing ? 'pause' : 'play'}`); // Previous, 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 them here would still work (it shares // the group's transport) but only invites confusion about which card is // actually in charge of it. And only for a stream one of our own accounts // is playing: someone else's phone keeps its own controls. The row shows // or hides as one, so an empty row never leaves a gap in the card. room.actions.hidden = !(room.onSpotify && room.spotifyAccount && !room.grouped); room.prev.disabled = !room.available; 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); room.toggle.setAttribute('aria-pressed', String(room.grouped)); // Where the card sits already says whether it is grouped, so the button // says what tapping it does instead. room.toggleLabel.textContent = room.grouped ? 'Leave' : `Join ${HOST_LABEL}`; placeRoom(room); } /* Song, artist and cover, each only when HEOS has one. The cover's src is only touched when the track changes, so a poll never makes it flicker. */ function paintTrack(room) { const track = room.available ? room.track : null; room.nowPlaying.hidden = !track; if (!track) return; room.song.textContent = track.song; room.artist.textContent = track.artist || ''; room.artist.hidden = !track.artist; if (!track.image) { room.cover.hidden = true; room.cover.removeAttribute('src'); } else if (room.cover.getAttribute('src') !== track.image) { room.cover.hidden = false; room.cover.src = track.image; } } /* A merged room moves into the host's card, because that is what merging means: one group, playing one thing. Leaving puts the card back in its own slot, which is why the slots exist. */ function placeRoom(room) { const target = room.grouped ? ui.joinedRooms : room.slot; if (room.node.parentElement !== target) target.appendChild(room.node); } function paintGrouping() { const order = Object.keys(rooms); const inside = Array.from(ui.joinedRooms.children); // Keep them in the order the cards are declared, not the order they joined. inside .slice() .sort((a, b) => order.indexOf(a.dataset.room) - order.indexOf(b.dataset.room)) .forEach((node) => ui.joinedRooms.appendChild(node)); ui.joined.hidden = inside.length === 0; ui.splitAll.hidden = inside.length === 0; } /* Press and hold to keep moving, accelerating as you hold. */ function holdable(node, action) { let timer = null; let delay = 420; const stop = () => { clearTimeout(timer); timer = null; delay = 420; }; const tick = () => { action(); delay = Math.max(130, delay * 0.72); timer = setTimeout(tick, delay); }; node.addEventListener('pointerdown', (event) => { if (event.button > 0 || node.disabled) return; event.preventDefault(); // also suppresses the click that would double-fire action(); timer = setTimeout(tick, delay); }); ['pointerup', 'pointercancel', 'pointerleave'].forEach((type) => node.addEventListener(type, stop)); } /* A tap lands on the next multiple of STEP rather than adding STEP, so a level of 23 goes to 25 on the way up and 20 on the way down. Mirrors stepped_level() in controller.py -- the panel guesses with it, the speakers are the ones that decide. */ function nextLevel(current, direction) { if (direction > 0) return Math.min(100, (Math.floor(current / STEP) + 1) * STEP); const aligned = Math.floor(current / STEP) * STEP; return Math.max(0, aligned === current ? current - STEP : aligned); } function nudge(key, direction) { const room = rooms[key]; if (!room.available) return; room.volume = nextLevel(room.volume ?? 0, direction); room.taps += direction; paintRoom(room); if (room.taps === 0) refresh(); // taps cancelled out; take the real level else flushVolume(key); } /* A burst of taps collapses into one call, so holding + does not queue up thirty requests the speakers then have to chew through. */ async function flushVolume(key) { const room = rooms[key]; if (room.inflight) return; // A dragged level goes first: any taps still waiting were pressed after it, // so they are meant to move on from it. let body; if (room.wanted !== null) { body = { target: key, level: room.wanted }; room.wanted = null; } else if (room.taps) { body = { target: key, steps: room.taps }; room.taps = 0; } else { return; } room.inflight = true; try { const data = await api('/api/volume', body); if (!room.taps && room.wanted === null && !room.dragging) { room.volume = data.level; paintRoom(room); } } catch (error) { toast(error.message); refresh(); } finally { room.inflight = false; if (room.taps || room.wanted !== null) flushVolume(key); } } /* Drag the knob to set the level outright, with the speakers following as it goes -- a drag collapses into one call at a time, the same as a burst of taps. It moves by how far the finger travels rather than jumping to where it lands, so grabbing the knob off-centre, or brushing it while scrolling past, never lurches the volume. */ function draggable(room) { const knob = room.level; let startX = 0; let startLevel = 0; let travel = 0; knob.addEventListener('pointerdown', (event) => { if (event.button > 0 || !room.available) return; event.preventDefault(); knob.setPointerCapture(event.pointerId); startX = event.clientX; startLevel = room.volume ?? 0; travel = room.meter.clientWidth - knob.offsetWidth; // how far the knob itself can go room.dragging = true; room.meter.classList.add('dragging'); }); knob.addEventListener('pointermove', (event) => { if (!room.dragging || travel <= 0) return; const moved = ((event.clientX - startX) / travel) * 100; const level = Math.round(Math.max(0, Math.min(100, startLevel + moved))); if (level === room.volume) return; room.volume = level; room.wanted = level; room.taps = 0; // an absolute level supersedes any taps not yet sent paintRoom(room); flushVolume(room.key); }); const stop = () => { room.dragging = false; room.meter.classList.remove('dragging'); }; knob.addEventListener('pointerup', stop); knob.addEventListener('pointercancel', stop); } async function setGrouped(key, joined) { const room = rooms[key]; if (room.busy || !room.available) return; room.busy = true; room.grouped = joined; // show the new state while the speakers catch up paintRoom(room); paintGrouping(); try { const data = await api('/api/group', { target: key, joined }); applyJoined(data.joined || []); } catch (error) { toast(error.message); } finally { room.busy = false; refresh(); } } /* Sends the state it wants rather than "toggle", so a stale idea of what the room is doing cannot flip it the wrong way. */ async function togglePlay(key) { const room = rooms[key]; if (!room.available || room.playBusy) return; const wanted = room.playState === 'play' ? 'pause' : 'play'; room.playBusy = true; room.playState = wanted; paintRoom(room); try { const data = await api('/api/playback', { target: key, state: wanted }); room.playState = data.state; } catch (error) { toast(error.message); } finally { room.playBusy = false; paintRoom(room); } } /* Nothing to optimistically flip the way play/pause does -- the panel cannot guess which song comes up -- so it looks again once HEOS has moved on, rather than showing the old song until the next poll. */ async function skipTrack(key, direction) { const room = rooms[key]; if (!room.available) return; try { await api('/api/skip', { target: key, direction }); setTimeout(refresh, 1000); } catch (error) { toast(error.message); } } function applyJoined(joined) { Object.values(rooms).forEach((room) => { room.grouped = joined.includes(room.key); paintRoom(room); }); paintGrouping(); } ui.splitAll.addEventListener('click', async () => { try { applyJoined([]); const data = await api('/api/group/none', {}); // {} so it is a POST, as the route requires applyJoined(data.joined || []); } catch (error) { toast(error.message); } finally { refresh(); } }); /* --- 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); el('[data-role="sheet-close"]').addEventListener('click', closeSheet); function sheetOpen() { return !ui.sheet.hidden; } function openSheet() { if (!inputs.length) { toast('No inputs reported by the AVR yet'); return; } ui.options.innerHTML = ''; inputs.forEach((source) => { const option = document.createElement('button'); option.className = 'option'; option.setAttribute('aria-current', String(currentInput && currentInput.code === source.code)); option.innerHTML = ''; option.firstChild.textContent = source.name; option.lastChild.textContent = source.code; option.addEventListener('click', () => chooseInput(source)); ui.options.appendChild(option); }); ui.sheet.hidden = false; } function closeSheet() { ui.sheet.hidden = true; } async function chooseInput(source) { closeSheet(); currentInput = source; ui.inputName.textContent = source.name; try { const data = await api('/api/avr/input', { code: source.code }); currentInput = data; ui.inputName.textContent = data.name; } catch (error) { toast(error.message); refresh(); } } /* --- state ------------------------------------------------------------- */ function render(state) { (state.rooms || []).forEach((incoming) => { const room = rooms[incoming.key]; if (!room) return; room.available = incoming.available; room.grouped = incoming.grouped; // Do not stomp on a volume the user is in the middle of changing. if (!room.taps && !room.inflight && !room.dragging && room.wanted === null) { room.volume = incoming.volume; } if (!room.playBusy) room.playState = incoming.play_state; room.track = incoming.now_playing || null; room.onSpotify = Boolean(incoming.spotify); room.spotifyAccount = incoming.spotify_account || null; paintRoom(room); }); paintGrouping(); const avr = state.avr || {}; inputs = avr.inputs || []; currentInput = avr.input || null; ui.inputName.textContent = currentInput ? currentInput.name : '—'; ui.avrStatus.textContent = avr.connected ? 'ready' : 'offline'; ui.avrStatus.classList.toggle('on', Boolean(avr.connected)); const problems = state.errors || []; ui.foot.textContent = problems.length ? problems[0] : (state.demo ? 'demo mode — no real speakers' : ''); ui.foot.classList.toggle('bad', problems.length > 0); } let refreshing = false; async function refresh() { if (refreshing) return; refreshing = true; try { render(await api('/api/state')); } catch (error) { ui.foot.textContent = error.message; ui.foot.classList.add('bad'); } finally { refreshing = false; } } function busy() { return sheetOpen() || Object.values(rooms).some((r) => r.taps || r.inflight || r.dragging || r.busy || r.playBusy); } ui.refresh.addEventListener('click', () => { ui.refresh.classList.add('spin'); setTimeout(() => ui.refresh.classList.remove('spin'), 700); refresh(); }); setInterval(() => { if (document.visibilityState === 'visible' && !busy()) refresh(); }, POLL_MS); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') refresh(); }); Object.values(rooms).forEach(paintRoom); paintGrouping(); refresh();