From 4dd7e298a5aed9f8ecdd4b9f270936b4e01befc0 Mon Sep 17 00:00:00 2001 From: franzz Date: Wed, 16 Sep 2026 15:03:45 +0200 Subject: [PATCH] Add song progress bar --- controller.py | 8 +++++++- heos.py | 38 ++++++++++++++++++++++++++++++++++++++ static/app.js | 18 ++++++++++++++++++ static/panel.css | 40 +++++++++++++++++++++++++++++++++++----- templates/index.html | 5 +++++ 5 files changed, 103 insertions(+), 6 deletions(-) diff --git a/controller.py b/controller.py index 1d6907c..399a220 100644 --- a/controller.py +++ b/controller.py @@ -453,7 +453,13 @@ class Controller: # Kept while paused, so the card does not jump about # under the thumb that just pressed pause. 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: room["available"] = False room["error"] = str(exc) diff --git a/heos.py b/heos.py index 0212d83..8361aa8 100644 --- a/heos.py +++ b/heos.py @@ -43,6 +43,11 @@ class HeosClient: self._sock = None self._buffer = b"" 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 ----------------------------------------- def _connect(self): @@ -51,6 +56,14 @@ class HeosClient: sock.settimeout(self.timeout) self._sock = sock 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): if self._sock is not None: @@ -106,6 +119,7 @@ class HeosClient: heos = reply.get("heos", {}) if heos.get("command") != path: + self._watch_progress(heos) continue # an event or a late reply to something else if "under process" in heos.get("message", ""): continue # placeholder ack; the real payload follows @@ -113,6 +127,30 @@ class HeosClient: raise HeosError(_failure_text(heos)) 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): """Keep the socket warm so the first press after an idle spell is as fast as the rest.""" diff --git a/static/app.js b/static/app.js index 7701872..a598bc4 100644 --- a/static/app.js +++ b/static/app.js @@ -88,6 +88,7 @@ els('.room').forEach((node) => { cover: el('[data-role="cover"]', node), song: el('[data-role="song"]', node), artist: el('[data-role="artist"]', node), + progress: el('[data-role="progress"]', node), steps: els('.step', node), volume: null, playState: null, @@ -131,6 +132,7 @@ function paintRoom(room) { room.steps.forEach((button) => { button.disabled = !room.available; }); paintTrack(room); const playing = room.playState === 'play'; + room.node.classList.toggle('playing', playing); room.play.classList.toggle('playing', playing); room.play.disabled = !room.available || room.playState === null; room.play.setAttribute( @@ -183,6 +185,22 @@ 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; + 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 diff --git a/static/panel.css b/static/panel.css index b29bafa..f4df1a8 100644 --- a/static/panel.css +++ b/static/panel.css @@ -83,6 +83,13 @@ svg { width: 22px; height: 22px; fill: currentColor; } gap: 14px; } .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 h2 { @@ -99,12 +106,8 @@ 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; @@ -118,6 +121,27 @@ svg { width: 22px; height: 22px; fill: currentColor; } .song { font-size: 20px; font-weight: 550; } .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 { display: flex; align-items: center; gap: 14px; } @@ -241,8 +265,14 @@ svg { width: 22px; height: 22px; fill: currentColor; } 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) 18%, transparent) 0%, + transparent 70%); +} .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 .song { font-size: 18px; } .joined .step { height: 54px; } diff --git a/templates/index.html b/templates/index.html index d42e1b6..1229f52 100644 --- a/templates/index.html +++ b/templates/index.html @@ -86,6 +86,11 @@ + +