/* 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 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"]'),
};
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(path, 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;
function toast(message) {
ui.toast.textContent = message;
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,
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),
steps: els('.step', node),
volume: null,
grouped: false,
available: false,
pending: 0, // taps not yet sent
inflight: false,
busy: false, // a grouping change is in flight
};
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));
});
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; });
room.toggle.disabled = !room.available;
room.toggle.classList.toggle('busy', room.busy);
room.toggle.setAttribute('aria-pressed', String(room.grouped));
room.toggleLabel.textContent = room.grouped ? 'Grouped with AVR' : 'Separate';
}
/* 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));
}
function nudge(key, direction) {
const room = rooms[key];
if (!room.available) return;
room.volume = Math.max(0, Math.min(100, (room.volume ?? 0) + direction * STEP));
room.pending += direction * STEP;
paintRoom(room);
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.pending) return;
const delta = room.pending;
room.pending = 0;
room.inflight = true;
try {
const data = await api('/api/volume', { target: key, delta });
if (!room.pending) {
room.volume = data.level;
paintRoom(room);
}
} catch (error) {
toast(error.message);
refresh();
} finally {
room.inflight = false;
if (room.pending) 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);
try {
const data = await api('/api/group', { target: key, joined });
applyJoined(data.joined || []);
} catch (error) {
toast(error.message);
} finally {
room.busy = false;
refresh();
}
}
function applyJoined(joined) {
Object.values(rooms).forEach((room) => {
room.grouped = joined.includes(room.key);
paintRoom(room);
});
}
ui.splitAll.addEventListener('click', async () => {
try {
applyJoined([]);
const data = await api('/api/group/none');
applyJoined(data.joined || []);
} catch (error) {
toast(error.message);
} finally {
refresh();
}
});
/* --- the input picker -------------------------------------------------- */
ui.inputButton.addEventListener('click', openSheet);
el('[data-role="scrim"]').addEventListener('click', closeSheet);
el('[data-role="sheet-close"]').addEventListener('click', closeSheet);
function sheetOpen() { return !ui.sheet.hidden; }
function openSheet() {
if (!inputs.length) {
toast('No inputs reported by the AVR yet');
return;
}
ui.options.innerHTML = '';
inputs.forEach((source) => {
const option = document.createElement('button');
option.className = 'option';
option.setAttribute('aria-current', String(currentInput && currentInput.code === source.code));
option.innerHTML = '';
option.firstChild.textContent = source.name;
option.lastChild.textContent = source.code;
option.addEventListener('click', () => chooseInput(source));
ui.options.appendChild(option);
});
ui.sheet.hidden = false;
}
function closeSheet() { ui.sheet.hidden = true; }
async function chooseInput(source) {
closeSheet();
currentInput = source;
ui.inputName.textContent = source.name;
try {
const data = await api('/api/avr/input', { code: source.code });
currentInput = data;
ui.inputName.textContent = data.name;
} catch (error) {
toast(error.message);
refresh();
}
}
/* --- state ------------------------------------------------------------- */
function render(state) {
(state.rooms || []).forEach((incoming) => {
const room = rooms[incoming.key];
if (!room) return;
room.available = incoming.available;
room.grouped = incoming.grouped;
// Do not stomp on a volume the user is in the middle of changing.
if (!room.pending && !room.inflight) room.volume = incoming.volume;
paintRoom(room);
});
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.pending || r.inflight || r.busy);
}
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);
refresh();