fix music navs
Deploy HEOS panel / deploy (push) Successful in 25s

This commit is contained in:
2026-09-15 23:19:11 +02:00
parent 7c19ff9096
commit 5b82a24ac4
9 changed files with 299 additions and 72 deletions
+119 -25
View File
@@ -59,20 +59,29 @@ els('.room').forEach((node) => {
node,
slot: el(`.room-slot[data-slot="${key}"]`),
level: el('[data-role="level"]', node),
bar: el('[data-role="bar"]', 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), // absent with no Spotify account set up
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),
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,
@@ -83,32 +92,42 @@ els('.room').forEach((node) => {
const direction = Number(button.dataset.delta);
holdable(button, () => nudge(key, direction));
});
draggable(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));
room.next.addEventListener('click', () => skipTrack(key, 'next'));
});
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.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; });
paintTrack(room);
const playing = room.playState === 'play';
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'}`);
// 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 either here would still work (it shares the
// group's transport) but only invites confusion about which card is
// actually in charge of it.
const transport = room.onSpotify && !room.grouped;
room.play.hidden = !transport;
room.next.hidden = !transport || !playing;
// 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;
// Resuming stays on offer while grouped: only the speakers have Spotify
// buttons, so hiding them here would leave none at all.
@@ -128,6 +147,24 @@ function paintRoom(room) {
placeRoom(room);
}
/* Song, artist and cover, each only when HEOS has one. The cover's src is
only touched when the track changes, so a poll never makes it flicker. */
function paintTrack(room) {
const track = room.available ? room.track : null;
room.nowPlaying.hidden = !track;
if (!track) return;
room.song.textContent = track.song;
room.artist.textContent = track.artist || '';
room.artist.hidden = !track.artist;
if (!track.image) {
room.cover.hidden = true;
room.cover.removeAttribute('src');
} else if (room.cover.getAttribute('src') !== track.image) {
room.cover.hidden = false;
room.cover.src = track.image;
}
}
/* 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. */
@@ -193,13 +230,23 @@ function nudge(key, direction) {
thirty requests the speakers then have to chew through. */
async function flushVolume(key) {
const room = rooms[key];
if (room.inflight || !room.taps) return;
const steps = room.taps;
room.taps = 0;
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', { target: key, steps });
if (!room.taps) {
const data = await api('/api/volume', body);
if (!room.taps && room.wanted === null && !room.dragging) {
room.volume = data.level;
paintRoom(room);
}
@@ -208,10 +255,52 @@ async function flushVolume(key) {
refresh();
} finally {
room.inflight = false;
if (room.taps) flushVolume(key);
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;
@@ -250,13 +339,15 @@ async function togglePlay(key) {
}
}
/* Fire-and-forget: the queue's next track has no local state to reconcile,
so there is nothing to optimistically flip the way play/pause does. */
async function skipTrack(key) {
/* 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: 'next' });
await api('/api/skip', { target: key, direction });
setTimeout(refresh, 1000);
} catch (error) {
toast(error.message);
}
@@ -359,8 +450,11 @@ function render(state) {
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.volume = incoming.volume;
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);
@@ -395,7 +489,7 @@ async function refresh() {
function busy() {
return sheetOpen()
|| Object.values(rooms).some((r) => r.taps || r.inflight || r.busy || r.playBusy);
|| Object.values(rooms).some((r) => r.taps || r.inflight || r.dragging || r.busy || r.playBusy);
}
ui.refresh.addEventListener('click', () => {
+68 -20
View File
@@ -85,14 +85,10 @@ svg { width: 22px; height: 22px; fill: currentColor; }
.card.offline { opacity: .5; }
.card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.card-head h2 { margin: 0; font-size: 17px; font-weight: 600; }
.level { color: var(--muted); font-size: 13px; }
.level b {
color: var(--ink);
font-size: 26px;
font-weight: 640;
font-variant-numeric: tabular-nums;
.card-head h2 {
margin: 0; min-width: 0;
font-size: 17px; font-weight: 600;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.pill {
@@ -102,6 +98,26 @@ svg { width: 22px; height: 22px; fill: currentColor; }
}
.pill.on { color: var(--live); }
/* --- now playing, between the room's name and its volume --------------- */
/* The padding matches the card's gap, so the line sits halfway between the
song and the volume. It goes with the block, when nothing is playing. */
.now-playing {
display: flex; align-items: center; gap: 16px; min-width: 0;
padding-bottom: 14px;
border-bottom: 1px solid var(--edge);
}
.cover {
flex: 0 0 auto;
width: 62px; height: 62px;
border-radius: 13px;
object-fit: cover;
background: var(--raised);
}
.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; }
.artist { font-size: 17px; color: var(--muted); }
/* --- volume ---------------------------------------------------------- */
.volume { display: flex; align-items: center; gap: 14px; }
@@ -116,12 +132,41 @@ svg { width: 22px; height: 22px; fill: currentColor; }
.step:active { background: var(--accent); transform: scale(.96); }
.step:disabled { opacity: .4; }
.meter { flex: 1; height: 8px; border-radius: 999px; background: #0c111b; overflow: hidden; }
.meter i { display: block; height: 100%; width: 0; border-radius: 999px; background: var(--accent); transition: width .12s ease-out; }
.meter { position: relative; flex: 1; height: 8px; border-radius: 999px; background: #0c111b; }
.meter i {
display: block; height: 100%; width: calc(var(--level, 0) * 1%);
border-radius: 999px; background: var(--accent);
transition: width .12s ease-out;
}
/* --- play / pause, beside the join button ------------------------------ */
.actions { display: flex; align-items: stretch; gap: 10px; }
.actions .toggle { flex: 1; }
/* The level rides the bar. It travels the track less its own width, so it
never hangs off either end over the buttons -- and the fill's end is
always somewhere underneath it. */
.knob {
position: absolute; top: 50%;
left: calc((100% - 46px) * var(--level, 0) / 100);
width: 46px; height: 30px;
transform: translateY(-50%);
display: grid; place-items: center;
border-radius: 999px;
background: var(--accent); color: #fff;
box-shadow: 0 0 0 3px var(--card);
font-size: 14px; font-weight: 640; font-variant-numeric: tabular-nums;
transition: left .12s ease-out;
cursor: grab;
touch-action: none; /* the drag is ours, not a page scroll */
}
/* A thumb-sized grip around a knob only 30px tall. A press on the
pseudo-element lands on the knob itself. */
.knob::before { content: ''; position: absolute; inset: -12px -8px; }
/* Under a finger the knob has to keep up, not ease after it. */
.meter.dragging i,
.meter.dragging .knob { transition: none; }
.meter.dragging .knob { cursor: grabbing; transform: translateY(-50%) scale(1.12); }
/* --- previous / play-pause / next, centred ----------------------------- */
.actions { display: flex; align-items: stretch; justify-content: center; gap: 10px; }
.transport {
flex: 0 0 auto;
@@ -141,15 +186,16 @@ svg { width: 22px; height: 22px; fill: currentColor; }
.transport.playing .icon-play { display: none; }
.transport.playing .icon-pause { display: block; }
/* --- join / leave the AVR -------------------------------------------- */
/* --- join / leave the AVR, beside the room's name ---------------------- */
.toggle {
height: 62px; border-radius: 16px;
flex: 0 0 auto;
height: 40px; padding: 0 14px; border-radius: 999px;
background: var(--raised);
display: flex; align-items: center; justify-content: center; gap: 10px;
font-size: 15px; font-weight: 550;
display: flex; align-items: center; justify-content: center; gap: 8px;
font-size: 14px; font-weight: 550; white-space: nowrap;
color: var(--muted);
}
.toggle .dot { width: 9px; height: 9px; border-radius: 50%; background: currentColor; opacity: .6; }
.toggle .dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; opacity: .6; }
.toggle[aria-pressed="true"] { background: var(--accent); color: #fff; }
.toggle[aria-pressed="true"] .dot { background: #fff; opacity: 1; }
.toggle:active { transform: scale(.985); }
@@ -196,11 +242,13 @@ svg { width: 22px; height: 22px; fill: currentColor; }
.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; }
.joined .card-head h2 { font-size: 15px; font-weight: 550; color: var(--muted); }
.joined .level b { font-size: 22px; }
.joined .now-playing { padding-bottom: 12px; }
.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: 40px; font-size: 13px; font-weight: 500;
height: 36px; font-size: 13px; font-weight: 500;
background: none; border: 1px solid var(--edge); color: var(--muted);
}
.joined .toggle .dot { display: none; }