Files
heos/static/app.js
T
franzz 0c8672789d
Deploy HEOS panel / deploy (push) Failing after 49s
Set hard-coded rooms order in config file
2026-09-22 15:37:07 +02:00

861 lines
34 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 || '/';
// 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,
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"]'),
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),
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),
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),
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; });
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.actions.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);
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.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".
toast(data.signed_out
? `${data.device} let go of ${who}'s Spotify`
: `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 = '<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;
// 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();