739 lines
30 KiB
JavaScript
739 lines
30 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;
|
||
// 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();
|