Files
heos/static/app.js
T
franzz ec684b7803
Deploy HEOS panel / deploy (push) Successful in 26s
Make progress bar cursor draggable
2026-09-16 23:19:08 +02:00

739 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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 || '/';
const POLL_MS = 5000;
// How long a dropped cursor waits for the player to report it got there.
const SEEK_HOLD_MS = 10000;
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,
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 <svg><path/></svg> 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"]'),
refresh: el('[data-role="refresh"]'),
avrStatus: el('[data-role="avr-status"]'),
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"]'),
source: el('.card.source'),
joined: el('[data-role="joined"]'),
avrNowPlaying: el('[data-role="avr-now-playing"]'),
avrCover: el('[data-role="avr-cover"]'),
avrSong: el('[data-role="avr-song"]'),
avrArtist: el('[data-role="avr-artist"]'),
avrProgress: el('[data-role="avr-progress"]'),
};
// Shares its shape with a room's {nowPlaying, cover, song, artist,
// progress} refs, so renderTrack() below works for both -- a device plugged
// into the AVR (a Zidoo, say) is "now playing" the same way a room's own
// stream is.
const avrTrack = {
nowPlaying: ui.avrNowPlaying,
cover: ui.avrCover,
song: ui.avrSong,
artist: ui.avrArtist,
progress: ui.avrProgress,
// Only a Zidoo ever fills this card's now-playing in, and it can always seek.
canSeek: () => true,
seek: null,
};
// Same reasoning as a room's own cover: drop one that will not load rather
// than leave a broken-image box.
avrTrack.cover.addEventListener('error', () => { avrTrack.cover.hidden = true; });
seekable(avrTrack);
const rooms = {};
let inputs = [];
let currentInput = null;
let avrNowPlaying = 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), // the resume buttons and the host toggle
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),
progress: el('[data-role="progress"]', 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,
// HEOS cannot seek, so only a stream Spotify can be asked to seek in: the
// same one the transport buttons show for (see paintRoom).
canSeek: () => room.onSpotify && room.spotifyAccount && !room.grouped,
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; });
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; });
renderTrack(room.available ? room.track : null, room);
const playing = room.playState === 'play';
room.node.classList.toggle('playing', playing);
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;
// 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.spotify).forEach((button) => {
button.hidden = !room.available || room.grouped;
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));
// 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';
// Grouped, its slot is empty and the AVR's card is the one that moves up.
room.slot.classList.toggle('on-top', playing && !room.grouped);
placeRoom(room);
}
/* 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.nowPlaying.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;
}
}
/* The discreet cursor on the line below the song, sized off the same
{position_ms, duration_ms} the poll hands back -- absent for anything
HEOS never sends a progress event for (an AVR input, an internet radio
stream with no fixed length), in which case the line stays plain. Around
a seek, the position seekPosition() picks shows instead of the one reported. */
function renderProgress(track, refs) {
const { progress } = refs;
progress.hidden = !track;
refs.shown = null;
if (!track) return;
const { duration_ms: duration } = track;
const position = seekPosition(track, refs);
const known = typeof duration === 'number' && duration > 0 && typeof position === 'number';
const percent = known ? Math.min(100, Math.max(0, (position / duration) * 100)) : 0;
if (known) {
refs.shown = track;
refs.shownMs = position;
}
progress.classList.toggle('known', known);
progress.classList.toggle('seekable', known && Boolean(refs.canSeek()));
progress.style.setProperty('--progress', percent);
// Whole seconds on both sides, so elapsed and remaining always add up to
// the total shown at the end of the line.
const total = known ? Math.floor(duration / 1000) : 0;
const elapsed = known ? Math.min(total, Math.max(0, Math.floor(position / 1000))) : 0;
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) : '';
}
/* Drag the cursor, or either time riding with it, to jump backwards or
forwards. Like the volume knob, it moves by how far the finger travels
rather than jumping to where it lands, so grabbing a time off-centre never
lurches the song -- and only the release is sent, so the player is not
asked to seek a dozen times along the way. A vertical swipe stays a page
scroll (see panel.css), which cancels the drag. */
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);
}
/* 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.progress.dataset.target, 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);
}
/* Where the cursor goes: under the finger while dragging, and where it was
dropped while that is sent. After that the player still reports its old
position for a poll or two -- HEOS only passes its progress on as it goes,
and a Zidoo mid-seek is no quicker -- so the cursor stays put rather than
bouncing back, until a reported position could only come after the seek
(the drop, give or take, plus however long it has played on since). A
different track, or SEEK_HOLD_MS with no such report, lets go as well. */
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;
}
/* Dragged or on its way -- not merely waiting for the player to catch up,
which is exactly what the polls must keep coming for. */
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}`;
}
/* 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.joined : room.slot;
if (room.node.parentElement !== target) target.appendChild(room.node);
// Left empty, a slot would still take a gap of its own and double the space.
room.slot.hidden = room.grouped;
}
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'))
|| avrNowPlaying?.play_state === 'play';
ui.source.classList.toggle('playing', playing);
ui.source.classList.toggle('on-top', playing);
}
/* 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();
}
/* --- 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 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 = '<span class="option-label"><svg viewBox="0 0 512 512" aria-hidden="true"><path d=""/></svg><span></span></span><span class="code"></span>';
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;
}
function closeSheet() { ui.sheet.hidden = true; }
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 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);
});
const avr = state.avr || {};
inputs = avr.inputs || [];
currentInput = avr.input || null;
ui.inputName.textContent = currentInput ? currentInput.name : '—';
setInputIcon(currentInput ? currentInput.code : null);
ui.avrStatus.textContent = avr.connected ? 'ready' : 'offline';
ui.avrStatus.classList.toggle('on', Boolean(avr.connected));
avrNowPlaying = avr.now_playing || null;
renderTrack(avrNowPlaying, avrTrack);
paintGrouping(); // after avrNowPlaying, which decides where the AVR's card 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()
|| seeking(avrTrack)
|| Object.values(rooms).some((r) => r.taps || r.inflight || r.dragging || r.busy || r.playBusy || seeking(r));
}
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();