This commit is contained in:
+7
-1
@@ -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)
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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
@@ -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; }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user