/* 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; // Where the app is mounted: "/" on its own port, "/heos/" behind a proxy. const BASE = document.documentElement.dataset.base || '/'; // The commit this page was rendered from, so a resumed iOS home-screen // session can tell the Pi has moved on and reload itself. const VERSION = document.documentElement.dataset.version || ''; const POLL_MS = 5000; // How long a dropped cursor waits for the player to report it got there. const SEEK_HOLD_MS = 10000; // How far an arrow key moves the progress cursor. const SEEK_KEY_MS = 10000; // And how long a pressed play or pause on the AVR waits for the same. The // Zidoo acknowledges the key press at once but takes a moment to act on it. const PLAY_HOLD_MS = 6000; // Which way each arrow key moves a slider. const ARROWS = { ArrowRight: 1, ArrowUp: 1, ArrowLeft: -1, ArrowDown: -1 }; const el = (sel, root = document) => root.querySelector(sel); const els = (sel, root = document) => Array.from(root.querySelectorAll(sel)); const SPEAKER = { viewbox: '0 0 384 512', path: 'M0 64C0 28.7 28.7 0 64 0L320 0c35.3 0 64 28.7 64 64l0 384c0 35.3-28.7 64-64 64L64 512c-35.3 0-64-28.7-64-64L0 64zM304 336a112 112 0 1 0 -224 0 112 112 0 1 0 224 0zM192 272a64 64 0 1 1 0 128 64 64 0 1 1 0-128zm0-112a48 48 0 1 0 0-96 48 48 0 1 0 0 96z' }; const SPEAKERS = { viewbox: '0 -32 448 576', path: 'M160-32C124.7-32 96-3.3 96 32l0 352c0 35.3 28.7 64 64 64l224 0c35.3 0 64-28.7 64-64l0-352c0-35.3-28.7-64-64-64L160-32zM272 184a104 104 0 1 1 0 208 104 104 0 1 1 0-208zm56 104a56 56 0 1 0 -112 0 56 56 0 1 0 112 0zM240 64a32 32 0 1 1 64 0 32 32 0 1 1 -64 0zM48 88c0-13.3-10.7-24-24-24S0 74.7 0 88L0 480c0 35.3 28.7 64 64 64l264 0c13.3 0 24-10.7 24-24s-10.7-24-24-24L64 496c-8.8 0-16-7.2-16-16L48 88z' }; const TV = { viewbox: '0 0 576 512', path: 'M64 96l0 240 448 0 0-240-448 0zM0 96C0 60.7 28.7 32 64 32l448 0c35.3 0 64 28.7 64 64l0 240c0 35.3-28.7 64-64 64L64 400c-35.3 0-64-28.7-64-64L0 96zM160 448l256 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-256 0c-17.7 0-32-14.3-32-32s14.3-32 32-32z' }; // Keyed on a room's kind (config.IN_ROOM_GROUPS, or "avr") or an input's // HEOS id, straight from data-icon or GET /api/avr/inputs' "code" -- not // something a room name or a renamed input label could change. "plug" is // the fallback for an input HEOS reports that none of these name. const ICONS = { avr: { viewbox: '0 0 448 512', path: 'M64 32C28.7 32 0 60.7 0 96l0 64c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-64c0-35.3-28.7-64-64-64L64 32zm216 72a24 24 0 1 1 0 48 24 24 0 1 1 0-48zm56 24a24 24 0 1 1 48 0 24 24 0 1 1 -48 0zM64 288c-35.3 0-64 28.7-64 64l0 64c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-64c0-35.3-28.7-64-64-64L64 288zm216 72a24 24 0 1 1 0 48 24 24 0 1 1 0-48zm56 24a24 24 0 1 1 48 0 24 24 0 1 1 -48 0z' }, 'none': SPEAKER, 'stereo-pair': SPEAKERS, 'lcr-fronts': SPEAKERS, 'surround-sound-system': SPEAKERS, 'subwoofer': SPEAKER, mediaplayer: { viewbox: '0 -16 576 512', path: 'M0 112c0 70.7 57.3 128 128 128l224 0c70.7 0 128-57.3 128-128S422.7-16 352-16c-48.2 0-90.2 26.6-112 66-21.8-39.4-63.8-66-112-66-70.7 0-128 57.3-128 128zm304 0a48 48 0 1 1 96 0 48 48 0 1 1 -96 0zM128 64a48 48 0 1 1 0 96 48 48 0 1 1 0-96zM64 352l0 64c0 35.3 28.7 64 64 64l224 0c35.3 0 64-28.7 64-64l0-64c0-35.3-28.7-64-64-64l-224 0c-35.3 0-64 28.7-64 64zM537.5 490.8c4.2 3.4 9.4 5.2 14.8 5.2 13.1 0 23.7-10.6 23.7-23.7l0-240.6c0-13.1-10.6-23.7-23.7-23.7-5.4 0-10.6 1.8-14.8 5.2l-73.5 58.8 0 160 73.5 58.8z' }, game: { viewbox: '0 0 640 512', path: 'M448 64c106 0 192 86 192 192S554 448 448 448l-256 0C86 448 0 362 0 256S86 64 192 64l256 0zM192 176c-13.3 0-24 10.7-24 24l0 32-32 0c-13.3 0-24 10.7-24 24s10.7 24 24 24l32 0 0 32c0 13.3 10.7 24 24 24s24-10.7 24-24l0-32 32 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-32 0 0-32c0-13.3-10.7-24-24-24zm240 96a32 32 0 1 0 0 64 32 32 0 1 0 0-64zm64-96a32 32 0 1 0 0 64 32 32 0 1 0 0-64z' }, tv: TV, tvaudio: TV, bluray: { viewbox: '0 0 512 512', path: 'M0 256a256 256 0 1 1 512 0 256 256 0 1 1 -512 0zm256 32a32 32 0 1 1 0-64 32 32 0 1 1 0 64zm-96-32a96 96 0 1 0 192 0 96 96 0 1 0 -192 0zm-56-16c0-32.4 16.3-66.6 42.8-93.2S207.6 104 240 104c13.3 0 24-10.7 24-24s-10.7-24-24-24c-47.9 0-93.7 23.5-127.1 56.9S56 192.1 56 240c0 13.3 10.7 24 24 24s24-10.7 24-24z' }, plug: { viewbox: '0 -32 448 544', path: 'M128-32c17.7 0 32 14.3 32 32l0 96 128 0 0-96c0-17.7 14.3-32 32-32s32 14.3 32 32l0 96 64 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l0 64c0 95.1-69.2 174.1-160 189.3l0 66.7c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-66.7C101.2 398.1 32 319.1 32 224l0-64c-17.7 0-32-14.3-32-32S14.3 96 32 96l64 0 0-96c0-17.7 14.3-32 32-32z' } }; function inputIcon(code) { return ICONS[(code || '').replace('inputs/', '')] || ICONS.plug; } /* Repaints an inline in place -- cheaper than replacing the node, and there is no library here to lose track of the change. */ function paintIcon(svg, icon) { svg.setAttribute('viewBox', icon.viewbox); svg.firstElementChild.setAttribute('d', icon.path); } // Room and AVR cards carry their icon as a fixed kind (data-icon="avr", set // server-side from config -- see index.html) rather than the AVR input's // live one, so these paint once, not on every poll. els('[data-icon]').forEach((svg) => paintIcon(svg, ICONS[svg.dataset.icon])); const ui = { foot: el('[data-role="foot"]'), toast: el('[data-role="toast"]'), inputButton: el('[data-role="input-button"]'), inputName: el('[data-role="input-name"]'), inputIcon: el('[data-role="input-icon"]'), sheet: el('[data-role="sheet"]'), options: el('[data-role="options"]'), app: el('.app'), avr: el('.card.avr'), joined: el('[data-role="joined"]'), }; // The AVR is one of the rooms below, so its key comes off its card like // theirs do. Which one it is, everything that only it can do goes through. const HOST = ui.avr.dataset.room; 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('.card[data-room]').forEach((node) => { const key = node.dataset.room; const room = { key, node, avr: node.classList.contains('avr'), level: el('[data-role="level"]', node), meter: el('[data-role="meter"]', node), navigation: el('[data-role="navigation"]', 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), sources: el('.sources', node), // the resume buttons and the host toggle media: el('[data-role="media"]', node), cover: el('[data-role="cover"]', node), song: el('[data-role="song"]', 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 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, wantedPlay: null, // {state, sentAt} from a press until the player reports it // HEOS cannot seek, so only a stream Spotify can be asked to seek in: the // same one the transport buttons show for (see paintTransport). Only a // Zidoo ever fills the AVR's card in, and it can always seek. canSeek: () => room.avr || (room.onSpotify && room.spotifyAccount && !room.grouped), playing: () => shownPlayState(room) === 'play', seek: null, // {ms, stage, ...} from a drag of the cursor until the player catches up }; rooms[key] = room; room.steps.forEach((button) => { const direction = Number(button.dataset.delta); holdable(button, () => nudge(key, direction)); }); draggable(room); seekable(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; }); // 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)); if (room.next) room.next.addEventListener('click', () => skipTrack(key, 'next')); }); function paintVolume(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); // The knob is a slider to VoiceOver, which reads these rather than its digits. if (known) room.level.setAttribute('aria-valuenow', room.volume); else room.level.removeAttribute('aria-valuenow'); room.level.setAttribute('aria-disabled', String(!room.available)); room.steps.forEach((button) => { button.disabled = !room.available; }); } function paintTransport(room) { const state = shownPlayState(room); const playing = state === 'play'; room.node.classList.toggle('playing', playing); room.play.classList.toggle('playing', playing); room.play.disabled = !room.available || !state; room.play.setAttribute( 'aria-label', `${room.node.querySelector('h2').textContent}: ${playing ? 'pause' : 'play'}`); room.navigation.hidden = room.avr ? !state : !(room.onSpotify && room.spotifyAccount && !room.grouped); if (room.prev) room.prev.disabled = !room.available; if (room.next) room.next.disabled = !room.available; } function paintSources(room) { if (!room.sources) return; // Grouped, the room plays whatever the AVR does, so resuming Spotify on it // is not on offer. They hide one by one rather than the row hiding with // them, because the row also holds the host toggle, which stays put and // merely disables. els('.spotify', room.sources).forEach((button) => { const holding = button.dataset.account === room.spotifyAccount; button.hidden = !room.available || room.grouped; button.setAttribute('aria-pressed', String(holding)); // Bordered, the button is the way back out of the room rather than into // it, so its label has to say that and not "Resume". button.setAttribute( 'aria-label', `${holding ? 'Disconnect' : 'Resume'} ${button.querySelector('span').textContent}'s Spotify`); }); room.toggle.disabled = !room.available || room.busy; room.toggle.classList.toggle('loading', room.busy); room.toggle.setAttribute('aria-pressed', String(room.grouped)); // Out of the group the button names where tapping it sends the room; in it, // where the card sits already says so, so the button says what it does. room.toggleLabel.textContent = room.grouped ? 'Ungroup' : 'Cinema'; } function paintRoom(room) { room.node.classList.toggle('offline', !room.available); renderTrack(room.available ? room.track : null, 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 = ''; 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 = '
'; 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. */ function renderTrack(track, refs) { renderProgress(track, refs); refs.media.hidden = !track; if (!track) return; refs.song.textContent = track.song; refs.artist.textContent = track.artist || ''; refs.artist.hidden = !track.artist; if (!track.image) { refs.cover.hidden = true; refs.cover.removeAttribute('src'); } else if (refs.cover.getAttribute('src') !== track.image) { refs.cover.hidden = false; refs.cover.src = track.image; } } function renderProgress(track, refs) { refs.shown = null; refs.tick = null; if (!track) return; const { duration_ms: duration } = track; const position = seekPosition(track, refs); const known = typeof duration === 'number' && duration > 0 && typeof position === 'number'; if (known) { refs.shown = track; refs.shownMs = position; refs.tick = { ms: position, at: Date.now(), duration }; } paintProgress(refs, known, position, duration); } /* Just the painting, shared with tickProgress() below so a between-polls nudge repaints exactly the same way a real sync does. */ function paintProgress(refs, known, position, duration) { const { progress } = refs; // Whole seconds on both sides, so elapsed and remaining always add up to // the total shown at the end of the line -- and the knob is placed off // these same whole seconds, not the raw fractional position, so it never // creeps past where the digits still say it is. const total = known ? Math.floor(duration / 1000) : 0; const elapsed = known ? Math.min(total, Math.max(0, Math.floor(position / 1000))) : 0; const percent = known ? Math.min(100, Math.max(0, (elapsed / total) * 100)) : 0; progress.classList.toggle('known', known); progress.classList.toggle('seekable', known && Boolean(refs.canSeek())); progress.style.setProperty('--progress', percent); const labels = { elapsed: known ? '+' + clock(elapsed) : '', remaining: known ? '−' + clock(total - elapsed) : '', }; Object.entries(labels).forEach(([role, text]) => { const label = el(`[data-role="progress-${role}"]`, progress); label.textContent = text; // Read back once the new text is in, for panel.css to clamp it by. if (known) label.style.setProperty('--half', `${label.offsetWidth / 2}px`); }); el('[data-role="progress-total"]', progress).textContent = known ? clock(total) : ''; const cursor = el('[data-role="progress-cursor"]', progress); const seekable = progress.classList.contains('seekable'); cursor.tabIndex = seekable ? 0 : -1; cursor.setAttribute('aria-readonly', String(!seekable)); if (known) { cursor.setAttribute('aria-valuemax', total); cursor.setAttribute('aria-valuenow', elapsed); cursor.setAttribute('aria-valuetext', `${clock(elapsed)} of ${clock(total)}`); } } function tickProgress(refs) { if (!refs.tick || !refs.shown || refs.seek || !refs.playing()) return; const { ms, at, duration } = refs.tick; const position = Math.min(duration, ms + (Date.now() - at)); paintProgress(refs, true, position, duration); } setInterval(() => Object.values(rooms).forEach(tickProgress), 250); function seekable(refs) { const { progress } = refs; const line = el('.progress-line', progress); let startX = 0; let startMs = 0; let moved = false; let held = null; // a previous seek still waiting on the player, back if this drag goes nowhere line.addEventListener('pointerdown', (event) => { if (event.button > 0 || seeking(refs) || !refs.shown || !progress.classList.contains('seekable')) return; if (!event.target.closest('.progress-cursor, .progress-time')) return; event.preventDefault(); line.setPointerCapture(event.pointerId); startX = event.clientX; startMs = refs.shownMs; // where the cursor is, even if the player has not caught up with it yet moved = false; held = refs.seek; refs.seek = { ms: startMs, stage: 'drag', song: refs.shown.song }; progress.classList.add('dragging'); }); line.addEventListener('pointermove', (event) => { if (!refs.seek || refs.seek.stage !== 'drag' || !refs.shown) return; const dx = event.clientX - startX; // A few pixels of slack, so a tap that wobbles is still just a tap. if (!moved && Math.abs(dx) < 6) return; moved = true; const { duration_ms: duration } = refs.shown; const ms = startMs + (dx / line.clientWidth) * duration; refs.seek.ms = Math.round(Math.max(0, Math.min(duration, ms))); renderProgress(refs.shown, refs); }); const stop = (event) => { if (!refs.seek || refs.seek.stage !== 'drag') return; progress.classList.remove('dragging'); if (event.type === 'pointerup' && moved) { sendSeek(refs); } else { refs.seek = held; if (refs.shown) renderProgress(refs.shown, refs); } }; line.addEventListener('pointerup', stop); line.addEventListener('pointercancel', stop); // The arrow keys, and VoiceOver's swipe up and down on a slider, move the // cursor a step at a time, each one its own seek. el('[data-role="progress-cursor"]', progress).addEventListener('keydown', (event) => { const direction = ARROWS[event.key]; if (!direction || seeking(refs) || !refs.shown || !progress.classList.contains('seekable')) return; event.preventDefault(); const { duration_ms: duration } = refs.shown; const ms = refs.shownMs + direction * SEEK_KEY_MS; refs.seek = { ms: Math.round(Math.max(0, Math.min(duration, ms))), stage: 'drag', song: refs.shown.song }; renderProgress(refs.shown, refs); sendSeek(refs); }); } /* A seek that fails snaps the cursor back; one that works holds it where it was dropped (see seekPosition). */ async function sendSeek(refs) { const { seek } = refs; seek.stage = 'send'; seek.sentAt = Date.now(); try { await api('/api/seek', { target: refs.key, position_ms: seek.ms }); seek.stage = 'hold'; // The player takes a moment to report where it has got to. setTimeout(refresh, 1500); } catch (error) { toast(error.message); refs.seek = null; } // Unless a poll in the meantime found nothing left to seek in. if (refs.shown) renderProgress(refs.shown, refs); } function seekPosition(track, refs) { const { seek } = refs; if (!seek) return track.position_ms; if (seek.stage !== 'hold') return seek.ms; const since = Date.now() - seek.sentAt; const slack = 2000; const caughtUp = track.position_ms >= seek.ms - slack && track.position_ms <= seek.ms + since + slack; if (caughtUp || track.song !== seek.song || since > SEEK_HOLD_MS) { refs.seek = null; return track.position_ms; } return seek.ms; } function seeking(refs) { return Boolean(refs.seek) && refs.seek.stage !== 'hold'; } /* 83 -> "1:23", 4000 -> "1:06:40": hours only for what runs that long. */ function clock(seconds) { const h = Math.floor(seconds / 3600); const m = Math.floor(seconds / 60) % 60; const s = String(seconds % 60).padStart(2, '0'); return h ? `${h}:${String(m).padStart(2, '0')}:${s}` : `${m}:${s}`; } function placeRoom(room) { // The host's card is where the column ends, and every room placed above // measures from it, so it is the one card that never moves. if (room.avr) return; if (room.grouped) { if (room.node.parentElement !== ui.joined) ui.joined.appendChild(room.node); } else if (room.node.parentElement !== ui.app) { const order = Object.keys(rooms); const next = order.slice(order.indexOf(room.key) + 1) .map((key) => rooms[key].node) .find((node) => node.parentElement === ui.app); // Nothing left in the column below it means it goes last, before the host. ui.app.insertBefore(room.node, next || ui.avr); } } function paintGrouping() { const order = Object.keys(rooms); const inside = Array.from(ui.joined.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.joined.appendChild(node)); ui.joined.hidden = inside.length === 0; // Grouped rooms share the host's transport, so one of them playing means // the whole group is -- and the tint goes on the group's card. // A film running on the Zidoo counts as the AVR playing too, grouped rooms or not. const playing = inside.some((node) => node.classList.contains('playing')) || shownPlayState(rooms[HOST]) === 'play'; ui.avr.classList.toggle('playing', playing); } /* Press and hold to keep moving, accelerating as you hold. */ function holdable(node, action) { let timer = null; let delay = 420; // A press by pointer acts on pointerdown and preventDefault()s, so the // click behind it is not meant to act again. One with no pointer press // just before it -- Enter or Space on a focused button, VoiceOver's // double-tap -- is the only way in for those, and acts once. let pressed = false; 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 pressed = true; action(); timer = setTimeout(tick, delay); }); ['pointerup', 'pointercancel', 'pointerleave'].forEach((type) => node.addEventListener(type, stop)); ['pointerup', 'pointercancel'].forEach((type) => node.addEventListener(type, () => setTimeout(() => { pressed = false; }, 500))); node.addEventListener('click', () => { if (!pressed && !node.disabled) action(); }); } 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); } } 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); // The arrow keys, and VoiceOver's swipe up and down on a slider. knob.addEventListener('keydown', (event) => { const direction = ARROWS[event.key]; if (!direction) return; event.preventDefault(); nudge(room.key, direction); }); } 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(); } } function shownPlayState(room) { const reported = room.playState; if (!room.wantedPlay) return reported; // Nothing loaded any more -- the film ended, or the AVR changed input -- // leaves nothing to hold; so does the player reporting the state asked for. if (!reported || reported === room.wantedPlay.state || Date.now() - room.wantedPlay.sentAt > PLAY_HOLD_MS) { room.wantedPlay = null; } return room.wantedPlay ? room.wantedPlay.state : reported; } /* Sends the state it wants rather than "toggle", so a stale idea of what the room is doing cannot flip it the wrong way. For the AVR this reaches the Zidoo rather than HEOS, which can no more pause a device on one of the AVR's inputs than it can seek in it -- app.py routes it there. */ async function togglePlay(key) { const room = rooms[key]; const state = shownPlayState(room); if (!room.available || !state || room.playBusy) return; const wanted = state === 'play' ? 'pause' : 'play'; room.playBusy = true; room.wantedPlay = { state: wanted, sentAt: Date.now() }; paintRoom(room); paintGrouping(); try { const data = await api('/api/playback', { target: key, state: wanted }); // HEOS answers with the state the room is now in, which lets the hold go // at once. The Zidoo only says the key press was taken, so the AVR's card // keeps holding until a poll sees the film itself move. if (!room.avr) room.playState = data.state; } catch (error) { toast(error.message); room.wantedPlay = null; } finally { room.playBusy = false; paintRoom(room); paintGrouping(); // The Zidoo takes a moment to report that it has started or stopped. if (room.avr) setTimeout(refresh, 1500); } } /* 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(); } /* --- 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', () => { // The bordered button is the account already playing here, and resuming // it again would do nothing, so that tap is the opposite request: let // the room go, so another account can have it. const room = rooms[button.dataset.target]; if (room && room.spotifyAccount === button.dataset.account) disconnectSpotify(button); else resumeSpotify(button); }); }); async function resumeSpotify(button) { const { target, account } = button.dataset; const who = button.querySelector('span').textContent; button.disabled = true; button.classList.add('loading'); 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; button.classList.remove('loading'); } } async function disconnectSpotify(button) { const { target, account } = button.dataset; const who = button.querySelector('span').textContent; const room = rooms[target]; button.disabled = true; button.classList.add('loading'); try { const data = await api('/api/spotify/disconnect', { target, account }); // The speaker not answering on the LAN is not a failure -- the room // still stops -- but it does leave the account signed in to it, and // that is the difference between "let go of" and "stopped". // A stereo pair is only ever paused -- signing it out can leave one // half silent -- so it staying signed in is the plan, not a failure. toast(data.signed_out ? `${data.device} let go of ${who}'s Spotify` : data.paired ? `Paused ${who}'s Spotify on ${data.device}` : `Stopped ${who}'s Spotify on ${data.device} — the speaker did not answer, so it stays signed in`, 'ok'); // The room stops with it, so drop the border, the transport row and the // song now rather than leaving a card that still claims to be playing; // the refresh below confirms it. room.spotifyAccount = null; room.playState = 'stop'; room.track = null; paintRoom(room); setTimeout(refresh, 1500); } catch (error) { toast(error.message); } finally { button.disabled = false; button.classList.remove('loading'); } } /* --- 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 setInputIcon(code) { paintIcon(ui.inputIcon, inputIcon(code)); } 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 = ''; paintIcon(option.querySelector('svg'), inputIcon(source.code)); option.querySelector('.option-label span').textContent = source.name; option.querySelector('.code').textContent = source.code; option.addEventListener('click', () => chooseInput(source)); ui.options.appendChild(option); }); ui.sheet.hidden = false; // Into the sheet, on the input already selected, so a keyboard or VoiceOver // starts there rather than on the page behind it. (el('[aria-current="true"]', ui.options) || ui.options.firstElementChild).focus(); } function closeSheet() { ui.sheet.hidden = true; ui.inputButton.focus(); } document.addEventListener('keydown', (event) => { if (event.key === 'Escape' && sheetOpen()) closeSheet(); }); async function chooseInput(source) { closeSheet(); currentInput = source; ui.inputName.textContent = source.name; setInputIcon(source.code); try { const data = await api('/api/avr/input', { code: source.code }); currentInput = data; ui.inputName.textContent = data.name; setInputIcon(data.code); } catch (error) { toast(error.message); refresh(); } } /* --- state ------------------------------------------------------------- */ function applyRoom(room, incoming) { if (!room) return; room.available = Boolean(incoming.available); room.grouped = Boolean(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 || null; room.track = incoming.now_playing || null; room.onSpotify = Boolean(incoming.spotify); room.spotifyAccount = incoming.spotify_account || null; paintRoom(room); } function render(state) { // The Pi shipped a newer version while this tab (or a resumed iOS // home-screen session) was still showing an old one -- catch up rather // than keep polling a page that no longer matches the server. if (VERSION && state.version && state.version !== VERSION && !busy()) { location.reload(); return; } (state.rooms || []).forEach((incoming) => applyRoom(rooms[incoming.key], incoming)); const avr = state.avr || {}; inputs = avr.inputs || []; currentInput = avr.input || null; ui.inputName.textContent = currentInput ? currentInput.name : '—'; setInputIcon(currentInput ? currentInput.code : null); // Whatever is on one of its inputs is what the AVR is playing, and its own // play state is that device's -- HEOS has none to give for an input. applyRoom(rooms[HOST], { available: avr.connected !== false, volume: avr.volume, now_playing: avr.now_playing || null, play_state: (avr.now_playing || {}).play_state || null, }); paintGrouping(); // after the AVR's card, which decides where it sits 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 || seeking(r)); } setInterval(() => { if (document.visibilityState === 'visible' && !busy()) refresh(); }, POLL_MS); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') refresh(); }); Object.values(rooms).forEach(paintRoom); paintGrouping(); refresh();