Set hard-coded rooms order in config file
Deploy HEOS panel / deploy (push) Failing after 49s

This commit is contained in:
franzz committed 2026-09-22 15:37:07 +02:00
1 parent 83a26c9476
commit 0c8672789d
11 files changed
+111 -120

No files matched your search

+3 -3
View File
@@ -91,11 +91,11 @@ sudo nginx -t && sudo systemctl reload nginx
Copy `config.json.example` to `config.json` and fill in the exact `name` HEOS reports for each device. The names come from whatever you typed in the HEOS app, so they rarely match the model names. Copy `config.json.example` to `config.json` and fill in the exact `name` HEOS reports for each device. The names come from whatever you typed in the HEOS app, so they rarely match the model names.
`web_port` is this app listening port. `app_port` is this app listening port.
Open `http://<host-ip>:<web_port>/api/targets`. Open `http://<host-ip>:<app_port>/api/targets`.
`host_key` is the group host into which the room will be grouped into (the AVR), and `room_keys` sets which rooms can be grouped (also set the card order on the app). `host_room` is the group host into which the room will be grouped into (the AVR), and `guest_rooms` sets which rooms can be grouped. Each room in `rooms` (the host included) also needs an `order` -- the card's position on the app, lowest first.
`heos_host` only needs to point at **one** device: HEOS is distributed, so any unit can see and control the whole network. `heos_port`, `zidoo_host` and `zidoo_port` all work the same way. `heos_host` only needs to point at **one** device: HEOS is distributed, so any unit can see and control the whole network. `heos_port`, `zidoo_host` and `zidoo_port` all work the same way.
+17 -16
View File
@@ -44,14 +44,14 @@ spotify: dict = {} # account key -> SpotifyClient, configured accounts only
# controller.py's module docstring), so its API has no field that says how # controller.py's module docstring), so its API has no field that says how
# many units heos_name actually names -- that has to come from config.json's # many units heos_name actually names -- that has to come from config.json's
# "in_room_group" instead (config.IN_ROOM_GROUPS). The AVR/speaker split # "in_room_group" instead (config.IN_ROOM_GROUPS). The AVR/speaker split
# needs no such flag: this app's own design always makes HOST_KEY the AVR # needs no such flag: this app's own design always makes HOST_ROOM the AVR
# and ROOM_KEYS plain speakers, so it is known before any HEOS call is made. # and GUEST_ROOMS plain speakers, so it is known before any HEOS call is made.
# #
# "kind" is passed straight through to the template as-is (one of # "kind" is passed straight through to the template as-is (one of
# config.IN_ROOM_GROUPS, or "avr") -- which icon that draws is the # config.IN_ROOM_GROUPS, or "avr") -- which icon that draws is the
# interface's business, not ours: see ICONS in static/app.js. # interface's business, not ours: see ICONS in static/app.js.
def _room_meta(key: str, kind: str) -> dict: def _room_meta(key: str, kind: str) -> dict:
return {"key": key, "kind": kind, **config.TARGETS[key]} return {"key": key, "kind": kind, **config.ROOMS[key]}
def _build_spotify() -> dict: def _build_spotify() -> dict:
@@ -104,9 +104,9 @@ def _payload() -> dict:
def _target_from(data: dict, field: str = "target") -> str: def _target_from(data: dict, field: str = "target") -> str:
key = data.get(field) key = data.get(field)
if not key: if not key:
raise ValueError(f"Missing '{field}'. Valid rooms: {', '.join(config.TARGETS)}") raise ValueError(f"Missing '{field}'. Valid rooms: {', '.join(config.ROOMS)}")
if key not in config.TARGETS: if key not in config.ROOMS:
raise ValueError(f"Unknown room '{key}'. Valid rooms: {', '.join(config.TARGETS)}") raise ValueError(f"Unknown room '{key}'. Valid rooms: {', '.join(config.ROOMS)}")
return key return key
@@ -122,7 +122,7 @@ def _spotify_from(data: dict) -> SpotifyClient:
def _spotify_name(key: str) -> str: def _spotify_name(key: str) -> str:
target = config.TARGETS[key] target = config.ROOMS[key]
return target.get("spotify_name", target["heos_name"]) return target.get("spotify_name", target["heos_name"])
@@ -234,7 +234,7 @@ def _release_over_heos(key: str):
inputs = controller.heos_inputs(key) inputs = controller.heos_inputs(key)
if not inputs: if not inputs:
raise ValueError( raise ValueError(
f"'{config.TARGETS[key]['label']}' did not answer on the network, and HEOS lists " f"'{config.ROOMS[key]['label']}' did not answer on the network, and HEOS lists "
"no input on it to fall back to -- so there is no way from here to take this room " "no input on it to fall back to -- so there is no way from here to take this room "
"off Spotify.") "off Spotify.")
# An AUX jack with nothing in it is the quietest thing a speaker can be # An AUX jack with nothing in it is the quietest thing a speaker can be
@@ -329,14 +329,14 @@ def _link_zidoo_poster(avr: dict):
# --- The UI ----------------------------------------------------------- # --- The UI -----------------------------------------------------------
@app.route("/") @app.route("/")
def index(): def index():
host = _room_meta(config.HOST_ROOM, "avr")
rooms = {config.HOST_ROOM: host, **{key: _room_meta(key, config.ROOMS[key].get("in_room_group", "none")) for key in config.GUEST_ROOMS}}
return render_template( return render_template(
"index.html", "index.html",
app_name=config.APP_NAME, app_name=config.APP_NAME,
host=_room_meta(config.HOST_KEY, "avr"), version=config.APP_VERSION,
rooms=[ host=host,
_room_meta(key, config.TARGETS[key].get("in_room_group", "none")) rooms=sorted(rooms.values(), key=lambda room: room["order"]),
for key in config.ROOM_KEYS
],
step=config.VOLUME_STEP, step=config.VOLUME_STEP,
spotify_accounts=[{"key": key, "label": config.SPOTIFY_ACCOUNTS[key]["label"]} for key in spotify], spotify_accounts=[{"key": key, "label": config.SPOTIFY_ACCOUNTS[key]["label"]} for key in spotify],
) )
@@ -360,6 +360,7 @@ def api_state():
if not data.get("demo"): if not data.get("demo"):
_mark_spotify_accounts(data["rooms"]) _mark_spotify_accounts(data["rooms"])
_link_zidoo_poster(data["avr"]) _link_zidoo_poster(data["avr"])
data["version"] = config.APP_VERSION
return jsonify(data) return jsonify(data)
@@ -433,7 +434,7 @@ def api_playback():
state = data.get("state") state = data.get("state")
if state is not None and state not in ("play", "pause", "stop"): 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") raise ValueError("'state' must be play, pause or stop -- or left out to toggle")
if key == config.HOST_KEY: if key == config.HOST_ROOM:
if state == "stop": if state == "stop":
raise ValueError("The AVR's card plays a device on one of its inputs, which can only " raise ValueError("The AVR's card plays a device on one of its inputs, which can only "
"play or pause -- 'stop' is for a HEOS room") "play or pause -- 'stop' is for a HEOS room")
@@ -465,7 +466,7 @@ def api_seek():
if data.get("position_ms") is None: if data.get("position_ms") is None:
raise ValueError("Provide 'position_ms', where to jump to") raise ValueError("Provide 'position_ms', where to jump to")
position = max(0, int(data["position_ms"])) position = max(0, int(data["position_ms"]))
if key == config.HOST_KEY: if key == config.HOST_ROOM:
controller.zidoo_seek(position) controller.zidoo_seek(position)
else: else:
account = _spotify_playing_on().get(_spotify_name(key)) account = _spotify_playing_on().get(_spotify_name(key))
@@ -711,7 +712,7 @@ def legacy_avr_input():
def main(): def main():
global controller, spotify global controller, spotify
parser = argparse.ArgumentParser(description="HEOS panel") parser = argparse.ArgumentParser(description="HEOS panel")
parser.add_argument("--port", type=int, default=config.WEB_PORT) parser.add_argument("--port", type=int, default=config.APP_PORT)
parser.add_argument("--host", default="0.0.0.0", parser.add_argument("--host", default="0.0.0.0",
help="0.0.0.0 so your phone can reach it directly; " help="0.0.0.0 so your phone can reach it directly; "
"127.0.0.1 to allow only a local reverse proxy") "127.0.0.1 to allow only a local reverse proxy")
+16 -13
View File
@@ -1,27 +1,30 @@
{ {
"web_port": 5443, "app_port": 5443,
"heos_host": "REPLACE_WITH_HEOS_HOST", "heos_host": "REPLACE_WITH_HEOS_HOST",
"heos_port": 1255, "heos_port": 1255,
"zidoo_host": "REPLACE_WITH_ZIDOO_HOST", "zidoo_host": "REPLACE_WITH_ZIDOO_HOST",
"zidoo_port": 9529, "zidoo_port": 9529,
"targets": { "rooms": {
"avr": {
"label": "Home Cinema",
"heos_name": "REPLACE_WITH_EXACT_HEOS_NAME"
},
"lego_room": {
"label": "Lego Room",
"heos_name": "REPLACE_WITH_EXACT_HEOS_NAME"
},
"living_room": { "living_room": {
"label": "Living Room", "label": "Living Room",
"heos_name": "REPLACE_WITH_EXACT_HEOS_NAME", "heos_name": "REPLACE_WITH_EXACT_HEOS_NAME",
"spotify_name": "REPLACE_ONLY_IF_DIFFERENT_FROM_HEOS_NAME", "spotify_name": "REPLACE_ONLY_IF_DIFFERENT_FROM_HEOS_NAME",
"in_room_group": "stereo-pair" "in_room_group": "stereo-pair",
"order": 1
},
"avr": {
"label": "Home Cinema",
"heos_name": "REPLACE_WITH_EXACT_HEOS_NAME",
"order": 2
},
"lego_room": {
"label": "Lego Room",
"heos_name": "REPLACE_WITH_EXACT_HEOS_NAME",
"order": 3
} }
}, },
"host_key": "avr", "host_room": "avr",
"room_keys": ["living_room", "lego_room"], "guest_rooms": ["living_room", "lego_room"],
"volume_step": 5, "volume_step": 5,
"spotify_accounts": { "spotify_accounts": {
"account1": "REPLACE_WITH_ACCOUNT_1_NAME", "account1": "REPLACE_WITH_ACCOUNT_1_NAME",
+27 -7
View File
@@ -7,6 +7,7 @@ reports for your own devices.
import json import json
import os import os
import subprocess
from pathlib import Path from pathlib import Path
@@ -58,29 +59,31 @@ except FileNotFoundError:
"and fill in your own rooms (see the README's Configure section)." "and fill in your own rooms (see the README's Configure section)."
) )
TARGETS = _cfg["targets"] ROOMS = _cfg["rooms"]
for _key, _target in TARGETS.items(): for _index, (_key, _room) in enumerate(ROOMS.items()):
_group = _target.get("in_room_group", "none") _group = _room.get("in_room_group", "none")
if _group not in IN_ROOM_GROUPS: if _group not in IN_ROOM_GROUPS:
raise SystemExit( raise SystemExit(
f"{_config_path}: '{_key}' has in_room_group '{_group}', not one " f"{_config_path}: '{_key}' has in_room_group '{_group}', not one "
f"of {', '.join(IN_ROOM_GROUPS)}" f"of {', '.join(IN_ROOM_GROUPS)}"
) )
# No 'order' in config.json just means "wherever it falls in rooms".
_room.setdefault("order", _index)
# The AVR is always the group host: its content takes over every room # The AVR is always the group host: its content takes over every room
# that joins, which is the whole point of the merge buttons. # that joins, which is the whole point of the merge buttons.
HOST_KEY = _cfg["host_key"] HOST_ROOM = _cfg["host_room"]
# The rooms that get a card with volume + a join/leave button, in order. # The rooms that get a card with volume + a join/leave button.
ROOM_KEYS = _cfg["room_keys"] GUEST_ROOMS = _cfg["guest_rooms"]
# The grid the volume buttons snap to. A tap moves to the next multiple of # The grid the volume buttons snap to. A tap moves to the next multiple of
# this rather than adding it, so at 5 a level of 23 goes to 25, not 28. # this rather than adding it, so at 5 a level of 23 goes to 25, not 28.
VOLUME_STEP = _cfg["volume_step"] VOLUME_STEP = _cfg["volume_step"]
# Port the panel itself listens on. # Port the panel itself listens on.
WEB_PORT = _cfg["web_port"] APP_PORT = _cfg["app_port"]
# --- Network ---------------------------------------------------------- # --- Network ----------------------------------------------------------
# Any ONE HEOS device's IP is enough: HEOS is a distributed system, so # Any ONE HEOS device's IP is enough: HEOS is a distributed system, so
@@ -104,6 +107,23 @@ ZIDOO_INPUT_CODE = "inputs/mediaplayer" # see GET /api/avr/inputs
# Shown as the app's name on the iOS home screen. # Shown as the app's name on the iOS home screen.
APP_NAME = "Heos" APP_NAME = "Heos"
# The deployed commit, so the UI can tell when the Pi has moved on to a
# newer version and reload itself -- iOS otherwise resumes a suspended
# home-screen session without ever checking back with the server. Empty
# when git isn't available; app.js treats that as "nothing to compare".
def _app_version() -> str:
try:
result = subprocess.run(
["git", "rev-parse", "--short", "HEAD"],
cwd=Path(__file__).resolve().parent,
capture_output=True, text=True, timeout=1,
)
return result.stdout.strip() if result.returncode == 0 else ""
except OSError:
return ""
APP_VERSION = _app_version()
# --- Spotify (optional) ------------------------------------------------- # --- Spotify (optional) -------------------------------------------------
# One Spotify button per account on each speaker's card (not the AVR's), # One Spotify button per account on each speaker's card (not the AVR's),
# resuming that account's playback there -- see the README's Spotify section and # resuming that account's playback there -- see the README's Spotify section and
+17 -17
View File
@@ -107,7 +107,7 @@ class Controller:
"""The AVR is always a plain player. Resolving it by player name """The AVR is always a plain player. Resolving it by player name
matters: once it leads a group, HEOS also reports a *group* under matters: once it leads a group, HEOS also reports a *group* under
the very same name.""" the very same name."""
target = self.cfg.TARGETS[self.cfg.HOST_KEY] target = self.cfg.ROOMS[self.cfg.HOST_ROOM]
pid = self._player_pid(target["heos_name"]) pid = self._player_pid(target["heos_name"])
if pid is None: if pid is None:
raise TargetError(f"No HEOS player named '{target['heos_name']}' (the AVR) was found") raise TargetError(f"No HEOS player named '{target['heos_name']}' (the AVR) was found")
@@ -115,12 +115,12 @@ class Controller:
def member_pids(self, key: str) -> list: def member_pids(self, key: str) -> list:
"""The pid of the one player that is this room, as a list.""" """The pid of the one player that is this room, as a list."""
if key not in self.cfg.TARGETS: if key not in self.cfg.ROOMS:
raise TargetError(f"Unknown room '{key}'") raise TargetError(f"Unknown room '{key}'")
if key == self.cfg.HOST_KEY: if key == self.cfg.HOST_ROOM:
return [self._host_pid()] return [self._host_pid()]
name = self.cfg.TARGETS[key]["heos_name"] name = self.cfg.ROOMS[key]["heos_name"]
pid = self._player_pid(name) pid = self._player_pid(name)
if pid is None: if pid is None:
raise TargetError( raise TargetError(
@@ -221,7 +221,7 @@ class Controller:
def joined_keys(self) -> list: def joined_keys(self) -> list:
joined, host_pids = [], self._host_group_pids() joined, host_pids = [], self._host_group_pids()
for key in self.cfg.ROOM_KEYS: for key in self.cfg.GUEST_ROOMS:
try: try:
if host_pids & set(self.member_pids(key)): if host_pids & set(self.member_pids(key)):
joined.append(key) joined.append(key)
@@ -256,7 +256,7 @@ class Controller:
with self._lock: with self._lock:
self.scan() self.scan()
wanted = set(self.joined_keys()) | {key} wanted = set(self.joined_keys()) | {key}
self.group_targets(self.cfg.HOST_KEY, [k for k in self.cfg.ROOM_KEYS if k in wanted]) self.group_targets(self.cfg.HOST_ROOM, [k for k in self.cfg.GUEST_ROOMS if k in wanted])
self.scan() self.scan()
self._nudge_avr_input() self._nudge_avr_input()
return self.joined_keys() return self.joined_keys()
@@ -274,7 +274,7 @@ class Controller:
try: try:
current = self.avr_current_input() current = self.avr_current_input()
if current: if current:
self.play_heos_input(self.cfg.HOST_KEY, current["code"]) self.play_heos_input(self.cfg.HOST_ROOM, current["code"])
except HeosError: except HeosError:
pass pass
@@ -292,12 +292,12 @@ class Controller:
"""Make the AVR's group contain exactly these rooms and no others.""" """Make the AVR's group contain exactly these rooms and no others."""
with self._lock: with self._lock:
self.scan() self.scan()
wanted = [k for k in self.cfg.ROOM_KEYS if k in joined] wanted = [k for k in self.cfg.GUEST_ROOMS if k in joined]
for key in list(self.joined_keys()): for key in list(self.joined_keys()):
if key not in wanted: if key not in wanted:
self.ungroup(key) self.ungroup(key)
if wanted: if wanted:
self.group_targets(self.cfg.HOST_KEY, wanted) self.group_targets(self.cfg.HOST_ROOM, wanted)
self.scan() self.scan()
return self.joined_keys() return self.joined_keys()
@@ -414,7 +414,7 @@ class Controller:
"""{"code", "name"} pairs for the picker -- heos_inputs()'s shape, """{"code", "name"} pairs for the picker -- heos_inputs()'s shape,
renamed to match what the UI and /api/avr/* already send and renamed to match what the UI and /api/avr/* already send and
expect.""" expect."""
sources = self.heos_inputs(self.cfg.HOST_KEY) sources = self.heos_inputs(self.cfg.HOST_ROOM)
return [{"code": s["input_id"], "name": s["name"]} for s in sources] return [{"code": s["input_id"], "name": s["name"]} for s in sources]
def avr_current_input(self): def avr_current_input(self):
@@ -435,7 +435,7 @@ class Controller:
return {"code": mid, "name": payload.get("station") or payload.get("song") or mid} return {"code": mid, "name": payload.get("station") or payload.get("song") or mid}
def avr_select_input(self, code: str) -> dict: def avr_select_input(self, code: str) -> dict:
self.play_heos_input(self.cfg.HOST_KEY, code) self.play_heos_input(self.cfg.HOST_ROOM, code)
name = next((s["name"] for s in self.avr_inputs() if s["code"] == code), code) name = next((s["name"] for s in self.avr_inputs() if s["code"] == code), code)
return {"code": code, "name": name} return {"code": code, "name": name}
@@ -476,7 +476,7 @@ class Controller:
matched up as replies come back rather than asked for one at a matched up as replies come back rather than asked for one at a
time.""" time."""
snapshot = { snapshot = {
"host": {"key": self.cfg.HOST_KEY, "label": self.cfg.TARGETS[self.cfg.HOST_KEY]["label"]}, "host": {"key": self.cfg.HOST_ROOM, "label": self.cfg.ROOMS[self.cfg.HOST_ROOM]["label"]},
"rooms": [], "rooms": [],
"avr": {"connected": False, "input": None, "inputs": [], "volume": None}, "avr": {"connected": False, "input": None, "inputs": [], "volume": None},
"heos_ok": True, "heos_ok": True,
@@ -491,7 +491,7 @@ class Controller:
joined = set(self.joined_keys()) joined = set(self.joined_keys())
room_pids, room_errors = {}, {} room_pids, room_errors = {}, {}
for key in self.cfg.ROOM_KEYS: for key in self.cfg.GUEST_ROOMS:
try: try:
room_pids[key] = self.member_pids(key)[0] room_pids[key] = self.member_pids(key)[0]
except TargetError as exc: except TargetError as exc:
@@ -526,10 +526,10 @@ class Controller:
else: else:
by_room.setdefault(key, {})[field] = result by_room.setdefault(key, {})[field] = result
for key in self.cfg.ROOM_KEYS: for key in self.cfg.GUEST_ROOMS:
room = { room = {
"key": key, "key": key,
"label": self.cfg.TARGETS[key]["label"], "label": self.cfg.ROOMS[key]["label"],
"available": True, "available": True,
"grouped": key in joined, "grouped": key in joined,
"volume": None, "volume": None,
@@ -574,10 +574,10 @@ class Controller:
snapshot["heos_ok"] = False snapshot["heos_ok"] = False
snapshot["errors"].append(str(exc)) snapshot["errors"].append(str(exc))
snapshot["rooms"] = [ snapshot["rooms"] = [
{"key": key, "label": self.cfg.TARGETS[key]["label"], "available": False, {"key": key, "label": self.cfg.ROOMS[key]["label"], "available": False,
"grouped": False, "volume": None, "play_state": None, "spotify": False, "grouped": False, "volume": None, "play_state": None, "spotify": False,
"now_playing": None, "error": str(exc)} "now_playing": None, "error": str(exc)}
for key in self.cfg.ROOM_KEYS for key in self.cfg.GUEST_ROOMS
] ]
try: try:
+10 -10
View File
@@ -52,11 +52,11 @@ class DemoController:
self.cfg = cfg self.cfg = cfg
self.avr = _FakeAvr() self.avr = _FakeAvr()
self.heos = None self.heos = None
self._volume = {key: 22 + 7 * i for i, key in enumerate(cfg.TARGETS)} self._volume = {key: 22 + 7 * i for i, key in enumerate(cfg.ROOMS)}
self._play = {key: "play" for key in cfg.TARGETS} self._play = {key: "play" for key in cfg.ROOMS}
self._joined = set() self._joined = set()
# One room on Spotify and one not, so both kinds of card show up. # One room on Spotify and one not, so both kinds of card show up.
self._spotify = {key: i == 0 for i, key in enumerate(cfg.ROOM_KEYS)} self._spotify = {key: i == 0 for i, key in enumerate(cfg.GUEST_ROOMS)}
# No cover, since the demo has no network to fetch one from. # No cover, since the demo has no network to fetch one from.
self._track = {"song": "Harvest Moon", "artist": "Neil Young", "image": None} self._track = {"song": "Harvest Moon", "artist": "Neil Young", "image": None}
# Its play button only shows for a stream one of your accounts plays. # Its play button only shows for a stream one of your accounts plays.
@@ -67,19 +67,19 @@ class DemoController:
# -- what the UI uses --------------------------------------------- # -- what the UI uses ---------------------------------------------
def state(self): def state(self):
return { return {
"host": {"key": self.cfg.HOST_KEY, "label": self.cfg.TARGETS[self.cfg.HOST_KEY]["label"]}, "host": {"key": self.cfg.HOST_ROOM, "label": self.cfg.ROOMS[self.cfg.HOST_ROOM]["label"]},
"rooms": [ "rooms": [
{"key": key, "label": self.cfg.TARGETS[key]["label"], "available": True, {"key": key, "label": self.cfg.ROOMS[key]["label"], "available": True,
"grouped": key in self._joined, "volume": self._volume[key], "grouped": key in self._joined, "volume": self._volume[key],
"play_state": self._play[key], "spotify": self._spotify[key], "play_state": self._play[key], "spotify": self._spotify[key],
"spotify_account": self._account if self._spotify[key] else None, "spotify_account": self._account if self._spotify[key] else None,
"now_playing": self._track if self._spotify[key] and self._play[key] != "stop" else None, "now_playing": self._track if self._spotify[key] and self._play[key] != "stop" else None,
"error": None} "error": None}
for key in self.cfg.ROOM_KEYS for key in self.cfg.GUEST_ROOMS
], ],
"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], "volume": self._volume[self.cfg.HOST_ROOM],
# 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,
@@ -126,7 +126,7 @@ class DemoController:
return self.joined_keys() return self.joined_keys()
def set_membership(self, joined): def set_membership(self, joined):
self._joined = {k for k in self.cfg.ROOM_KEYS if k in joined} self._joined = {k for k in self.cfg.GUEST_ROOMS if k in joined}
return self.joined_keys() return self.joined_keys()
def zidoo_play_state(self, state=None): def zidoo_play_state(self, state=None):
@@ -141,13 +141,13 @@ class DemoController:
self._film_ms = min(596000, int(position_ms)) self._film_ms = min(596000, int(position_ms))
def joined_keys(self): def joined_keys(self):
return [k for k in self.cfg.ROOM_KEYS if k in self._joined] return [k for k in self.cfg.GUEST_ROOMS if k in self._joined]
# -- enough of the rest to keep the legacy routes answering -------- # -- enough of the rest to keep the legacy routes answering --------
def scan(self): def scan(self):
return { return {
"players": [{"name": t["heos_name"], "pid": 1000 + i, "model": "Demo"} "players": [{"name": t["heos_name"], "pid": 1000 + i, "model": "Demo"}
for i, t in enumerate(self.cfg.TARGETS.values())], for i, t in enumerate(self.cfg.ROOMS.values())],
"groups": [], "groups": [],
} }
+11 -35
View File
@@ -5,6 +5,9 @@
const STEP = Number(document.documentElement.dataset.step) || 2; const STEP = Number(document.documentElement.dataset.step) || 2;
// Where the app is mounted: "/" on its own port, "/heos/" behind a proxy. // Where the app is mounted: "/" on its own port, "/heos/" behind a proxy.
const BASE = document.documentElement.dataset.base || '/'; const BASE = document.documentElement.dataset.base || '/';
// The commit this page was rendered from, so a resumed iOS home-screen
// session can tell the Pi has moved on and reload itself.
const VERSION = document.documentElement.dataset.version || '';
const POLL_MS = 5000; const POLL_MS = 5000;
// How long a dropped cursor waits for the player to report it got there. // How long a dropped cursor waits for the player to report it got there.
const SEEK_HOLD_MS = 10000; const SEEK_HOLD_MS = 10000;
@@ -13,8 +16,6 @@ const SEEK_KEY_MS = 10000;
// And how long a pressed play or pause on the AVR waits for the same. The // And how long a pressed play or pause on the AVR waits for the same. The
// Zidoo acknowledges the key press at once but takes a moment to act on it. // Zidoo acknowledges the key press at once but takes a moment to act on it.
const PLAY_HOLD_MS = 6000; const PLAY_HOLD_MS = 6000;
// Motion turned down in the OS means cards change places without sliding.
const CALM = matchMedia('(prefers-reduced-motion: reduce)');
// Which way each arrow key moves a slider. // Which way each arrow key moves a slider.
const ARROWS = { ArrowRight: 1, ArrowUp: 1, ArrowLeft: -1, ArrowDown: -1 }; const ARROWS = { ArrowRight: 1, ArrowUp: 1, ArrowLeft: -1, ArrowDown: -1 };
@@ -77,10 +78,6 @@ const ui = {
// theirs do. Which one it is, everything that only it can do goes through. // theirs do. Which one it is, everything that only it can do goes through.
const HOST = ui.avr.dataset.room; const HOST = ui.avr.dataset.room;
// Named, so a view transition can follow the card when it moves to the top
// and back. A room's name is set as it is placed; the host's never moves.
ui.avr.style.viewTransitionName = 'avr';
const rooms = {}; const rooms = {};
let inputs = []; let inputs = [];
let currentInput = null; let currentInput = null;
@@ -229,7 +226,6 @@ function paintRoom(room) {
paintTransport(room); paintTransport(room);
paintSources(room); paintSources(room);
placeRoom(room); placeRoom(room);
placeCards();
} }
/* Song, artist, cover and progress, each only when there is one. The /* Song, artist, cover and progress, each only when there is one. The
@@ -432,10 +428,6 @@ function placeRoom(room) {
// Nothing left in the column below it means it goes last, before the host. // Nothing left in the column below it means it goes last, before the host.
ui.app.insertBefore(room.node, next || ui.avr); ui.app.insertBefore(room.node, next || ui.avr);
} }
// A view transition only follows an element it can name, and the name has
// to be one of a kind on the page (see placeCards). Inside the host's card
// the card travels with it, so it takes no name of its own there.
room.node.style.viewTransitionName = room.grouped ? 'none' : `room-${room.key}`;
} }
function paintGrouping() { function paintGrouping() {
@@ -453,30 +445,6 @@ function paintGrouping() {
const playing = inside.some((node) => node.classList.contains('playing')) const playing = inside.some((node) => node.classList.contains('playing'))
|| shownPlayState(rooms[HOST]) === 'play'; || shownPlayState(rooms[HOST]) === 'play';
ui.avr.classList.toggle('playing', playing); ui.avr.classList.toggle('playing', playing);
placeCards();
}
let placing = false;
function placeCards() {
if (placing) return;
placing = true;
queueMicrotask(() => {
placing = false;
// Grouped, a room's card has left the column and the AVR's is the one
// that moves up. The AVR's tint and its position answer the same
// question, and paintGrouping() has just settled it.
const wanted = Object.values(rooms).map((room) => [
room.node,
// The AVR's tint already answers this: paintGrouping has just settled
// it, over the whole group rather than the host's own stream alone.
room.avr ? room.node.classList.contains('playing') : room.playing() && !room.grouped,
]);
const move = () => wanted.forEach(([node, top]) => node.classList.toggle('on-top', top));
const moves = wanted.some(([node, top]) => node.classList.contains('on-top') !== top);
if (!moves || CALM.matches || !document.startViewTransition) move();
else document.startViewTransition(move);
});
} }
/* Press and hold to keep moving, accelerating as you hold. */ /* Press and hold to keep moving, accelerating as you hold. */
@@ -830,6 +798,14 @@ function applyRoom(room, incoming) {
} }
function render(state) { function render(state) {
// The Pi shipped a newer version while this tab (or a resumed iOS
// home-screen session) was still showing an old one -- catch up rather
// than keep polling a page that no longer matches the server.
if (VERSION && state.version && state.version !== VERSION && !busy()) {
location.reload();
return;
}
(state.rooms || []).forEach((incoming) => applyRoom(rooms[incoming.key], incoming)); (state.rooms || []).forEach((incoming) => applyRoom(rooms[incoming.key], incoming));
const avr = state.avr || {}; const avr = state.avr || {};
+3 -12
View File
@@ -47,13 +47,13 @@
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
--bg: #0a0d14; --bg: #0a0d14;
--card: #151a25; --card: #202736;
--edge: #232b3b; --edge: #232b3b;
--ink: #eef2f9; --ink: #eef2f9;
--muted: #98a3b8; --muted: #98a3b8;
--outline: #5f6b87; --outline: #5f6b87;
--playing: #1f4a42; --playing: #1f4a42;
--raised: #29334a; --raised: #34405b;
--scrim: rgba(4, 6, 11, .6); --scrim: rgba(4, 6, 11, .6);
--shadow: rgba(0, 0, 0, .45); --shadow: rgba(0, 0, 0, .45);
--shade: rgba(255, 255, 255, .05); --shade: rgba(255, 255, 255, .05);
@@ -135,7 +135,7 @@ svg { width: 22px; height: 22px; fill: currentColor; }
.ghost { background: transparent; border: 1px solid var(--outline); color: var(--muted); box-shadow: none; } /* nothing to lift */ .ghost { background: transparent; border: 1px solid var(--outline); color: var(--muted); box-shadow: none; } /* nothing to lift */
@keyframes spin { to { transform: rotate(360deg); } } @keyframes spin { to { transform: rotate(360deg); } }
@keyframes rise { from { transform: translateY(14%); opacity: .4; } } @keyframes rise { from { transform: translateY(100%); opacity: .4; } }
/* Header */ /* Header */
@@ -148,15 +148,6 @@ header .logo { height: 44px; width: auto; display: block; margin: var(--space-xs
/* Card */ /* Card */
/* A playing card goes to the top. order does not transition, so the swap
rides on a view transition instead -- see placeCards() in app.js. */
.app > .on-top { order: -1; }
::view-transition-group(*) {
animation-duration: .4s;
animation-timing-function: cubic-bezier(.22, 1, .36, 1);
}
.card { .card {
background: var(--card); background: var(--card);
border-radius: var(--radius-xl); border-radius: var(--radius-xl);
+2 -2
View File
@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="en" data-step="{{ step }}" data-host="{{ host.label }}" data-base="{{ url_for('index') }}"> <html lang="en" data-step="{{ step }}" data-host="{{ host.label }}" data-base="{{ url_for('index') }}" data-version="{{ version }}">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
@@ -130,7 +130,7 @@
<img class="logo" src="{{ url_for('static', filename='logo.svg') }}" alt="{{ app_name }}"> <img class="logo" src="{{ url_for('static', filename='logo.svg') }}" alt="{{ app_name }}">
</header> </header>
{% for room in rooms + [host] %} {% for room in rooms %}
{{ room_card(room, spotify_accounts) }} {{ room_card(room, spotify_accounts) }}
{% endfor %} {% endfor %}
+3 -3
View File
@@ -22,9 +22,9 @@ def build():
heos = FakeHeos() heos = FakeHeos()
cfg = SimpleNamespace( cfg = SimpleNamespace(
HEOS_HOST="127.0.0.1", HEOS_PORT=heos.port, HEOS_HOST="127.0.0.1", HEOS_PORT=heos.port,
HOST_KEY="avr", HOST_ROOM="avr",
ROOM_KEYS=["lego_room", "living_room"], GUEST_ROOMS=["lego_room", "living_room"],
TARGETS={ ROOMS={
"avr": {"label": "Home Cinema", "heos_name": "Home Cinema"}, "avr": {"label": "Home Cinema", "heos_name": "Home Cinema"},
"lego_room": {"label": "Lego Room", "heos_name": "Lego Room"}, "lego_room": {"label": "Lego Room", "heos_name": "Lego Room"},
"living_room": {"label": "Living Room", "heos_name": "Denon Home 200 L"}, "living_room": {"label": "Living Room", "heos_name": "Denon Home 200 L"},
+2 -2
View File
@@ -169,8 +169,8 @@ class ZidooThroughTheControllerTest(unittest.TestCase):
threading.Thread(target=self.server.serve_forever, args=(0.05,), daemon=True).start() threading.Thread(target=self.server.serve_forever, args=(0.05,), daemon=True).start()
self.cfg = SimpleNamespace( self.cfg = SimpleNamespace(
HEOS_HOST="127.0.0.1", HEOS_PORT=1255, HEOS_HOST="127.0.0.1", HEOS_PORT=1255,
HOST_KEY="avr", ROOM_KEYS=[], HOST_ROOM="avr", GUEST_ROOMS=[],
TARGETS={"avr": {"label": "Home Cinema", "heos_name": "Home Cinema"}}, ROOMS={"avr": {"label": "Home Cinema", "heos_name": "Home Cinema"}},
VOLUME_STEP=5, VOLUME_STEP=5,
ZIDOO_HOST="127.0.0.1", ZIDOO_PORT=self.server.server_address[1], ZIDOO_HOST="127.0.0.1", ZIDOO_PORT=self.server.server_address[1],
) )