From dc7f09052db9b5306bd38d6775942838458daf5f Mon Sep 17 00:00:00 2001 From: Franzz Date: Tue, 15 Sep 2026 01:03:25 +0200 Subject: [PATCH] play/pause --- README.md | 4 ++++ app.py | 13 +++++++++++++ controller.py | 29 ++++++++++++++++++++++++++++- demo.py | 13 ++++++++++++- static/app.js | 36 ++++++++++++++++++++++++++++++++++-- static/panel.css | 27 +++++++++++++++++++++++++++ templates/index.html | 15 +++++++++++---- tests/fakes.py | 10 ++++++++++ tests/test_panel.py | 21 +++++++++++++++++++++ 9 files changed, 160 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index efa501b..01da64f 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/app.py b/app.py index cf8c9ca..03a674d 100644 --- a/app.py +++ b/app.py @@ -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(): diff --git a/controller.py b/controller.py index 58df416..0147995 100644 --- a/controller.py +++ b/controller.py @@ -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 ] diff --git a/demo.py b/demo.py index c0a00ae..6865a8c 100644 --- a/demo.py +++ b/demo.py @@ -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() diff --git a/static/app.js b/static/app.js index 298f252..167e7fd 100644 --- a/static/app.js +++ b/static/app.js @@ -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', () => { diff --git a/static/panel.css b/static/panel.css index c9380b3..1256cc7 100644 --- a/static/panel.css +++ b/static/panel.css @@ -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 ------------------------------------------------------------- */ diff --git a/templates/index.html b/templates/index.html index 99a4712..a6e75a7 100644 --- a/templates/index.html +++ b/templates/index.html @@ -58,10 +58,17 @@ - +
+ + + +
{% endfor %} diff --git a/tests/fakes.py b/tests/fakes.py index a3f662b..9396e12 100644 --- a/tests/fakes.py +++ b/tests/fakes.py @@ -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) diff --git a/tests/test_panel.py b/tests/test_panel.py index 9c3494f..8757619 100644 --- a/tests/test_panel.py +++ b/tests/test_panel.py @@ -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"