play/pause
Deploy HEOS panel / deploy (push) Successful in 24s

This commit is contained in:
2026-09-15 01:03:25 +02:00
parent d2d7677519
commit dc7f09052d
9 changed files with 160 additions and 8 deletions
+4
View File
@@ -9,6 +9,9 @@ Everything it does fits on one screen:
- **Volume** up/down for the Home 400 and the Living Room pair. 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.
- **Play or pause** either room. A room that is grouped shares the AVR's
transport, so pausing it pauses the group — HEOS's doing, not the panel's:
a group has one thing playing, by definition
- **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
@@ -257,6 +260,7 @@ Used by the interface:
| `GET /api/targets` | every player and group HEOS can see |
| `POST /api/volume` | `{"target": "home400", "steps": 1}` — taps, snapped to `VOLUME_STEP`. Also takes `delta` (raw points) or `level` (absolute) |
| `POST /api/mute` | `{"target": "home400"}` |
| `POST /api/playback` | `{"target": "home400", "state": "pause"}`, or no `state` to toggle |
| `POST /api/group` | `{"target": "home400", "joined": true}` |
| `POST /api/group/none` | every room back on its own |
| `GET /api/avr/inputs` | your renamed sources |
+13
View File
@@ -136,6 +136,19 @@ def api_mute():
return jsonify({"target": key, "ok": True})
@app.post("/api/playback")
@handle_errors
def api_playback():
"""Start or stop a room. Send 'state' to be explicit, or leave it out to
flip whatever the speakers are actually doing."""
data = _payload()
key = _target_from(data)
state = data.get("state")
if state is not None and state not in ("play", "pause", "stop"):
raise ValueError("'state' must be play, pause or stop -- or left out to toggle")
return jsonify({"target": key, "state": controller.toggle_play(key, state)})
@app.post("/api/group")
@handle_errors
def api_group():
+28 -1
View File
@@ -346,6 +346,31 @@ class Controller:
self._fresh()
self.heos.command("player/set_play_state", pid=self.playback_pid(key), state=state)
def get_play_state(self, key: str) -> str:
""""play", "pause" or "stop" for whatever this room is doing."""
with self._lock:
self._fresh()
reply = self.heos.command("player/get_play_state", pid=self.playback_pid(key))
return parse_message(reply["heos"]["message"]).get("state", "stop")
def toggle_play(self, key: str, state: str = None) -> str:
"""Start or stop a room. With no state, flips whatever it is doing
now -- read from the speakers rather than trusted from the phone,
whose copy can be a few seconds old.
A room that is grouped shares the AVR's transport, so this stops the
whole group. That is HEOS's doing, not ours: a group has one thing
playing, by definition.
"""
with self._lock:
self._fresh()
if state is None:
state = "pause" if self.get_play_state(key) == "play" else "play"
self.heos.command(
"player/set_play_state", pid=self.playback_pid(key), state=state
)
return state
def skip(self, key: str, direction: str):
with self._lock:
self._fresh()
@@ -388,11 +413,13 @@ class Controller:
"available": True,
"grouped": key in joined,
"volume": None,
"play_state": None,
"error": None,
}
try:
scope, obj_id = self._volume_handles(key)[0]
room["volume"] = self._read_volume(scope, obj_id)
room["play_state"] = self.get_play_state(key)
except (TargetError, HeosError, KeyError) as exc:
room["available"] = False
room["error"] = str(exc)
@@ -402,7 +429,7 @@ class Controller:
snapshot["errors"].append(str(exc))
snapshot["rooms"] = [
{"key": key, "label": self.cfg.TARGETS[key]["label"], "available": False,
"grouped": False, "volume": None, "error": str(exc)}
"grouped": False, "volume": None, "play_state": None, "error": str(exc)}
for key in self.cfg.ROOM_KEYS
]
+12 -1
View File
@@ -49,6 +49,7 @@ class DemoController:
self.avr = _FakeAvr(cfg.AVR_INPUT_CODES)
self.heos = None
self._volume = {key: 22 + 7 * i for i, key in enumerate(cfg.TARGETS)}
self._play = {key: "play" for key in cfg.TARGETS}
self._joined = set()
# -- what the UI uses ---------------------------------------------
@@ -57,7 +58,8 @@ class DemoController:
"host": {"key": self.cfg.HOST_KEY, "label": self.cfg.TARGETS[self.cfg.HOST_KEY]["label"]},
"rooms": [
{"key": key, "label": self.cfg.TARGETS[key]["label"], "available": True,
"grouped": key in self._joined, "volume": self._volume[key], "error": None}
"grouped": key in self._joined, "volume": self._volume[key],
"play_state": self._play[key], "error": None}
for key in self.cfg.ROOM_KEYS
],
"avr": {"connected": True, "inputs": self.avr.inputs(), "input": self.avr.current_input()},
@@ -82,6 +84,15 @@ class DemoController:
def toggle_mute(self, key):
return None
def get_play_state(self, key):
return self._play[key]
def toggle_play(self, key, state=None):
if state is None:
state = "pause" if self._play[key] == "play" else "play"
self._play[key] = state
return state
def join(self, key):
self._joined.add(key)
return self.joined_keys()
+34 -2
View File
@@ -60,13 +60,16 @@ els('.room').forEach((node) => {
bar: el('[data-role="bar"]', node),
toggle: el('[data-role="group"]', node),
toggleLabel: el('[data-role="group-label"]', node),
play: el('[data-role="play"]', node),
steps: els('.step', node),
volume: null,
playState: null,
grouped: false,
available: false,
taps: 0, // button presses not yet sent
inflight: false,
busy: false, // a grouping change is in flight
playBusy: false,
};
rooms[key] = room;
@@ -76,14 +79,21 @@ els('.room').forEach((node) => {
});
room.toggle.addEventListener('click', () => setGrouped(key, !room.grouped));
room.play.addEventListener('click', () => togglePlay(key));
});
function paintRoom(room) {
const known = room.volume !== null && room.volume !== undefined;
room.level.textContent = known ? room.volume : '—';
room.level.textContent = known ? room.volume + '%' : '—';
room.bar.style.width = `${known ? room.volume : 0}%`;
room.node.classList.toggle('offline', !room.available);
room.steps.forEach((button) => { button.disabled = !room.available; });
const playing = room.playState === 'play';
room.play.classList.toggle('playing', playing);
room.play.disabled = !room.available || room.playState === null;
room.play.setAttribute(
'aria-label', `${room.node.querySelector('h2').textContent}: ${playing ? 'pause' : 'play'}`);
room.toggle.disabled = !room.available;
room.toggle.classList.toggle('busy', room.busy);
room.toggle.setAttribute('aria-pressed', String(room.grouped));
@@ -195,6 +205,26 @@ async function setGrouped(key, joined) {
}
}
/* Sends the state it wants rather than "toggle", so a stale idea of what
the room is doing cannot flip it the wrong way. */
async function togglePlay(key) {
const room = rooms[key];
if (!room.available || room.playBusy) return;
const wanted = room.playState === 'play' ? 'pause' : 'play';
room.playBusy = true;
room.playState = wanted;
paintRoom(room);
try {
const data = await api('/api/playback', { target: key, state: wanted });
room.playState = data.state;
} catch (error) {
toast(error.message);
} finally {
room.playBusy = false;
paintRoom(room);
}
}
function applyJoined(joined) {
Object.values(rooms).forEach((room) => {
room.grouped = joined.includes(room.key);
@@ -266,6 +296,7 @@ function render(state) {
room.grouped = incoming.grouped;
// Do not stomp on a volume the user is in the middle of changing.
if (!room.taps && !room.inflight) room.volume = incoming.volume;
if (!room.playBusy) room.playState = incoming.play_state;
paintRoom(room);
});
paintGrouping();
@@ -297,7 +328,8 @@ async function refresh() {
}
function busy() {
return sheetOpen() || Object.values(rooms).some((r) => r.taps || r.inflight || r.busy);
return sheetOpen()
|| Object.values(rooms).some((r) => r.taps || r.inflight || r.busy || r.playBusy);
}
ui.refresh.addEventListener('click', () => {
+27
View File
@@ -118,6 +118,32 @@ svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width:
.meter { flex: 1; height: 8px; border-radius: 999px; background: #0c111b; overflow: hidden; }
.meter i { display: block; height: 100%; width: 0; border-radius: 999px; background: var(--accent); transition: width .12s ease-out; }
/* --- play / pause, beside the join button ------------------------------ */
.actions { display: flex; align-items: stretch; gap: 10px; }
.actions .toggle { flex: 1; }
.transport {
flex: 0 0 auto;
width: 66px; height: 52px;
border-radius: 16px;
background: var(--raised);
display: grid; place-items: center;
}
.transport:active { background: var(--accent); transform: scale(.97); }
.transport:disabled { opacity: .5; }
.transport svg { width: 19px; height: 19px; }
/* A play triangle wants filling; the pause bars are drawn with the stroke
everything else in here uses. */
.transport .icon-play { fill: currentColor; stroke: none; }
.transport .icon-pause { stroke-width: 2.4; }
/* Which icon shows is a class on the button, not `hidden` on the svg:
`hidden` is an HTMLElement property and SVGElement does not inherit it,
so svg.hidden = true sets a JS expando and styles nothing. */
.transport .icon-pause { display: none; }
.transport.playing .icon-play { display: none; }
.transport.playing .icon-pause { display: block; }
/* --- join / leave the AVR -------------------------------------------- */
.toggle {
height: 52px; border-radius: 16px;
@@ -165,6 +191,7 @@ svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width:
background: none; border: 1px solid var(--edge); color: var(--muted);
}
.joined .toggle .dot { display: none; }
.joined .transport { height: 40px; width: 58px; }
.joined .toggle:active { background: var(--raised); color: var(--ink); }
/* --- misc ------------------------------------------------------------- */
+7
View File
@@ -58,10 +58,17 @@
<button class="step" data-delta="1" aria-label="{{ room.label }}: volume up">+</button>
</div>
<div class="actions">
<button class="transport" data-role="play" aria-label="{{ room.label }}: play">
<svg class="icon-play" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5l11 7-11 7z"/></svg>
<svg class="icon-pause" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 5v14M15 5v14"/></svg>
</button>
<button class="toggle" data-role="group" aria-pressed="false">
<span class="dot" aria-hidden="true"></span>
<span data-role="group-label">Separate</span>
</button>
</div>
</section>
</div>
{% endfor %}
+10
View File
@@ -19,6 +19,7 @@ class FakeHeos(threading.Thread):
super().__init__(daemon=True)
self.groups = {3: [3, 4]} # gid -> pids, leader first
self.volumes = {pid: 20 for pid in self.NAMES}
self.play_states = {pid: "play" for pid in self.NAMES}
self.group_volumes = {3: 25}
self.commands = [] # everything we were asked to do
self.server = socket.socket()
@@ -96,6 +97,15 @@ class FakeHeos(threading.Thread):
store[key[1]] = int(args["level"])
return self._ok(path, message=f"{key[0]}={key[1]}&level={args['level']}")
if path == "player/get_play_state":
pid = int(args["pid"])
return self._ok(path, message=f"pid={pid}&state={self.play_states[pid]}")
if path == "player/set_play_state":
pid = int(args["pid"])
self.play_states[pid] = args["state"]
return self._ok(path, message=f"pid={pid}&state={args['state']}")
if path.endswith("/toggle_mute") or path == "system/heart_beat":
return self._ok(path)
+21
View File
@@ -144,6 +144,26 @@ class PanelTest(unittest.TestCase):
self.panel.set_volume("living_room_group", 23)
self.assertEqual(self.panel.step_volume("living_room_group", 3), 35)
# -- play / pause ------------------------------------------------------
def test_toggle_play_flips_what_the_speakers_report(self):
self.panel.scan()
self.assertEqual(self.panel.get_play_state("home400"), "play")
self.assertEqual(self.panel.toggle_play("home400"), "pause")
self.assertEqual(self.panel.get_play_state("home400"), "pause")
self.assertEqual(self.panel.toggle_play("home400"), "play")
def test_toggle_play_takes_an_explicit_state(self):
self.panel.scan()
self.assertEqual(self.panel.toggle_play("home400", "stop"), "stop")
self.assertEqual(self.heos.play_states[HOME400_PID], "stop")
def test_pair_is_controlled_through_one_of_its_speakers(self):
"""Playback is a player command -- a group has none of its own -- so
it goes to the pair's leader."""
self.panel.scan()
self.panel.toggle_play("living_room_group", "pause")
self.assertEqual(self.heos.play_states[3], "pause")
# -- the AVR ---------------------------------------------------------
def test_renamed_inputs_and_selection(self):
deadline = time.time() + 5
@@ -176,6 +196,7 @@ class PanelTest(unittest.TestCase):
self.assertEqual([r["key"] for r in state["rooms"]], ["home400", "living_room_group"])
self.assertEqual([r["grouped"] for r in state["rooms"]], [True, False])
self.assertTrue(all(isinstance(r["volume"], int) for r in state["rooms"]))
self.assertEqual([r["play_state"] for r in state["rooms"]], ["play", "play"])
def test_state_reports_trouble_instead_of_blowing_up(self):
self.panel.heos.host = "127.0.0.1"