This commit is contained in:
1 parent
83a26c9476
commit
0c8672789d
11 files changed
+111
-120
No files matched your search
@@ -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.
|
||||
|
||||
`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.
|
||||
|
||||
|
||||
@@ -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
|
||||
# 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
|
||||
# needs no such flag: this app's own design always makes HOST_KEY the AVR
|
||||
# and ROOM_KEYS plain speakers, so it is known before any HEOS call is made.
|
||||
# needs no such flag: this app's own design always makes HOST_ROOM the AVR
|
||||
# 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
|
||||
# config.IN_ROOM_GROUPS, or "avr") -- which icon that draws is the
|
||||
# interface's business, not ours: see ICONS in static/app.js.
|
||||
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:
|
||||
@@ -104,9 +104,9 @@ def _payload() -> dict:
|
||||
def _target_from(data: dict, field: str = "target") -> str:
|
||||
key = data.get(field)
|
||||
if not key:
|
||||
raise ValueError(f"Missing '{field}'. Valid rooms: {', '.join(config.TARGETS)}")
|
||||
if key not in config.TARGETS:
|
||||
raise ValueError(f"Unknown room '{key}'. Valid rooms: {', '.join(config.TARGETS)}")
|
||||
raise ValueError(f"Missing '{field}'. Valid rooms: {', '.join(config.ROOMS)}")
|
||||
if key not in config.ROOMS:
|
||||
raise ValueError(f"Unknown room '{key}'. Valid rooms: {', '.join(config.ROOMS)}")
|
||||
return key
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ def _spotify_from(data: dict) -> SpotifyClient:
|
||||
|
||||
|
||||
def _spotify_name(key: str) -> str:
|
||||
target = config.TARGETS[key]
|
||||
target = config.ROOMS[key]
|
||||
return target.get("spotify_name", target["heos_name"])
|
||||
|
||||
|
||||
@@ -234,7 +234,7 @@ def _release_over_heos(key: str):
|
||||
inputs = controller.heos_inputs(key)
|
||||
if not inputs:
|
||||
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 "
|
||||
"off Spotify.")
|
||||
# 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 -----------------------------------------------------------
|
||||
@app.route("/")
|
||||
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(
|
||||
"index.html",
|
||||
app_name=config.APP_NAME,
|
||||
host=_room_meta(config.HOST_KEY, "avr"),
|
||||
rooms=[
|
||||
_room_meta(key, config.TARGETS[key].get("in_room_group", "none"))
|
||||
for key in config.ROOM_KEYS
|
||||
],
|
||||
version=config.APP_VERSION,
|
||||
host=host,
|
||||
rooms=sorted(rooms.values(), key=lambda room: room["order"]),
|
||||
step=config.VOLUME_STEP,
|
||||
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"):
|
||||
_mark_spotify_accounts(data["rooms"])
|
||||
_link_zidoo_poster(data["avr"])
|
||||
data["version"] = config.APP_VERSION
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
@@ -433,7 +434,7 @@ def api_playback():
|
||||
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")
|
||||
if key == config.HOST_KEY:
|
||||
if key == config.HOST_ROOM:
|
||||
if state == "stop":
|
||||
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")
|
||||
@@ -465,7 +466,7 @@ def api_seek():
|
||||
if data.get("position_ms") is None:
|
||||
raise ValueError("Provide 'position_ms', where to jump to")
|
||||
position = max(0, int(data["position_ms"]))
|
||||
if key == config.HOST_KEY:
|
||||
if key == config.HOST_ROOM:
|
||||
controller.zidoo_seek(position)
|
||||
else:
|
||||
account = _spotify_playing_on().get(_spotify_name(key))
|
||||
@@ -711,7 +712,7 @@ def legacy_avr_input():
|
||||
def main():
|
||||
global controller, spotify
|
||||
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",
|
||||
help="0.0.0.0 so your phone can reach it directly; "
|
||||
"127.0.0.1 to allow only a local reverse proxy")
|
||||
|
||||
+16
-13
@@ -1,27 +1,30 @@
|
||||
{
|
||||
"web_port": 5443,
|
||||
"app_port": 5443,
|
||||
"heos_host": "REPLACE_WITH_HEOS_HOST",
|
||||
"heos_port": 1255,
|
||||
"zidoo_host": "REPLACE_WITH_ZIDOO_HOST",
|
||||
"zidoo_port": 9529,
|
||||
"targets": {
|
||||
"avr": {
|
||||
"label": "Home Cinema",
|
||||
"heos_name": "REPLACE_WITH_EXACT_HEOS_NAME"
|
||||
},
|
||||
"lego_room": {
|
||||
"label": "Lego Room",
|
||||
"heos_name": "REPLACE_WITH_EXACT_HEOS_NAME"
|
||||
},
|
||||
"rooms": {
|
||||
"living_room": {
|
||||
"label": "Living Room",
|
||||
"heos_name": "REPLACE_WITH_EXACT_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",
|
||||
"room_keys": ["living_room", "lego_room"],
|
||||
"host_room": "avr",
|
||||
"guest_rooms": ["living_room", "lego_room"],
|
||||
"volume_step": 5,
|
||||
"spotify_accounts": {
|
||||
"account1": "REPLACE_WITH_ACCOUNT_1_NAME",
|
||||
|
||||
@@ -7,6 +7,7 @@ reports for your own devices.
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -58,29 +59,31 @@ except FileNotFoundError:
|
||||
"and fill in your own rooms (see the README's Configure section)."
|
||||
)
|
||||
|
||||
TARGETS = _cfg["targets"]
|
||||
ROOMS = _cfg["rooms"]
|
||||
|
||||
for _key, _target in TARGETS.items():
|
||||
_group = _target.get("in_room_group", "none")
|
||||
for _index, (_key, _room) in enumerate(ROOMS.items()):
|
||||
_group = _room.get("in_room_group", "none")
|
||||
if _group not in IN_ROOM_GROUPS:
|
||||
raise SystemExit(
|
||||
f"{_config_path}: '{_key}' has in_room_group '{_group}', not one "
|
||||
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
|
||||
# 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.
|
||||
ROOM_KEYS = _cfg["room_keys"]
|
||||
# The rooms that get a card with volume + a join/leave button.
|
||||
GUEST_ROOMS = _cfg["guest_rooms"]
|
||||
|
||||
# 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.
|
||||
VOLUME_STEP = _cfg["volume_step"]
|
||||
|
||||
# Port the panel itself listens on.
|
||||
WEB_PORT = _cfg["web_port"]
|
||||
APP_PORT = _cfg["app_port"]
|
||||
|
||||
# --- Network ----------------------------------------------------------
|
||||
# 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.
|
||||
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) -------------------------------------------------
|
||||
# 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
|
||||
|
||||
+17
-17
@@ -107,7 +107,7 @@ class Controller:
|
||||
"""The AVR is always a plain player. Resolving it by player name
|
||||
matters: once it leads a group, HEOS also reports a *group* under
|
||||
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"])
|
||||
if pid is None:
|
||||
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:
|
||||
"""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}'")
|
||||
if key == self.cfg.HOST_KEY:
|
||||
if key == self.cfg.HOST_ROOM:
|
||||
return [self._host_pid()]
|
||||
|
||||
name = self.cfg.TARGETS[key]["heos_name"]
|
||||
name = self.cfg.ROOMS[key]["heos_name"]
|
||||
pid = self._player_pid(name)
|
||||
if pid is None:
|
||||
raise TargetError(
|
||||
@@ -221,7 +221,7 @@ class Controller:
|
||||
|
||||
def joined_keys(self) -> list:
|
||||
joined, host_pids = [], self._host_group_pids()
|
||||
for key in self.cfg.ROOM_KEYS:
|
||||
for key in self.cfg.GUEST_ROOMS:
|
||||
try:
|
||||
if host_pids & set(self.member_pids(key)):
|
||||
joined.append(key)
|
||||
@@ -256,7 +256,7 @@ class Controller:
|
||||
with self._lock:
|
||||
self.scan()
|
||||
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._nudge_avr_input()
|
||||
return self.joined_keys()
|
||||
@@ -274,7 +274,7 @@ class Controller:
|
||||
try:
|
||||
current = self.avr_current_input()
|
||||
if current:
|
||||
self.play_heos_input(self.cfg.HOST_KEY, current["code"])
|
||||
self.play_heos_input(self.cfg.HOST_ROOM, current["code"])
|
||||
except HeosError:
|
||||
pass
|
||||
|
||||
@@ -292,12 +292,12 @@ class Controller:
|
||||
"""Make the AVR's group contain exactly these rooms and no others."""
|
||||
with self._lock:
|
||||
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()):
|
||||
if key not in wanted:
|
||||
self.ungroup(key)
|
||||
if wanted:
|
||||
self.group_targets(self.cfg.HOST_KEY, wanted)
|
||||
self.group_targets(self.cfg.HOST_ROOM, wanted)
|
||||
self.scan()
|
||||
return self.joined_keys()
|
||||
|
||||
@@ -414,7 +414,7 @@ class Controller:
|
||||
"""{"code", "name"} pairs for the picker -- heos_inputs()'s shape,
|
||||
renamed to match what the UI and /api/avr/* already send and
|
||||
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]
|
||||
|
||||
def avr_current_input(self):
|
||||
@@ -435,7 +435,7 @@ class Controller:
|
||||
return {"code": mid, "name": payload.get("station") or payload.get("song") or mid}
|
||||
|
||||
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)
|
||||
return {"code": code, "name": name}
|
||||
|
||||
@@ -476,7 +476,7 @@ class Controller:
|
||||
matched up as replies come back rather than asked for one at a
|
||||
time."""
|
||||
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": [],
|
||||
"avr": {"connected": False, "input": None, "inputs": [], "volume": None},
|
||||
"heos_ok": True,
|
||||
@@ -491,7 +491,7 @@ class Controller:
|
||||
joined = set(self.joined_keys())
|
||||
|
||||
room_pids, room_errors = {}, {}
|
||||
for key in self.cfg.ROOM_KEYS:
|
||||
for key in self.cfg.GUEST_ROOMS:
|
||||
try:
|
||||
room_pids[key] = self.member_pids(key)[0]
|
||||
except TargetError as exc:
|
||||
@@ -526,10 +526,10 @@ class Controller:
|
||||
else:
|
||||
by_room.setdefault(key, {})[field] = result
|
||||
|
||||
for key in self.cfg.ROOM_KEYS:
|
||||
for key in self.cfg.GUEST_ROOMS:
|
||||
room = {
|
||||
"key": key,
|
||||
"label": self.cfg.TARGETS[key]["label"],
|
||||
"label": self.cfg.ROOMS[key]["label"],
|
||||
"available": True,
|
||||
"grouped": key in joined,
|
||||
"volume": None,
|
||||
@@ -574,10 +574,10 @@ class Controller:
|
||||
snapshot["heos_ok"] = False
|
||||
snapshot["errors"].append(str(exc))
|
||||
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,
|
||||
"now_playing": None, "error": str(exc)}
|
||||
for key in self.cfg.ROOM_KEYS
|
||||
for key in self.cfg.GUEST_ROOMS
|
||||
]
|
||||
|
||||
try:
|
||||
|
||||
@@ -52,11 +52,11 @@ class DemoController:
|
||||
self.cfg = cfg
|
||||
self.avr = _FakeAvr()
|
||||
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._volume = {key: 22 + 7 * i for i, key in enumerate(cfg.ROOMS)}
|
||||
self._play = {key: "play" for key in cfg.ROOMS}
|
||||
self._joined = set()
|
||||
# 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.
|
||||
self._track = {"song": "Harvest Moon", "artist": "Neil Young", "image": None}
|
||||
# Its play button only shows for a stream one of your accounts plays.
|
||||
@@ -67,19 +67,19 @@ class DemoController:
|
||||
# -- what the UI uses ---------------------------------------------
|
||||
def state(self):
|
||||
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": [
|
||||
{"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],
|
||||
"play_state": self._play[key], "spotify": self._spotify[key],
|
||||
"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,
|
||||
"error": None}
|
||||
for key in self.cfg.ROOM_KEYS
|
||||
for key in self.cfg.GUEST_ROOMS
|
||||
],
|
||||
"avr": {
|
||||
"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
|
||||
# AVR card's now-playing block has something to show here too.
|
||||
"now_playing": {"song": "Big Buck Bunny", "artist": "2008", "image": None,
|
||||
@@ -126,7 +126,7 @@ class DemoController:
|
||||
return self.joined_keys()
|
||||
|
||||
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()
|
||||
|
||||
def zidoo_play_state(self, state=None):
|
||||
@@ -141,13 +141,13 @@ class DemoController:
|
||||
self._film_ms = min(596000, int(position_ms))
|
||||
|
||||
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 --------
|
||||
def scan(self):
|
||||
return {
|
||||
"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": [],
|
||||
}
|
||||
|
||||
|
||||
+11
-35
@@ -5,6 +5,9 @@
|
||||
const STEP = Number(document.documentElement.dataset.step) || 2;
|
||||
// Where the app is mounted: "/" on its own port, "/heos/" behind a proxy.
|
||||
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;
|
||||
// How long a dropped cursor waits for the player to report it got there.
|
||||
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
|
||||
// Zidoo acknowledges the key press at once but takes a moment to act on it.
|
||||
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.
|
||||
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.
|
||||
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 = {};
|
||||
let inputs = [];
|
||||
let currentInput = null;
|
||||
@@ -229,7 +226,6 @@ function paintRoom(room) {
|
||||
paintTransport(room);
|
||||
paintSources(room);
|
||||
placeRoom(room);
|
||||
placeCards();
|
||||
}
|
||||
|
||||
/* 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.
|
||||
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() {
|
||||
@@ -453,30 +445,6 @@ function paintGrouping() {
|
||||
const playing = inside.some((node) => node.classList.contains('playing'))
|
||||
|| shownPlayState(rooms[HOST]) === 'play';
|
||||
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. */
|
||||
@@ -830,6 +798,14 @@ function applyRoom(room, incoming) {
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
const avr = state.avr || {};
|
||||
|
||||
+3
-12
@@ -47,13 +47,13 @@
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #0a0d14;
|
||||
--card: #151a25;
|
||||
--card: #202736;
|
||||
--edge: #232b3b;
|
||||
--ink: #eef2f9;
|
||||
--muted: #98a3b8;
|
||||
--outline: #5f6b87;
|
||||
--playing: #1f4a42;
|
||||
--raised: #29334a;
|
||||
--raised: #34405b;
|
||||
--scrim: rgba(4, 6, 11, .6);
|
||||
--shadow: rgba(0, 0, 0, .45);
|
||||
--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 */
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@keyframes rise { from { transform: translateY(14%); opacity: .4; } }
|
||||
@keyframes rise { from { transform: translateY(100%); opacity: .4; } }
|
||||
|
||||
/* Header */
|
||||
|
||||
@@ -148,15 +148,6 @@ header .logo { height: 44px; width: auto; display: block; margin: var(--space-xs
|
||||
|
||||
/* 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 {
|
||||
background: var(--card);
|
||||
border-radius: var(--radius-xl);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!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>
|
||||
<meta charset="utf-8">
|
||||
@@ -130,7 +130,7 @@
|
||||
<img class="logo" src="{{ url_for('static', filename='logo.svg') }}" alt="{{ app_name }}">
|
||||
</header>
|
||||
|
||||
{% for room in rooms + [host] %}
|
||||
{% for room in rooms %}
|
||||
{{ room_card(room, spotify_accounts) }}
|
||||
{% endfor %}
|
||||
|
||||
|
||||
+3
-3
@@ -22,9 +22,9 @@ def build():
|
||||
heos = FakeHeos()
|
||||
cfg = SimpleNamespace(
|
||||
HEOS_HOST="127.0.0.1", HEOS_PORT=heos.port,
|
||||
HOST_KEY="avr",
|
||||
ROOM_KEYS=["lego_room", "living_room"],
|
||||
TARGETS={
|
||||
HOST_ROOM="avr",
|
||||
GUEST_ROOMS=["lego_room", "living_room"],
|
||||
ROOMS={
|
||||
"avr": {"label": "Home Cinema", "heos_name": "Home Cinema"},
|
||||
"lego_room": {"label": "Lego Room", "heos_name": "Lego Room"},
|
||||
"living_room": {"label": "Living Room", "heos_name": "Denon Home 200 L"},
|
||||
|
||||
+2
-2
@@ -169,8 +169,8 @@ class ZidooThroughTheControllerTest(unittest.TestCase):
|
||||
threading.Thread(target=self.server.serve_forever, args=(0.05,), daemon=True).start()
|
||||
self.cfg = SimpleNamespace(
|
||||
HEOS_HOST="127.0.0.1", HEOS_PORT=1255,
|
||||
HOST_KEY="avr", ROOM_KEYS=[],
|
||||
TARGETS={"avr": {"label": "Home Cinema", "heos_name": "Home Cinema"}},
|
||||
HOST_ROOM="avr", GUEST_ROOMS=[],
|
||||
ROOMS={"avr": {"label": "Home Cinema", "heos_name": "Home Cinema"}},
|
||||
VOLUME_STEP=5,
|
||||
ZIDOO_HOST="127.0.0.1", ZIDOO_PORT=self.server.server_address[1],
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user