diff --git a/README.md b/README.md index c2aaffc..fe5cfb1 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A phone-sized web remote for a multi-room HEOS system, meant to be added to the Everything fits on one screen: -- **Volume** up/down for each room. A tap lands on the next multiple of `VOLUME_STEP` — from 23 it goes to 25, not 28 — so the levels stay round. Hold to keep moving, or drag the level along its bar to set it outright. +- **Volume** up/down for each room and for the AVR. A tap lands on the next multiple of `VOLUME_STEP` — from 23 it goes to 25, not 28 — so the levels stay round. Hold to keep moving, or drag the level along its bar to set it outright. - **Navigate** either room (play / pause / prev / next), while it is playing Spotify - **Group** either room with the AVR. the AVR is always the host, so its sound takes over whatever joins it. A room that joins moves _into_ the Home Cinema card, so one glance says what is playing together - **Ungroup** either room again, or all of them at once @@ -138,7 +138,7 @@ Used by the interface: | --- | --- | | `GET /api/state` | everything the UI draws, in one call | | `GET /api/targets` | every player and group HEOS can see | -| `POST /api/volume` | `{"target": "lego_room", "steps": 1}` — taps, snapped to `VOLUME_STEP`. Also takes `delta` (raw points) or `level` (absolute) | +| `POST /api/volume` | `{"target": "lego_room", "steps": 1}` — taps, snapped to `VOLUME_STEP`. Also takes `delta` (raw points) or `level` (absolute). `avr` is a target like any room | | `POST /api/mute` | `{"target": "lego_room"}` | | `POST /api/playback` | `{"target": "lego_room", "state": "pause"}`, or no `state` to toggle — the Zidoo's film for the AVR (`play` and `pause` only), a room's own stream over HEOS otherwise | | `POST /api/skip` | `{"target": "lego_room", "direction": "next"}` — `previous` too | diff --git a/controller.py b/controller.py index dc7321e..07d0aca 100644 --- a/controller.py +++ b/controller.py @@ -138,10 +138,18 @@ class Controller: def _id_param(scope: str) -> str: return "pid" if scope == "player" else "gid" - def _read_volume(self, scope, obj_id) -> int: - reply = self.heos.command(f"{scope}/get_volume", **{self._id_param(scope): obj_id}) + @staticmethod + def _level_in(reply) -> int: + """The level a get_volume reply carries, or None when there is no + answer to read -- a player that was never asked, or one whose reply + came back a HeosError.""" + if reply is None or isinstance(reply, HeosError): + return None return int(parse_message(reply["heos"]["message"]).get("level", -1)) + def _read_volume(self, scope, obj_id) -> int: + return self._level_in(self.heos.command(f"{scope}/get_volume", **{self._id_param(scope): obj_id})) + def volume(self, key: str) -> int: with self._lock: self._fresh() @@ -470,13 +478,13 @@ class Controller: snapshot = { "host": {"key": self.cfg.HOST_KEY, "label": self.cfg.TARGETS[self.cfg.HOST_KEY]["label"]}, "rooms": [], - "avr": {"connected": False, "input": None, "inputs": []}, + "avr": {"connected": False, "input": None, "inputs": [], "volume": None}, "heos_ok": True, "errors": [], } host_pid = None - avr_now_playing = None + avr_replies = {} with self._lock: try: self.scan() @@ -502,14 +510,19 @@ class Controller: ] request_keys += [(key, "volume"), (key, "play_state"), (key, "now_playing")] if host_pid is not None: - requests.append(("player/get_now_playing_media", {"pid": host_pid})) - request_keys.append((None, "avr_now_playing")) + # The AVR is a player like any other, so its own volume + # rides along in the same batch as the rooms'. + requests += [ + ("player/get_volume", {"pid": host_pid}), + ("player/get_now_playing_media", {"pid": host_pid}), + ] + request_keys += [(None, "volume"), (None, "now_playing")] results = self.heos.command_batch(requests) if requests else [] by_room = {} for (key, field), result in zip(request_keys, results): if key is None: - avr_now_playing = result + avr_replies[field] = result else: by_room.setdefault(key, {})[field] = result @@ -533,7 +546,7 @@ class Controller: volume_reply = data.get("volume") if isinstance(volume_reply, HeosError): raise volume_reply - room["volume"] = int(parse_message(volume_reply["heos"]["message"]).get("level", -1)) + room["volume"] = self._level_in(volume_reply) play_reply = data.get("play_state") if isinstance(play_reply, HeosError): @@ -569,6 +582,7 @@ class Controller: try: current_input = None + avr_now_playing = avr_replies.get("now_playing") if host_pid is not None and avr_now_playing is not None and not isinstance(avr_now_playing, HeosError): payload = avr_now_playing.get("payload") or {} mid = payload.get("mid", "") @@ -578,6 +592,7 @@ class Controller: "connected": host_pid is not None, "inputs": self.avr_inputs(), "input": current_input, + "volume": self._level_in(avr_replies.get("volume")), "now_playing": self.zidoo_now_playing(current_input), } except (HeosError, TargetError) as exc: diff --git a/demo.py b/demo.py index 90cc21d..8ff9af0 100644 --- a/demo.py +++ b/demo.py @@ -79,6 +79,7 @@ class DemoController: ], "avr": { "connected": True, "inputs": self.avr.inputs(), "input": self.avr.current_input(), + "volume": self._volume[self.cfg.HOST_KEY], # Standing in for a Zidoo plugged into this input, so the # AVR card's now-playing block has something to show here too. "now_playing": {"song": "Big Buck Bunny", "artist": "2008", "image": None, diff --git a/static/app.js b/static/app.js index 9a55ad8..df269aa 100644 --- a/static/app.js +++ b/static/app.js @@ -151,7 +151,7 @@ els('.card[data-room]').forEach((node) => { const direction = Number(button.dataset.delta); holdable(button, () => nudge(key, direction)); }); - if (room.meter) draggable(room); + draggable(room); seekable(room); // A cover that will not load (gone, or plain http on an https page) is @@ -165,10 +165,7 @@ els('.card[data-room]').forEach((node) => { if (room.next) room.next.addEventListener('click', () => skipTrack(key, 'next')); }); -/* The volume row, which the AVR's card has not got: its own level is not in - /api/state, so there is nothing to draw a meter from. */ function paintVolume(room) { - if (!room.meter) return; const known = room.volume !== null && room.volume !== undefined; room.level.textContent = known ? room.volume : '—'; // The fill and the knob riding it both size themselves off this. @@ -191,9 +188,6 @@ function paintTransport(room) { if (room.next) room.next.disabled = !room.available; } -/* The Spotify buttons and the host toggle, which the AVR's card has not got: - those resume an account in a room, or join it to the AVR, and neither is - something the AVR does to itself. */ function paintSources(room) { if (!room.sources) return; // Grouped, the room plays whatever the AVR does, so resuming Spotify on it @@ -787,6 +781,7 @@ function render(state) { // play state is that device's -- HEOS has none to give for an input. applyRoom(rooms[HOST], { available: avr.connected !== false, + volume: avr.volume, now_playing: avr.now_playing || null, play_state: (avr.now_playing || {}).play_state || null, }); diff --git a/templates/index.html b/templates/index.html index 679870a..e0c6e71 100644 --- a/templates/index.html +++ b/templates/index.html @@ -83,7 +83,6 @@ - {% if not avr %}
- {% endif %}