Add song progress bar
Deploy HEOS panel / deploy (push) Successful in 25s

This commit is contained in:
2026-09-16 15:03:45 +02:00
parent 58a2dedc03
commit 4dd7e298a5
5 changed files with 103 additions and 6 deletions
+7 -1
View File
@@ -453,7 +453,13 @@ class Controller:
# Kept while paused, so the card does not jump about # Kept while paused, so the card does not jump about
# under the thumb that just pressed pause. # under the thumb that just pressed pause.
if room["play_state"] != "stop": if room["play_state"] != "stop":
room["now_playing"] = self._track(media) track = self._track(media)
if track is not None:
progress = self.heos.progress_for(self.playback_pid(key))
if progress and progress["duration"]:
track["position_ms"] = progress["cur_pos"]
track["duration_ms"] = progress["duration"]
room["now_playing"] = track
except (TargetError, HeosError, KeyError) as exc: except (TargetError, HeosError, KeyError) as exc:
room["available"] = False room["available"] = False
room["error"] = str(exc) room["error"] = str(exc)
+38
View File
@@ -43,6 +43,11 @@ class HeosClient:
self._sock = None self._sock = None
self._buffer = b"" self._buffer = b""
self._lock = threading.Lock() self._lock = threading.Lock()
# pid -> {"cur_pos", "duration"}, filled in from whatever
# unsolicited progress events turn up while we are reading the
# reply to some other command. Dropped on reconnect, since a gap
# in the connection is a gap in what we know.
self._progress = {}
# -- connection management ----------------------------------------- # -- connection management -----------------------------------------
def _connect(self): def _connect(self):
@@ -51,6 +56,14 @@ class HeosClient:
sock.settimeout(self.timeout) sock.settimeout(self.timeout)
self._sock = sock self._sock = sock
self._buffer = b"" self._buffer = b""
self._progress = {}
# Without this, HEOS never pushes the now-playing-progress events
# that _exchange() below watches for. Best-effort: a player that
# refuses it still works, it just never shows song progress.
try:
self._exchange("system/register_for_change_events", {"enable": "on"})
except (OSError, HeosError):
pass
def _close(self): def _close(self):
if self._sock is not None: if self._sock is not None:
@@ -106,6 +119,7 @@ class HeosClient:
heos = reply.get("heos", {}) heos = reply.get("heos", {})
if heos.get("command") != path: if heos.get("command") != path:
self._watch_progress(heos)
continue # an event or a late reply to something else continue # an event or a late reply to something else
if "under process" in heos.get("message", ""): if "under process" in heos.get("message", ""):
continue # placeholder ack; the real payload follows continue # placeholder ack; the real payload follows
@@ -113,6 +127,30 @@ class HeosClient:
raise HeosError(_failure_text(heos)) raise HeosError(_failure_text(heos))
return reply return reply
def _watch_progress(self, heos: dict):
"""Skims a passing player_now_playing_progress event for its
pid/cur_pos/duration, the only source of song-position data this
client has -- there is no dedicated listener, so this only catches
what happens to arrive while some other command is being read."""
if heos.get("command") != "event/player_now_playing_progress":
return
message = parse_message(heos.get("message", ""))
pid = message.get("pid")
if not pid:
return
try:
self._progress[pid] = {
"cur_pos": int(message.get("cur_pos", 0)),
"duration": int(message.get("duration", 0)),
}
except ValueError:
pass
def progress_for(self, pid) -> dict:
"""{"cur_pos", "duration"} in ms for a player, or None if no
progress event for it has come through yet this connection."""
return self._progress.get(str(pid))
def heart_beat(self): def heart_beat(self):
"""Keep the socket warm so the first press after an idle spell is """Keep the socket warm so the first press after an idle spell is
as fast as the rest.""" as fast as the rest."""
+18
View File
@@ -88,6 +88,7 @@ els('.room').forEach((node) => {
cover: el('[data-role="cover"]', node), cover: el('[data-role="cover"]', node),
song: el('[data-role="song"]', node), song: el('[data-role="song"]', node),
artist: el('[data-role="artist"]', node), artist: el('[data-role="artist"]', node),
progress: el('[data-role="progress"]', node),
steps: els('.step', node), steps: els('.step', node),
volume: null, volume: null,
playState: null, playState: null,
@@ -131,6 +132,7 @@ function paintRoom(room) {
room.steps.forEach((button) => { button.disabled = !room.available; }); room.steps.forEach((button) => { button.disabled = !room.available; });
paintTrack(room); paintTrack(room);
const playing = room.playState === 'play'; const playing = room.playState === 'play';
room.node.classList.toggle('playing', playing);
room.play.classList.toggle('playing', playing); room.play.classList.toggle('playing', playing);
room.play.disabled = !room.available || room.playState === null; room.play.disabled = !room.available || room.playState === null;
room.play.setAttribute( room.play.setAttribute(
@@ -183,6 +185,22 @@ function renderTrack(track, refs) {
function paintTrack(room) { function paintTrack(room) {
renderTrack(room.available ? room.track : null, 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;
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);
} }
/* A merged room moves into the host's card, because that is what merging /* A merged room moves into the host's card, because that is what merging
+35 -5
View File
@@ -83,6 +83,13 @@ svg { width: 22px; height: 22px; fill: currentColor; }
gap: 14px; gap: 14px;
} }
.card.offline { opacity: .5; } .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 {
background: linear-gradient(135deg,
color-mix(in srgb, var(--live) 18%, var(--card)) 0%,
var(--card) 70%);
}
.card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; } .card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.card-head h2 { .card-head h2 {
@@ -99,12 +106,8 @@ svg { width: 22px; height: 22px; fill: currentColor; }
.pill.on { color: var(--live); } .pill.on { color: var(--live); }
/* --- now playing, between the room's name and its volume --------------- */ /* --- 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 { .now-playing {
display: flex; align-items: center; gap: 16px; min-width: 0; display: flex; align-items: center; gap: 16px; min-width: 0;
padding-bottom: 14px;
border-bottom: 1px solid var(--edge);
} }
.cover { .cover {
flex: 0 0 auto; flex: 0 0 auto;
@@ -118,6 +121,27 @@ svg { width: 22px; height: 22px; fill: currentColor; }
.song { font-size: 20px; font-weight: 550; } .song { font-size: 20px; font-weight: 550; }
.artist { font-size: 17px; color: var(--muted); } .artist { font-size: 17px; color: var(--muted); }
/* Doubles as the line between the song and the volume -- it goes with the
block, when nothing is playing. Muted throughout, so it never competes
with the volume meter below it; the cursor only shows once a position is
actually known (an AVR input or a stream with no duration never gets one). */
.progress { position: relative; height: 3px; border-radius: 999px; background: #0c111b; }
.progress-fill {
display: block; height: 100%; width: calc(var(--progress, 0) * 1%);
border-radius: 999px; background: var(--muted);
transition: width .12s ease-out;
}
.progress-cursor {
display: none;
position: absolute; top: 50%;
left: calc(var(--progress, 0) * 1%);
width: 7px; height: 7px; margin-left: -3.5px;
border-radius: 50%; background: var(--muted);
transform: translateY(-50%);
transition: left .12s ease-out;
}
.progress.known .progress-cursor { display: block; }
/* --- volume ---------------------------------------------------------- */ /* --- volume ---------------------------------------------------------- */
.volume { display: flex; align-items: center; gap: 14px; } .volume { display: flex; align-items: center; gap: 14px; }
@@ -241,8 +265,14 @@ svg { width: 22px; height: 22px; fill: currentColor; }
one around every room in it. */ one around every room in it. */
.joined .card.room { background: none; border: 0; border-radius: 0; padding: 0; gap: 12px; } .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.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) 18%, transparent) 0%,
transparent 70%);
}
.joined .card-head h2 { font-size: 15px; font-weight: 550; color: var(--muted); } .joined .card-head h2 { font-size: 15px; font-weight: 550; color: var(--muted); }
.joined .now-playing { padding-bottom: 12px; }
.joined .cover { width: 52px; height: 52px; border-radius: 10px; } .joined .cover { width: 52px; height: 52px; border-radius: 10px; }
.joined .song { font-size: 18px; } .joined .song { font-size: 18px; }
.joined .step { height: 54px; } .joined .step { height: 54px; }
+5
View File
@@ -86,6 +86,11 @@
</div> </div>
</div> </div>
<div class="progress" data-role="progress" hidden>
<i data-role="progress-fill"></i>
<b class="progress-cursor" data-role="progress-cursor"></b>
</div>
<div class="volume"> <div class="volume">
<button class="step" data-delta="-1" aria-label="{{ room.label }}: volume down"> <button class="step" data-delta="-1" aria-label="{{ room.label }}: volume down">
<svg viewBox="0 0 448 512" aria-hidden="true"><path d="M432 256c0 17.7-14.3 32-32 32L48 288c-17.7 0-32-14.3-32-32s14.3-32 32-32l352 0c17.7 0 32 14.3 32 32z"/></svg> <svg viewBox="0 0 448 512" aria-hidden="true"><path d="M432 256c0 17.7-14.3 32-32 32L48 288c-17.7 0-32-14.3-32-32s14.3-32 32-32l352 0c17.7 0 32 14.3 32 32z"/></svg>