merge all rooms (inc. AVR)
Deploy HEOS panel / deploy (push) Successful in 28s

This commit is contained in:
franzz committed 2026-09-20 18:10:32 +02:00
1 parent 0560079b16
commit db00e57ef7
9 files changed
+328 -228

No files matched your search

+114 -141
View File
@@ -8,6 +8,9 @@ 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;
// 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;
// Motion turned down in the OS means cards change places without sliding.
const CALM = matchMedia('(prefers-reduced-motion: reduce)');
@@ -63,45 +66,19 @@ const ui = {
app: el('.app'),
avr: el('.card.avr'),
joined: el('[data-role="joined"]'),
avrMedia: el('[data-role="avr-media"]'),
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 = {
media: ui.avrMedia,
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,
// A film paused on the Zidoo reports its own play_state; nothing else
// ever lands here with a position and duration to tick.
playing: () => avrNowPlaying?.play_state === 'play',
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);
// 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;
// Same as a room's card: named, so a view transition can follow it when it
// moves to the top and back.
// Named, so a view transition can follow the card when it moves to the top
// and back. A room's name is set as it is placed; the host's never moves.
ui.avr.style.viewTransitionName = 'avr';
const rooms = {};
let inputs = [];
let currentInput = null;
let avrNowPlaying = null;
/* --- transport -------------------------------------------------------- */
async function api(path, body) {
@@ -126,11 +103,12 @@ function toast(message, kind = 'error') {
}
/* --- rooms ------------------------------------------------------------ */
els('.room').forEach((node) => {
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),
@@ -159,10 +137,12 @@ els('.room').forEach((node) => {
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 paintRoom).
canSeek: () => room.onSpotify && room.spotifyAccount && !room.grouped,
playing: () => room.playState === 'play',
// 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;
@@ -171,7 +151,7 @@ els('.room').forEach((node) => {
const direction = Number(button.dataset.delta);
holdable(button, () => nudge(key, direction));
});
draggable(room);
if (room.meter) draggable(room);
seekable(room);
// A cover that will not load (gone, or plain http on an https page) is
@@ -179,37 +159,43 @@ els('.room').forEach((node) => {
// 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'));
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));
room.next.addEventListener('click', () => skipTrack(key, 'next'));
if (room.next) room.next.addEventListener('click', () => skipTrack(key, 'next'));
});
function paintRoom(room) {
/* The volume row, which the AVR's card has not got: its own level is not in
/api/state, so there is nothing to draw a meter from. */
function paintVolume(room) {
if (!room.meter) return;
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';
}
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 || room.playState === null;
room.play.disabled = !room.available || !state;
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;
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;
}
/* The Spotify buttons and the host toggle, which the AVR's card has not got:
those resume an account in a room, or join it to the AVR, and neither is
something the AVR does to itself. */
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
@@ -231,6 +217,14 @@ function paintRoom(room) {
// 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);
placeCards();
}
@@ -254,15 +248,6 @@ function renderTrack(track, refs) {
}
}
/* 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. Also leaves refs.tick pointing at this position and the moment
it was true, for tickProgress() to run forward from until the next call
here -- a poll, a drag, or a seek landing -- corrects it and moves the
mark again. */
function renderProgress(track, refs) {
refs.shown = null;
refs.tick = null;
@@ -305,12 +290,6 @@ function paintProgress(refs, known, position, duration) {
el('[data-role="progress-total"]', progress).textContent = known ? clock(total) : '';
}
/* Between polls the cursor otherwise sits still for POLL_MS at a time and
then jumps -- so this nudges it along at real speed from the last known
position, purely cosmetic until the next renderProgress() (a poll, most
often) corrects it and moves the mark again. Skipped while a seek owns
the display (seekPosition already covers that) or nothing is actually
playing, so a paused cursor stays put. */
function tickProgress(refs) {
if (!refs.tick || !refs.shown || refs.seek || !refs.playing()) return;
const { ms, at, duration } = refs.tick;
@@ -318,17 +297,8 @@ function tickProgress(refs) {
paintProgress(refs, true, position, duration);
}
setInterval(() => {
tickProgress(avrTrack);
Object.values(rooms).forEach(tickProgress);
}, 250);
setInterval(() => Object.values(rooms).forEach(tickProgress), 250);
/* 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);
@@ -384,7 +354,7 @@ async function sendSeek(refs) {
seek.stage = 'send';
seek.sentAt = Date.now();
try {
await api('/api/seek', { target: refs.progress.dataset.target, position_ms: seek.ms });
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);
@@ -396,13 +366,6 @@ async function sendSeek(refs) {
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;
@@ -417,8 +380,6 @@ function seekPosition(track, refs) {
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';
}
@@ -431,11 +392,10 @@ function clock(seconds) {
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 it back in the column,
ahead of the rooms declared after it, so the cards keep the order they are
declared in however they were grouped. */
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) {
@@ -465,20 +425,11 @@ function paintGrouping() {
// 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';
|| shownPlayState(rooms[HOST]) === 'play';
ui.avr.classList.toggle('playing', playing);
placeCards();
}
/* Which card sits at the top of the column is CSS order (see .on-top), and
order is not a property that transitions: the cards change places between
one frame and the next. A view transition does the sliding instead -- the
browser snapshots the cards where they are, runs the swap, and animates
them to where they landed. One turn can paint several cards (a poll
touches every room and then the group), so the move is collected and run
once, and only started when a card really does change places. Without
support, or with motion turned down, the cards just change places the way
they always did. */
let placing = false;
function placeCards() {
if (placing) return;
@@ -488,9 +439,12 @@ function placeCards() {
// Grouped, a room's card has left the column and the AVR's is the one
// that moves up. The AVR's tint and its position answer the same
// question, and paintGrouping() has just settled it.
const wanted = Object.values(rooms)
.map((room) => [room.node, room.playing() && !room.grouped]);
wanted.push([ui.avr, ui.avr.classList.contains('playing')]);
const wanted = Object.values(rooms).map((room) => [
room.node,
// The AVR's tint already answers this: paintGrouping has just settled
// it, over the whole group rather than the host's own stream alone.
room.avr ? room.node.classList.contains('playing') : room.playing() && !room.grouped,
]);
const move = () => wanted.forEach(([node, top]) => node.classList.toggle('on-top', top));
const moves = wanted.some(([node, top]) => node.classList.contains('on-top') !== top);
@@ -520,10 +474,6 @@ function holdable(node, action) {
['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;
@@ -573,11 +523,6 @@ async function 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;
@@ -633,23 +578,46 @@ async function setGrouped(key, joined) {
}
}
/* Sends the state it wants rather than "toggle", so a stale idea of what
the room is doing cannot flip it the wrong way. */
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];
if (!room.available || room.playBusy) return;
const wanted = room.playState === 'play' ? 'pause' : 'play';
const state = shownPlayState(room);
if (!room.available || !state || room.playBusy) return;
const wanted = state === 'play' ? 'pause' : 'play';
room.playBusy = true;
room.playState = wanted;
room.wantedPlay = { state: wanted, sentAt: Date.now() };
paintRoom(room);
paintGrouping();
try {
const data = await api('/api/playback', { target: key, state: wanted });
room.playState = data.state;
// 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);
}
}
@@ -792,31 +760,37 @@ async function chooseInput(source) {
}
/* --- 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) {
(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);
});
(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);
avrNowPlaying = avr.now_playing || null;
renderTrack(avrNowPlaying, avrTrack);
paintGrouping(); // after avrNowPlaying, which decides where the AVR's card sits
// 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,
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' : '');
@@ -839,7 +813,6 @@ async function refresh() {
function busy() {
return sheetOpen()
|| seeking(avrTrack)
|| Object.values(rooms).some((r) => r.taps || r.inflight || r.dragging || r.busy || r.playBusy || seeking(r));
}