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 @@ - +