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' : '');
+42 -36
View File
@@ -13,6 +13,7 @@
--live: #3ddc97;
--warn: #ff7a6b;
--radius: 22px;
--gutter: 16px; /* from the screen's side to a card, and from one card to the next */
}
* { box-sizing: border-box; }
@@ -51,15 +52,20 @@ svg { width: 22px; height: 22px; fill: currentColor; }
.app {
max-width: 520px;
margin: 0 auto;
padding: max(12px, env(safe-area-inset-top)) max(16px, env(safe-area-inset-right))
max(24px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left));
padding: max(12px, env(safe-area-inset-top)) max(var(--gutter), env(safe-area-inset-right))
max(24px, env(safe-area-inset-bottom)) max(var(--gutter), env(safe-area-inset-left));
display: flex;
flex-direction: column;
gap: 14px;
gap: var(--gutter);
}
/* A playing card goes to the top. The rest stay in markup order (rooms as
room_keys lists them, then the AVR), because items with the same order
keep their place, so playing cards keep that order among themselves too.
Only the order changes, not the DOM, so a knob held mid-drag stays put. */
.app > .on-top { order: -1; }
/* --- header ---------------------------------------------------------- */
.top { display: flex; align-items: center; justify-content: space-between; padding: 6px 4px 0; }
.top { order: -2; display: flex; align-items: center; justify-content: space-between; padding: 6px 4px 0; }
.top h1 { margin: 0; line-height: 0; }
.top .logo { height: 34px; width: auto; display: block; }
@@ -84,8 +90,10 @@ svg { width: 22px; height: 22px; fill: currentColor; }
}
.card.offline { opacity: .5; }
/* A tint, not a repaint -- it should read at a glance without competing
with the song title right above it. */
.card.room.playing {
with the song title right above it. A group plays one thing, so it is the
host's card that lights up, not the rooms merged into it. */
.card.room.playing,
.card.source.playing {
background: linear-gradient(135deg,
color-mix(in srgb, var(--live) 25%, var(--card)) 0%,
var(--card) 70%);
@@ -118,6 +126,9 @@ svg { width: 22px; height: 22px; fill: currentColor; }
object-fit: cover;
background: var(--raised);
}
/* A film's jacket keeps its own portrait shape rather than being cropped
square like an album's. */
.cover.poster { width: 50px; height: 75px; border-radius: 9px; }
.track { display: flex; flex-direction: column; min-width: 0; }
.track span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.song { font-size: 20px; font-weight: 550; }
@@ -212,23 +223,27 @@ svg { width: 22px; height: 22px; fill: currentColor; }
.transport.playing .icon-play { display: none; }
.transport.playing .icon-pause { display: block; }
/* --- join / leave the AVR, beside the room's name ---------------------- */
/* --- join / leave the AVR, between the Spotify buttons ----------------- */
/* Styled like the .spotify buttons it sits between -- same height, corner,
type and full-strength ink -- but sized to its own label instead of
sharing the row equally. Like them, it says "not the active one" with a
plain background rather than by dimming itself, so the three read as
equal choices. */
.toggle {
flex: 0 0 auto;
padding: 7px 14px; border-radius: 999px;
height: 50px; padding: 0 14px; border-radius: 16px;
background: var(--raised);
display: flex; align-items: center; justify-content: center; gap: 8px;
font-size: 14px; font-weight: 550; white-space: nowrap;
color: var(--muted);
display: flex; align-items: center; justify-content: center; gap: 9px;
font-size: 15px; font-weight: 550; white-space: nowrap;
}
.toggle .dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; opacity: .6; }
.toggle .cinema { flex: 0 0 auto; height: 18px; width: auto; color: #fff; }
.toggle .leave { display: none; flex: 0 0 auto; color: #fff; }
.toggle[aria-pressed="true"] { background: var(--accent); color: #fff; }
.toggle[aria-pressed="true"] .dot { background: #fff; opacity: 1; }
.toggle:active { transform: scale(.985); }
.toggle:disabled { opacity: .5; }
.toggle.busy { opacity: .6; }
/* --- Spotify: one resume button per account ----------------------------- */
/* --- Spotify: one resume button per account, around the host toggle ----- */
.spotify-row { display: flex; gap: 10px; }
.spotify {
flex: 1; min-width: 0;
@@ -260,33 +275,24 @@ svg { width: 22px; height: 22px; fill: currentColor; }
.source-button .chevron { color: var(--muted); }
/* --- rooms merged into the host's card --------------------------------- */
.joined { display: flex; flex-direction: column; gap: 12px; }
.joined-title { margin: 0 2px; font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); }
.joined-rooms { display: flex; flex-direction: column; gap: 14px; }
.joined { display: flex; flex-direction: column; gap: 14px; }
/* The card stops being a card in here: one border around the group, not
one around every room in it. */
.joined .card.room { background: none; border: 0; border-radius: 0; padding: 0; gap: 12px; }
.joined .card.room + .card.room { border-top: 1px solid var(--edge); padding-top: 14px; }
/* Flush against the group's own card, so no radius here -- .playing has
to out-specificity the plain "background: none" above to show at all. */
.joined .card.room.playing {
background: linear-gradient(135deg,
color-mix(in srgb, var(--live) 25%, transparent) 0%,
transparent 70%);
}
/* In here a room is an outline rather than a card: its border is what says
it is playing with the AVR, and the fill -- a tint too, when it plays --
belongs to the group's card instead. Same specificity as
.card.room.playing, so being later is what wins. */
.joined .card.room { background: none; border-color: var(--ink); border-radius: 18px; padding: 14px; gap: 12px; }
.joined .card-head h2 { font-size: 15px; font-weight: 550; color: var(--muted); }
.joined .cover { width: 52px; height: 52px; border-radius: 10px; }
.joined .song { font-size: 18px; }
.joined .step { height: 54px; }
.joined .toggle,
.joined .toggle[aria-pressed="true"] {
height: 36px; font-size: 13px; font-weight: 500;
background: none; border: 1px solid var(--edge); color: var(--muted);
}
.joined .toggle .dot { display: none; }
.joined .spotify { height: 40px; font-size: 13px; font-weight: 500; }
.joined .toggle:active { background: var(--raised); color: var(--ink); }
/* In here the Spotify buttons hide and the toggle says "Detach", so it takes
their place and their look: the whole row, a plain background rather than
the pressed accent, and an exit icon where the host's would only muddle. */
.joined .toggle { flex: 1; min-width: 0; }
.joined .toggle[aria-pressed="true"] { background: var(--raised); color: inherit; }
.joined .toggle .cinema { display: none; }
.joined .toggle .leave { display: block; }
/* --- misc ------------------------------------------------------------- */
.wide { width: 100%; height: 50px; border-radius: 16px; font-size: 15px; }