418 lines
14 KiB
JavaScript
418 lines
14 KiB
JavaScript
/* The panel. Every control acts immediately and reconciles with what the
|
|
speakers report a moment later, because a remote that waits for a
|
|
network round trip before it looks like it did anything feels broken. */
|
|
|
|
const STEP = Number(document.documentElement.dataset.step) || 2;
|
|
const HOST_LABEL = document.documentElement.dataset.host || 'the AVR';
|
|
// Where the app is mounted: "/" on its own port, "/heos/" behind a proxy.
|
|
const BASE = document.documentElement.dataset.base || '/';
|
|
const POLL_MS = 5000;
|
|
|
|
const el = (sel, root = document) => root.querySelector(sel);
|
|
const els = (sel, root = document) => Array.from(root.querySelectorAll(sel));
|
|
|
|
const ui = {
|
|
foot: el('[data-role="foot"]'),
|
|
toast: el('[data-role="toast"]'),
|
|
refresh: el('[data-role="refresh"]'),
|
|
splitAll: el('[data-role="split-all"]'),
|
|
avrStatus: el('[data-role="avr-status"]'),
|
|
inputButton: el('[data-role="input-button"]'),
|
|
inputName: el('[data-role="input-name"]'),
|
|
sheet: el('[data-role="sheet"]'),
|
|
options: el('[data-role="options"]'),
|
|
joined: el('[data-role="joined"]'),
|
|
joinedRooms: el('[data-role="joined-rooms"]'),
|
|
};
|
|
|
|
const rooms = {};
|
|
let inputs = [];
|
|
let currentInput = null;
|
|
|
|
/* --- transport -------------------------------------------------------- */
|
|
async function api(path, body) {
|
|
const options = body === undefined
|
|
? {}
|
|
: { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) };
|
|
const response = await fetch(BASE + path.replace(/^\//, ''), options);
|
|
let data = {};
|
|
try { data = await response.json(); } catch (_) { /* empty or not JSON */ }
|
|
if (!response.ok) throw new Error(data.error || `${response.status} ${response.statusText}`);
|
|
return data;
|
|
}
|
|
|
|
let toastTimer;
|
|
/* Red by default, since most toasts report trouble; 'ok' for a confirmation. */
|
|
function toast(message, kind = 'error') {
|
|
ui.toast.textContent = message;
|
|
ui.toast.classList.toggle('ok', kind === 'ok');
|
|
ui.toast.hidden = false;
|
|
clearTimeout(toastTimer);
|
|
toastTimer = setTimeout(() => { ui.toast.hidden = true; }, 4000);
|
|
}
|
|
|
|
/* --- rooms ------------------------------------------------------------ */
|
|
els('.room').forEach((node) => {
|
|
const key = node.dataset.room;
|
|
const room = {
|
|
key,
|
|
node,
|
|
slot: el(`.room-slot[data-slot="${key}"]`),
|
|
level: el('[data-role="level"]', node),
|
|
bar: el('[data-role="bar"]', node),
|
|
toggle: el('[data-role="group"]', node),
|
|
toggleLabel: el('[data-role="group-label"]', node),
|
|
play: el('[data-role="play"]', node),
|
|
next: el('[data-role="next"]', node),
|
|
spotify: el('.spotify-row', node), // absent with no Spotify account set up
|
|
steps: els('.step', node),
|
|
volume: null,
|
|
playState: null,
|
|
onSpotify: false,
|
|
spotifyAccount: null, // the account playing here, whose button gets a border
|
|
grouped: false,
|
|
available: false,
|
|
taps: 0, // button presses not yet sent
|
|
inflight: false,
|
|
busy: false, // a grouping change is in flight
|
|
playBusy: false,
|
|
};
|
|
rooms[key] = room;
|
|
|
|
room.steps.forEach((button) => {
|
|
const direction = Number(button.dataset.delta);
|
|
holdable(button, () => nudge(key, direction));
|
|
});
|
|
|
|
room.toggle.addEventListener('click', () => setGrouped(key, !room.grouped));
|
|
room.play.addEventListener('click', () => togglePlay(key));
|
|
room.next.addEventListener('click', () => skipTrack(key));
|
|
});
|
|
|
|
function paintRoom(room) {
|
|
const known = room.volume !== null && room.volume !== undefined;
|
|
room.level.textContent = known ? room.volume + '%' : '—';
|
|
room.bar.style.width = `${known ? room.volume : 0}%`;
|
|
room.node.classList.toggle('offline', !room.available);
|
|
room.steps.forEach((button) => { button.disabled = !room.available; });
|
|
const playing = room.playState === 'play';
|
|
room.play.classList.toggle('playing', playing);
|
|
room.play.disabled = !room.available || room.playState === null;
|
|
room.play.setAttribute(
|
|
'aria-label', `${room.node.querySelector('h2').textContent}: ${playing ? 'pause' : 'play'}`);
|
|
// Play/pause and next only mean something for a Spotify stream: an AVR
|
|
// input has no queue to pause or skip, and starting Spotify is what the
|
|
// account buttons are for. Grouped, transport belongs to the AVR's card,
|
|
// not this one -- pressing either here would still work (it shares the
|
|
// group's transport) but only invites confusion about which card is
|
|
// actually in charge of it.
|
|
const transport = room.onSpotify && !room.grouped;
|
|
room.play.hidden = !transport;
|
|
room.next.hidden = !transport || !playing;
|
|
room.next.disabled = !room.available;
|
|
// Resuming stays on offer while grouped: only the speakers have Spotify
|
|
// buttons, so hiding them here would leave none at all.
|
|
if (room.spotify) {
|
|
room.spotify.hidden = !room.available;
|
|
els('.spotify', room.spotify).forEach((button) => {
|
|
button.setAttribute('aria-pressed', String(button.dataset.account === room.spotifyAccount));
|
|
});
|
|
}
|
|
|
|
room.toggle.disabled = !room.available;
|
|
room.toggle.classList.toggle('busy', room.busy);
|
|
room.toggle.setAttribute('aria-pressed', String(room.grouped));
|
|
// Where the card sits already says whether it is grouped, so the button
|
|
// says what tapping it does instead.
|
|
room.toggleLabel.textContent = room.grouped ? 'Leave' : `Join ${HOST_LABEL}`;
|
|
placeRoom(room);
|
|
}
|
|
|
|
/* A merged room moves into the host's card, because that is what merging
|
|
means: one group, playing one thing. Leaving puts the card back in its
|
|
own slot, which is why the slots exist. */
|
|
function placeRoom(room) {
|
|
const target = room.grouped ? ui.joinedRooms : room.slot;
|
|
if (room.node.parentElement !== target) target.appendChild(room.node);
|
|
}
|
|
|
|
function paintGrouping() {
|
|
const order = Object.keys(rooms);
|
|
const inside = Array.from(ui.joinedRooms.children);
|
|
// Keep them in the order the cards are declared, not the order they joined.
|
|
inside
|
|
.slice()
|
|
.sort((a, b) => order.indexOf(a.dataset.room) - order.indexOf(b.dataset.room))
|
|
.forEach((node) => ui.joinedRooms.appendChild(node));
|
|
ui.joined.hidden = inside.length === 0;
|
|
ui.splitAll.hidden = inside.length === 0;
|
|
}
|
|
|
|
/* Press and hold to keep moving, accelerating as you hold. */
|
|
function holdable(node, action) {
|
|
let timer = null;
|
|
let delay = 420;
|
|
|
|
const stop = () => { clearTimeout(timer); timer = null; delay = 420; };
|
|
const tick = () => {
|
|
action();
|
|
delay = Math.max(130, delay * 0.72);
|
|
timer = setTimeout(tick, delay);
|
|
};
|
|
|
|
node.addEventListener('pointerdown', (event) => {
|
|
if (event.button > 0 || node.disabled) return;
|
|
event.preventDefault(); // also suppresses the click that would double-fire
|
|
action();
|
|
timer = setTimeout(tick, delay);
|
|
});
|
|
['pointerup', 'pointercancel', 'pointerleave'].forEach((type) => node.addEventListener(type, stop));
|
|
}
|
|
|
|
/* A tap lands on the next multiple of STEP rather than adding STEP, so a
|
|
level of 23 goes to 25 on the way up and 20 on the way down. Mirrors
|
|
stepped_level() in controller.py -- the panel guesses with it, the
|
|
speakers are the ones that decide. */
|
|
function nextLevel(current, direction) {
|
|
if (direction > 0) return Math.min(100, (Math.floor(current / STEP) + 1) * STEP);
|
|
const aligned = Math.floor(current / STEP) * STEP;
|
|
return Math.max(0, aligned === current ? current - STEP : aligned);
|
|
}
|
|
|
|
function nudge(key, direction) {
|
|
const room = rooms[key];
|
|
if (!room.available) return;
|
|
room.volume = nextLevel(room.volume ?? 0, direction);
|
|
room.taps += direction;
|
|
paintRoom(room);
|
|
if (room.taps === 0) refresh(); // taps cancelled out; take the real level
|
|
else flushVolume(key);
|
|
}
|
|
|
|
/* A burst of taps collapses into one call, so holding + does not queue up
|
|
thirty requests the speakers then have to chew through. */
|
|
async function flushVolume(key) {
|
|
const room = rooms[key];
|
|
if (room.inflight || !room.taps) return;
|
|
const steps = room.taps;
|
|
room.taps = 0;
|
|
room.inflight = true;
|
|
try {
|
|
const data = await api('/api/volume', { target: key, steps });
|
|
if (!room.taps) {
|
|
room.volume = data.level;
|
|
paintRoom(room);
|
|
}
|
|
} catch (error) {
|
|
toast(error.message);
|
|
refresh();
|
|
} finally {
|
|
room.inflight = false;
|
|
if (room.taps) flushVolume(key);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
/* Fire-and-forget: the queue's next track has no local state to reconcile,
|
|
so there is nothing to optimistically flip the way play/pause does. */
|
|
async function skipTrack(key) {
|
|
const room = rooms[key];
|
|
if (!room.available) return;
|
|
try {
|
|
await api('/api/skip', { target: key, direction: 'next' });
|
|
} catch (error) {
|
|
toast(error.message);
|
|
}
|
|
}
|
|
|
|
function applyJoined(joined) {
|
|
Object.values(rooms).forEach((room) => {
|
|
room.grouped = joined.includes(room.key);
|
|
paintRoom(room);
|
|
});
|
|
paintGrouping();
|
|
}
|
|
|
|
ui.splitAll.addEventListener('click', async () => {
|
|
try {
|
|
applyJoined([]);
|
|
const data = await api('/api/group/none', {}); // {} so it is a POST, as the route requires
|
|
applyJoined(data.joined || []);
|
|
} catch (error) {
|
|
toast(error.message);
|
|
} finally {
|
|
refresh();
|
|
}
|
|
});
|
|
|
|
/* --- Spotify: one button per account, asking the card's own Connect
|
|
receiver to resume that account, instead of always connecting to it
|
|
from the Spotify app -------------------------------------------------- */
|
|
els('.spotify').forEach((button) => {
|
|
button.addEventListener('click', () => resumeSpotify(button));
|
|
});
|
|
|
|
async function resumeSpotify(button) {
|
|
const { target, account } = button.dataset;
|
|
const who = button.querySelector('span').textContent;
|
|
button.disabled = true;
|
|
try {
|
|
const data = await api('/api/spotify/resume', { target, account });
|
|
toast(`Resuming ${who}'s Spotify on ${data.device}`, 'ok');
|
|
const room = rooms[target];
|
|
room.spotifyAccount = account; // show it straight away; the refresh below confirms it
|
|
paintRoom(room);
|
|
// HEOS takes a moment to notice the new stream; look again once it
|
|
// has, so play/pause turns up without waiting for the next poll.
|
|
setTimeout(refresh, 1500);
|
|
} catch (error) {
|
|
toast(error.message);
|
|
} finally {
|
|
button.disabled = false;
|
|
}
|
|
}
|
|
|
|
/* --- the input picker -------------------------------------------------- */
|
|
ui.inputButton.addEventListener('click', openSheet);
|
|
el('[data-role="scrim"]').addEventListener('click', closeSheet);
|
|
el('[data-role="sheet-close"]').addEventListener('click', closeSheet);
|
|
|
|
function sheetOpen() { return !ui.sheet.hidden; }
|
|
|
|
function openSheet() {
|
|
if (!inputs.length) {
|
|
toast('No inputs reported by the AVR yet');
|
|
return;
|
|
}
|
|
ui.options.innerHTML = '';
|
|
inputs.forEach((source) => {
|
|
const option = document.createElement('button');
|
|
option.className = 'option';
|
|
option.setAttribute('aria-current', String(currentInput && currentInput.code === source.code));
|
|
option.innerHTML = '<span></span><span class="code"></span>';
|
|
option.firstChild.textContent = source.name;
|
|
option.lastChild.textContent = source.code;
|
|
option.addEventListener('click', () => chooseInput(source));
|
|
ui.options.appendChild(option);
|
|
});
|
|
ui.sheet.hidden = false;
|
|
}
|
|
|
|
function closeSheet() { ui.sheet.hidden = true; }
|
|
|
|
async function chooseInput(source) {
|
|
closeSheet();
|
|
currentInput = source;
|
|
ui.inputName.textContent = source.name;
|
|
try {
|
|
const data = await api('/api/avr/input', { code: source.code });
|
|
currentInput = data;
|
|
ui.inputName.textContent = data.name;
|
|
} catch (error) {
|
|
toast(error.message);
|
|
refresh();
|
|
}
|
|
}
|
|
|
|
/* --- state ------------------------------------------------------------- */
|
|
function render(state) {
|
|
(state.rooms || []).forEach((incoming) => {
|
|
const room = rooms[incoming.key];
|
|
if (!room) return;
|
|
room.available = incoming.available;
|
|
room.grouped = incoming.grouped;
|
|
// Do not stomp on a volume the user is in the middle of changing.
|
|
if (!room.taps && !room.inflight) room.volume = incoming.volume;
|
|
if (!room.playBusy) room.playState = incoming.play_state;
|
|
room.onSpotify = Boolean(incoming.spotify);
|
|
room.spotifyAccount = incoming.spotify_account || null;
|
|
paintRoom(room);
|
|
});
|
|
paintGrouping();
|
|
|
|
const avr = state.avr || {};
|
|
inputs = avr.inputs || [];
|
|
currentInput = avr.input || null;
|
|
ui.inputName.textContent = currentInput ? currentInput.name : '—';
|
|
ui.avrStatus.textContent = avr.connected ? 'ready' : 'offline';
|
|
ui.avrStatus.classList.toggle('on', Boolean(avr.connected));
|
|
|
|
const problems = state.errors || [];
|
|
ui.foot.textContent = problems.length ? problems[0] : (state.demo ? 'demo mode — no real speakers' : '');
|
|
ui.foot.classList.toggle('bad', problems.length > 0);
|
|
}
|
|
|
|
let refreshing = false;
|
|
async function refresh() {
|
|
if (refreshing) return;
|
|
refreshing = true;
|
|
try {
|
|
render(await api('/api/state'));
|
|
} catch (error) {
|
|
ui.foot.textContent = error.message;
|
|
ui.foot.classList.add('bad');
|
|
} finally {
|
|
refreshing = false;
|
|
}
|
|
}
|
|
|
|
function busy() {
|
|
return sheetOpen()
|
|
|| Object.values(rooms).some((r) => r.taps || r.inflight || r.busy || r.playBusy);
|
|
}
|
|
|
|
ui.refresh.addEventListener('click', () => {
|
|
ui.refresh.classList.add('spin');
|
|
setTimeout(() => ui.refresh.classList.remove('spin'), 700);
|
|
refresh();
|
|
});
|
|
|
|
setInterval(() => {
|
|
if (document.visibilityState === 'visible' && !busy()) refresh();
|
|
}, POLL_MS);
|
|
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (document.visibilityState === 'visible') refresh();
|
|
});
|
|
|
|
Object.values(rooms).forEach(paintRoom);
|
|
paintGrouping();
|
|
refresh();
|