Fix grouped AVR / room card + add movie info
Deploy HEOS panel / deploy (push) Successful in 25s

This commit is contained in:
2026-09-16 20:36:29 +02:00
parent 69476134f7
commit 532b48ad37
7 changed files with 316 additions and 123 deletions
+47 -50
View File
@@ -3,7 +3,6 @@
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;
@@ -26,7 +25,7 @@ const ICONS = {
'lcr-fronts': SPEAKERS,
'surround-sound-system': SPEAKERS,
'subwoofer': SPEAKER,
mediaplayer: { viewbox: '0 0 640 544', path: 'M192 64c0-35.3 28.7-64 64-64L544 0c35.3 0 64 28.7 64 64l0 120.4c-6.8-.8-13.8-.6-20.9 .6l-64.1 11.7-54.5-89.2C464.1 100.4 456.4 96 448 96s-16.1 4.4-20.5 11.5l-54 88.3-17.9-25.6c-4.5-6.4-11.8-10.2-19.7-10.2s-15.2 3.8-19.7 10.2l-56 80c-5.1 7.3-5.8 16.9-1.6 24.8S271.1 288 280 288l72 0 0 64-96 0c-35.3 0-64-28.7-64-64l0-224zM352 403.8c-15.3 3.8-29.7 10.5-42.1 19.7-8.7 6.5-16.8 14.8-23.3 24.5L64 448c-35.3 0-64-28.7-64-64L0 160c0-35.3 28.7-64 64-64l80 0 0 192c0 61.9 50.1 112 112 112l96 0 0 3.8zM320 96a32 32 0 1 0 -64 0 32 32 0 1 0 64 0zM52 164l0 24c0 8.8 7.2 16 16 16l24 0c8.8 0 16-7.2 16-16l0-24c0-8.8-7.2-16-16-16l-24 0c-8.8 0-16 7.2-16 16zm16 80c-8.8 0-16 7.2-16 16l0 24c0 8.8 7.2 16 16 16l24 0c8.8 0 16-7.2 16-16l0-24c0-8.8-7.2-16-16-16l-24 0zm0 96c-8.8 0-16 7.2-16 16l0 24c0 8.8 7.2 16 16 16l24 0c8.8 0 16-7.2 16-16l0-24c0-8.8-7.2-16-16-16l-24 0zm556-84.1l0 208c0 23.3-18 35.4-29.1 40.5-11.9 5.4-25.4 7.5-34.9 7.5s-22.9-2.1-34.9-7.5c-11.1-5-29.1-17.2-29.1-40.5s18-35.4 29.1-40.5c11.9-5.4 25.4-7.5 34.9-7.5 4.6 0 10.1 .5 16 1.6l0-84.9-128 23.3 0 139.9c0 23.3-18 35.4-29.1 40.5-11.9 5.4-25.4 7.5-34.9 7.5s-22.9-2.1-34.9-7.5c-11.1-5-29.1-17.2-29.1-40.5s18-35.4 29.1-40.5c11.9-5.4 25.4-7.5 34.9-7.5 4.6 0 10.1 .5 16 1.6l0-161.6c0-11.6 8.3-21.5 19.7-23.6l176-32c7-1.3 14.2 .6 19.7 5.2s8.6 11.3 8.6 18.4z' },
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,
@@ -53,29 +52,31 @@ 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"]'),
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"]'),
joinedRooms: el('[data-role="joined-rooms"]'),
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} 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.
// 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,
};
// Same reasoning as a room's own cover: drop one that will not load rather
// than leave a broken-image box.
@@ -84,6 +85,7 @@ avrTrack.cover.addEventListener('error', () => { avrTrack.cover.hidden = true; }
const rooms = {};
let inputs = [];
let currentInput = null;
let avrNowPlaying = null;
/* --- transport -------------------------------------------------------- */
async function api(path, body) {
@@ -122,7 +124,7 @@ els('.room').forEach((node) => {
prev: el('[data-role="prev"]', 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
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),
@@ -169,7 +171,7 @@ function paintRoom(room) {
room.meter.style.setProperty('--level', known ? room.volume : 0);
room.node.classList.toggle('offline', !room.available);
room.steps.forEach((button) => { button.disabled = !room.available; });
paintTrack(room);
renderTrack(room.available ? room.track : null, room);
const playing = room.playState === 'play';
room.node.classList.toggle('playing', playing);
room.play.classList.toggle('playing', playing);
@@ -187,27 +189,31 @@ function paintRoom(room) {
room.actions.hidden = !(room.onSpotify && room.spotifyAccount && !room.grouped);
room.prev.disabled = !room.available;
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));
});
}
// 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));
// 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}`;
// 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 and cover, each only when there is one. The cover's src is
only touched when the track changes, so a poll never makes it flicker. */
/* 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.progress);
refs.nowPlaying.hidden = !track;
if (!track) return;
refs.song.textContent = track.song;
@@ -222,44 +228,46 @@ function renderTrack(track, refs) {
}
}
function paintTrack(room) {
renderTrack(room.available ? room.track : null, room);
paintProgress(room);
}
/* 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. */
function paintProgress(room) {
const track = room.available ? room.track : null;
room.progress.hidden = !track;
function renderProgress(track, progress) {
progress.hidden = !track;
if (!track) return;
const { position_ms: position, duration_ms: duration } = track;
const known = typeof duration === 'number' && duration > 0 && typeof position === 'number';
const percent = known ? Math.min(100, Math.max(0, (position / duration) * 100)) : 0;
room.progress.classList.toggle('known', known);
room.progress.style.setProperty('--progress', percent);
progress.classList.toggle('known', known);
progress.style.setProperty('--progress', percent);
}
/* 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;
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.joinedRooms.children);
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.joinedRooms.appendChild(node));
.forEach((node) => ui.joined.appendChild(node));
ui.joined.hidden = inside.length === 0;
ui.splitAll.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.
const groupPlaying = inside.some((node) => node.classList.contains('playing'));
ui.source.classList.toggle('playing', groupPlaying);
// A film on the Zidoo also moves the AVR up. The Zidoo does not say whether
// it is paused, so a loaded film counts, and it gets no tint.
ui.source.classList.toggle('on-top', groupPlaying || Boolean(avrNowPlaying));
}
/* Press and hold to keep moving, accelerating as you hold. */
@@ -438,18 +446,6 @@ function applyJoined(joined) {
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 -------------------------------------------------- */
@@ -543,7 +539,6 @@ function render(state) {
room.spotifyAccount = incoming.spotify_account || null;
paintRoom(room);
});
paintGrouping();
const avr = state.avr || {};
inputs = avr.inputs || [];
@@ -552,7 +547,9 @@ function render(state) {
setInputIcon(currentInput ? currentInput.code : null);
ui.avrStatus.textContent = avr.connected ? 'ready' : 'offline';
ui.avrStatus.classList.toggle('on', Boolean(avr.connected));
renderTrack(avr.now_playing || null, avrTrack);
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' : '');