This commit is contained in:
1 parent
db00e57ef7
commit
9a3d0b8a72
6 files changed
+42
-19
No files matched your search
@@ -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:
|
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
|
- **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
|
- **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
|
- **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/state` | everything the UI draws, in one call |
|
||||||
| `GET /api/targets` | every player and group HEOS can see |
|
| `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/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/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 |
|
| `POST /api/skip` | `{"target": "lego_room", "direction": "next"}` — `previous` too |
|
||||||
|
|||||||
+23
-8
@@ -138,10 +138,18 @@ class Controller:
|
|||||||
def _id_param(scope: str) -> str:
|
def _id_param(scope: str) -> str:
|
||||||
return "pid" if scope == "player" else "gid"
|
return "pid" if scope == "player" else "gid"
|
||||||
|
|
||||||
def _read_volume(self, scope, obj_id) -> int:
|
@staticmethod
|
||||||
reply = self.heos.command(f"{scope}/get_volume", **{self._id_param(scope): obj_id})
|
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))
|
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:
|
def volume(self, key: str) -> int:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._fresh()
|
self._fresh()
|
||||||
@@ -470,13 +478,13 @@ class Controller:
|
|||||||
snapshot = {
|
snapshot = {
|
||||||
"host": {"key": self.cfg.HOST_KEY, "label": self.cfg.TARGETS[self.cfg.HOST_KEY]["label"]},
|
"host": {"key": self.cfg.HOST_KEY, "label": self.cfg.TARGETS[self.cfg.HOST_KEY]["label"]},
|
||||||
"rooms": [],
|
"rooms": [],
|
||||||
"avr": {"connected": False, "input": None, "inputs": []},
|
"avr": {"connected": False, "input": None, "inputs": [], "volume": None},
|
||||||
"heos_ok": True,
|
"heos_ok": True,
|
||||||
"errors": [],
|
"errors": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
host_pid = None
|
host_pid = None
|
||||||
avr_now_playing = None
|
avr_replies = {}
|
||||||
with self._lock:
|
with self._lock:
|
||||||
try:
|
try:
|
||||||
self.scan()
|
self.scan()
|
||||||
@@ -502,14 +510,19 @@ class Controller:
|
|||||||
]
|
]
|
||||||
request_keys += [(key, "volume"), (key, "play_state"), (key, "now_playing")]
|
request_keys += [(key, "volume"), (key, "play_state"), (key, "now_playing")]
|
||||||
if host_pid is not None:
|
if host_pid is not None:
|
||||||
requests.append(("player/get_now_playing_media", {"pid": host_pid}))
|
# The AVR is a player like any other, so its own volume
|
||||||
request_keys.append((None, "avr_now_playing"))
|
# 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 []
|
results = self.heos.command_batch(requests) if requests else []
|
||||||
by_room = {}
|
by_room = {}
|
||||||
for (key, field), result in zip(request_keys, results):
|
for (key, field), result in zip(request_keys, results):
|
||||||
if key is None:
|
if key is None:
|
||||||
avr_now_playing = result
|
avr_replies[field] = result
|
||||||
else:
|
else:
|
||||||
by_room.setdefault(key, {})[field] = result
|
by_room.setdefault(key, {})[field] = result
|
||||||
|
|
||||||
@@ -533,7 +546,7 @@ class Controller:
|
|||||||
volume_reply = data.get("volume")
|
volume_reply = data.get("volume")
|
||||||
if isinstance(volume_reply, HeosError):
|
if isinstance(volume_reply, HeosError):
|
||||||
raise volume_reply
|
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")
|
play_reply = data.get("play_state")
|
||||||
if isinstance(play_reply, HeosError):
|
if isinstance(play_reply, HeosError):
|
||||||
@@ -569,6 +582,7 @@ class Controller:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
current_input = None
|
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):
|
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 {}
|
payload = avr_now_playing.get("payload") or {}
|
||||||
mid = payload.get("mid", "")
|
mid = payload.get("mid", "")
|
||||||
@@ -578,6 +592,7 @@ class Controller:
|
|||||||
"connected": host_pid is not None,
|
"connected": host_pid is not None,
|
||||||
"inputs": self.avr_inputs(),
|
"inputs": self.avr_inputs(),
|
||||||
"input": current_input,
|
"input": current_input,
|
||||||
|
"volume": self._level_in(avr_replies.get("volume")),
|
||||||
"now_playing": self.zidoo_now_playing(current_input),
|
"now_playing": self.zidoo_now_playing(current_input),
|
||||||
}
|
}
|
||||||
except (HeosError, TargetError) as exc:
|
except (HeosError, TargetError) as exc:
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ class DemoController:
|
|||||||
],
|
],
|
||||||
"avr": {
|
"avr": {
|
||||||
"connected": True, "inputs": self.avr.inputs(), "input": self.avr.current_input(),
|
"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
|
# Standing in for a Zidoo plugged into this input, so the
|
||||||
# AVR card's now-playing block has something to show here too.
|
# AVR card's now-playing block has something to show here too.
|
||||||
"now_playing": {"song": "Big Buck Bunny", "artist": "2008", "image": None,
|
"now_playing": {"song": "Big Buck Bunny", "artist": "2008", "image": None,
|
||||||
|
|||||||
+2
-7
@@ -151,7 +151,7 @@ els('.card[data-room]').forEach((node) => {
|
|||||||
const direction = Number(button.dataset.delta);
|
const direction = Number(button.dataset.delta);
|
||||||
holdable(button, () => nudge(key, direction));
|
holdable(button, () => nudge(key, direction));
|
||||||
});
|
});
|
||||||
if (room.meter) draggable(room);
|
draggable(room);
|
||||||
seekable(room);
|
seekable(room);
|
||||||
|
|
||||||
// A cover that will not load (gone, or plain http on an https page) is
|
// 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'));
|
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) {
|
function paintVolume(room) {
|
||||||
if (!room.meter) return;
|
|
||||||
const known = room.volume !== null && room.volume !== undefined;
|
const known = room.volume !== null && room.volume !== undefined;
|
||||||
room.level.textContent = known ? room.volume : '—';
|
room.level.textContent = known ? room.volume : '—';
|
||||||
// The fill and the knob riding it both size themselves off this.
|
// 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;
|
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) {
|
function paintSources(room) {
|
||||||
if (!room.sources) return;
|
if (!room.sources) return;
|
||||||
// Grouped, the room plays whatever the AVR does, so resuming Spotify on it
|
// 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.
|
// play state is that device's -- HEOS has none to give for an input.
|
||||||
applyRoom(rooms[HOST], {
|
applyRoom(rooms[HOST], {
|
||||||
available: avr.connected !== false,
|
available: avr.connected !== false,
|
||||||
|
volume: avr.volume,
|
||||||
now_playing: avr.now_playing || null,
|
now_playing: avr.now_playing || null,
|
||||||
play_state: (avr.now_playing || {}).play_state || null,
|
play_state: (avr.now_playing || {}).play_state || null,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -83,7 +83,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if not avr %}
|
|
||||||
<div class="volume">
|
<div class="volume">
|
||||||
<button class="control-btn step" data-delta="-1" aria-label="{{ room.label }}: volume down">
|
<button class="control-btn 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>
|
||||||
@@ -93,7 +92,6 @@
|
|||||||
<svg viewBox="0 0 448 512" aria-hidden="true"><path d="M256 80c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 144L48 224c-17.7 0-32 14.3-32 32s14.3 32 32 32l144 0 0 144c0 17.7 14.3 32 32 32s32-14.3 32-32l0-144 144 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-144 0 0-144z"/></svg>
|
<svg viewBox="0 0 448 512" aria-hidden="true"><path d="M256 80c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 144L48 224c-17.7 0-32 14.3-32 32s14.3 32 32 32l144 0 0 144c0 17.7 14.3 32 32 32s32-14.3 32-32l0-144 144 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-144 0 0-144z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="actions" data-role="actions" hidden>
|
<div class="actions" data-role="actions" hidden>
|
||||||
{% if not avr %}
|
{% if not avr %}
|
||||||
|
|||||||
@@ -240,6 +240,20 @@ class PanelTest(unittest.TestCase):
|
|||||||
self.assertTrue(all(isinstance(r["volume"], int) for r in state["rooms"]))
|
self.assertTrue(all(isinstance(r["volume"], int) for r in state["rooms"]))
|
||||||
self.assertEqual([r["play_state"] for r in state["rooms"]], ["play", "play"])
|
self.assertEqual([r["play_state"] for r in state["rooms"]], ["play", "play"])
|
||||||
|
|
||||||
|
def test_state_carries_the_avrs_own_volume(self):
|
||||||
|
"""The AVR's card has a level like every other, and it is the
|
||||||
|
player's own -- not the group's, even while it leads one."""
|
||||||
|
self.heos.volumes[AVR_PID] = 37
|
||||||
|
self.panel.join("lego_room")
|
||||||
|
self.assertEqual(self.panel.state()["avr"]["volume"], 37)
|
||||||
|
|
||||||
|
def test_the_avrs_volume_moves_like_a_rooms(self):
|
||||||
|
self.heos.volumes[AVR_PID] = 23
|
||||||
|
self.assertEqual(self.panel.step_volume("avr", 1), 25)
|
||||||
|
self.assertEqual(self.heos.volumes[AVR_PID], 25)
|
||||||
|
self.assertEqual(self.panel.set_volume("avr", 40), 40)
|
||||||
|
self.assertEqual(self.panel.volume("avr"), 40)
|
||||||
|
|
||||||
def test_state_reports_trouble_instead_of_blowing_up(self):
|
def test_state_reports_trouble_instead_of_blowing_up(self):
|
||||||
self.panel.heos.host = "127.0.0.1"
|
self.panel.heos.host = "127.0.0.1"
|
||||||
self.panel.heos.port = 1 # nothing is listening there
|
self.panel.heos.port = 1 # nothing is listening there
|
||||||
|
|||||||
Reference in new issue
Block a user