diff --git a/app.py b/app.py index fbf8bcc..9b2457e 100644 --- a/app.py +++ b/app.py @@ -36,6 +36,19 @@ app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1) controller: Controller = None spotify: dict = {} # account key -> SpotifyClient, configured accounts only +# HEOS merges any In-Room Group into one player at the hardware level (see +# 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. +# +# "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]} + def _build_spotify() -> dict: if not (config.SPOTIFY_CLIENT_ID and config.SPOTIFY_CLIENT_SECRET): @@ -137,8 +150,11 @@ def index(): return render_template( "index.html", app_name=config.APP_NAME, - host={"key": config.HOST_KEY, **config.TARGETS[config.HOST_KEY]}, - rooms=[{"key": key, **config.TARGETS[key]} for key in config.ROOM_KEYS], + 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 + ], step=config.VOLUME_STEP, spotify_accounts=[{"key": key, "label": config.SPOTIFY_ACCOUNTS[key]["label"]} for key in spotify], ) diff --git a/config.json.example b/config.json.example index f6e88af..bbbf926 100644 --- a/config.json.example +++ b/config.json.example @@ -16,7 +16,8 @@ "living_room": { "label": "Living Room", "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" } }, "host_key": "avr", diff --git a/config.py b/config.py index 128ffa0..c4d690a 100644 --- a/config.py +++ b/config.py @@ -38,9 +38,17 @@ _load_dotenv(Path(__file__).resolve().parent / ".env") # heos_name : the EXACT name HEOS reports for that player -- including # an In-Room Group like a stereo pair, which HEOS pairs at # the hardware level into one player, one pid, always. -# spotify_name : only needed if a room's Spotify Connect name differs -# from heos_name -- GET /api/spotify/devices?account=account1 shows what -# Spotify actually calls it. Defaults to heos_name. +# spotify_name : only needed if a room's Spotify Connect name differs +# from heos_name -- GET /api/spotify/devices?account=account1 shows what +# Spotify actually calls it. Defaults to heos_name. +# in_room_group : what heos_name actually names, when it is not a single +# speaker -- one of IN_ROOM_GROUPS below. HEOS merges any +# of these into one player, one pid, at the hardware +# level, the same way it does a Stereo Pair -- so, same +# as heos_name itself, this can only be set by hand, not +# read back from HEOS. Defaults to "none". +IN_ROOM_GROUPS = ("none", "stereo-pair", "lcr-fronts", "surround-sound-system", "subwoofer") + _config_path = Path(__file__).resolve().parent / "config.json" try: _cfg = json.loads(_config_path.read_text()) @@ -52,6 +60,14 @@ except FileNotFoundError: TARGETS = _cfg["targets"] +for _key, _target in TARGETS.items(): + _group = _target.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)}" + ) + # 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"] diff --git a/demo.py b/demo.py index 21ac4c0..6bd75cb 100644 --- a/demo.py +++ b/demo.py @@ -10,18 +10,21 @@ from controller import stepped_level class _FakeAvr: + # Codes already in HEOS's own "inputs/" shape (see GET + # /api/avr/inputs against the real AVR), not the demo's own invention -- + # the panel's input icon is keyed on this exact format. INPUTS = [ - {"code": "MPLAY", "name": "Apple TV"}, - {"code": "GAME", "name": "PlayStation"}, - {"code": "SAT/CBL", "name": "TV Box"}, - {"code": "BD", "name": "Blu-ray"}, - {"code": "TUNER", "name": "Radio"}, - {"code": "PHONO", "name": "Turntable"}, + {"code": "inputs/mediaplayer", "name": "Apple TV"}, + {"code": "inputs/game", "name": "PlayStation"}, + {"code": "inputs/tvaudio", "name": "TV Box"}, + {"code": "inputs/bluray", "name": "Blu-ray"}, + {"code": "inputs/tuner", "name": "Radio"}, + {"code": "inputs/phono", "name": "Turntable"}, ] def __init__(self): self.connected = True - self._code = "MPLAY" + self._code = "inputs/mediaplayer" def inputs(self, refresh=False): return list(self.INPUTS) @@ -71,7 +74,7 @@ class DemoController: # 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": None, "image": None} - if self.avr.current_input()["code"] == "MPLAY" else None, + if self.avr.current_input()["code"] == self.cfg.ZIDOO_INPUT_CODE else None, }, "heos_ok": True, "errors": [], @@ -139,7 +142,7 @@ class DemoController: return None def heos_inputs(self, key): - return [{"name": s["name"], "input_id": f"inputs/{s['code'].lower()}"} for s in self.avr.inputs()] + return [{"name": s["name"], "input_id": s["code"]} for s in self.avr.inputs()] def play_heos_input(self, key, input_id, source_key=None): return None diff --git a/static/app.js b/static/app.js index a598bc4..acfe03d 100644 --- a/static/app.js +++ b/static/app.js @@ -11,6 +11,44 @@ const POLL_MS = 5000; const el = (sel, root = document) => root.querySelector(sel); const els = (sel, root = document) => Array.from(root.querySelectorAll(sel)); +const SPEAKER = { viewbox: '0 0 384 512', path: 'M0 64C0 28.7 28.7 0 64 0L320 0c35.3 0 64 28.7 64 64l0 384c0 35.3-28.7 64-64 64L64 512c-35.3 0-64-28.7-64-64L0 64zM304 336a112 112 0 1 0 -224 0 112 112 0 1 0 224 0zM192 272a64 64 0 1 1 0 128 64 64 0 1 1 0-128zm0-112a48 48 0 1 0 0-96 48 48 0 1 0 0 96z' }; +const SPEAKERS = { viewbox: '0 -32 448 576', path: 'M160-32C124.7-32 96-3.3 96 32l0 352c0 35.3 28.7 64 64 64l224 0c35.3 0 64-28.7 64-64l0-352c0-35.3-28.7-64-64-64L160-32zM272 184a104 104 0 1 1 0 208 104 104 0 1 1 0-208zm56 104a56 56 0 1 0 -112 0 56 56 0 1 0 112 0zM240 64a32 32 0 1 1 64 0 32 32 0 1 1 -64 0zM48 88c0-13.3-10.7-24-24-24S0 74.7 0 88L0 480c0 35.3 28.7 64 64 64l264 0c13.3 0 24-10.7 24-24s-10.7-24-24-24L64 496c-8.8 0-16-7.2-16-16L48 88z' }; +const TV = { viewbox: '0 0 576 512', path: 'M64 96l0 240 448 0 0-240-448 0zM0 96C0 60.7 28.7 32 64 32l448 0c35.3 0 64 28.7 64 64l0 240c0 35.3-28.7 64-64 64L64 400c-35.3 0-64-28.7-64-64L0 96zM160 448l256 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-256 0c-17.7 0-32-14.3-32-32s14.3-32 32-32z' }; + +// Keyed on a room's kind (config.IN_ROOM_GROUPS, or "avr") or an input's +// HEOS id, straight from data-icon or GET /api/avr/inputs' "code" -- not +// something a room name or a renamed input label could change. "plug" is +// the fallback for an input HEOS reports that none of these name. +const ICONS = { + avr: { viewbox: '0 0 448 512', path: 'M64 32C28.7 32 0 60.7 0 96l0 64c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-64c0-35.3-28.7-64-64-64L64 32zm216 72a24 24 0 1 1 0 48 24 24 0 1 1 0-48zm56 24a24 24 0 1 1 48 0 24 24 0 1 1 -48 0zM64 288c-35.3 0-64 28.7-64 64l0 64c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-64c0-35.3-28.7-64-64-64L64 288zm216 72a24 24 0 1 1 0 48 24 24 0 1 1 0-48zm56 24a24 24 0 1 1 48 0 24 24 0 1 1 -48 0z' }, + 'none': SPEAKER, + 'stereo-pair': SPEAKERS, + 'lcr-fronts': SPEAKERS, + 'surround-sound-system': SPEAKERS, + 'subwoofer': SPEAKER, + mediaplayer: { viewbox: '0 0 640 544', path: 'M192 64c0-35.3 28.7-64 64-64L544 0c35.3 0 64 28.7 64 64l0 120.4c-6.8-.8-13.8-.6-20.9 .6l-64.1 11.7-54.5-89.2C464.1 100.4 456.4 96 448 96s-16.1 4.4-20.5 11.5l-54 88.3-17.9-25.6c-4.5-6.4-11.8-10.2-19.7-10.2s-15.2 3.8-19.7 10.2l-56 80c-5.1 7.3-5.8 16.9-1.6 24.8S271.1 288 280 288l72 0 0 64-96 0c-35.3 0-64-28.7-64-64l0-224zM352 403.8c-15.3 3.8-29.7 10.5-42.1 19.7-8.7 6.5-16.8 14.8-23.3 24.5L64 448c-35.3 0-64-28.7-64-64L0 160c0-35.3 28.7-64 64-64l80 0 0 192c0 61.9 50.1 112 112 112l96 0 0 3.8zM320 96a32 32 0 1 0 -64 0 32 32 0 1 0 64 0zM52 164l0 24c0 8.8 7.2 16 16 16l24 0c8.8 0 16-7.2 16-16l0-24c0-8.8-7.2-16-16-16l-24 0c-8.8 0-16 7.2-16 16zm16 80c-8.8 0-16 7.2-16 16l0 24c0 8.8 7.2 16 16 16l24 0c8.8 0 16-7.2 16-16l0-24c0-8.8-7.2-16-16-16l-24 0zm0 96c-8.8 0-16 7.2-16 16l0 24c0 8.8 7.2 16 16 16l24 0c8.8 0 16-7.2 16-16l0-24c0-8.8-7.2-16-16-16l-24 0zm556-84.1l0 208c0 23.3-18 35.4-29.1 40.5-11.9 5.4-25.4 7.5-34.9 7.5s-22.9-2.1-34.9-7.5c-11.1-5-29.1-17.2-29.1-40.5s18-35.4 29.1-40.5c11.9-5.4 25.4-7.5 34.9-7.5 4.6 0 10.1 .5 16 1.6l0-84.9-128 23.3 0 139.9c0 23.3-18 35.4-29.1 40.5-11.9 5.4-25.4 7.5-34.9 7.5s-22.9-2.1-34.9-7.5c-11.1-5-29.1-17.2-29.1-40.5s18-35.4 29.1-40.5c11.9-5.4 25.4-7.5 34.9-7.5 4.6 0 10.1 .5 16 1.6l0-161.6c0-11.6 8.3-21.5 19.7-23.6l176-32c7-1.3 14.2 .6 19.7 5.2s8.6 11.3 8.6 18.4z' }, + game: { viewbox: '0 0 640 512', path: 'M448 64c106 0 192 86 192 192S554 448 448 448l-256 0C86 448 0 362 0 256S86 64 192 64l256 0zM192 176c-13.3 0-24 10.7-24 24l0 32-32 0c-13.3 0-24 10.7-24 24s10.7 24 24 24l32 0 0 32c0 13.3 10.7 24 24 24s24-10.7 24-24l0-32 32 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-32 0 0-32c0-13.3-10.7-24-24-24zm240 96a32 32 0 1 0 0 64 32 32 0 1 0 0-64zm64-96a32 32 0 1 0 0 64 32 32 0 1 0 0-64z' }, + tv: TV, + tvaudio: TV, + plug: { viewbox: '0 -32 448 544', path: 'M128-32c17.7 0 32 14.3 32 32l0 96 128 0 0-96c0-17.7 14.3-32 32-32s32 14.3 32 32l0 96 64 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l0 64c0 95.1-69.2 174.1-160 189.3l0 66.7c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-66.7C101.2 398.1 32 319.1 32 224l0-64c-17.7 0-32-14.3-32-32S14.3 96 32 96l64 0 0-96c0-17.7 14.3-32 32-32z' }, +}; + +function inputIcon(code) { + return ICONS[(code || '').replace('inputs/', '')] || ICONS.plug; +} + +/* Repaints an inline in place -- cheaper than replacing + the node, and there is no library here to lose track of the change. */ +function paintIcon(svg, icon) { + svg.setAttribute('viewBox', icon.viewbox); + svg.firstElementChild.setAttribute('d', icon.path); +} + +// Room and AVR cards carry their icon as a fixed kind (data-icon="avr", set +// server-side from config -- see index.html) rather than the AVR input's +// live one, so these paint once, not on every poll. +els('[data-icon]').forEach((svg) => paintIcon(svg, ICONS[svg.dataset.icon])); + const ui = { foot: el('[data-role="foot"]'), toast: el('[data-role="toast"]'), @@ -19,6 +57,7 @@ const ui = { avrStatus: el('[data-role="avr-status"]'), inputButton: el('[data-role="input-button"]'), inputName: el('[data-role="input-name"]'), + inputIcon: el('[data-role="input-icon"]'), sheet: el('[data-role="sheet"]'), options: el('[data-role="options"]'), joined: el('[data-role="joined"]'), @@ -445,6 +484,10 @@ el('[data-role="sheet-close"]').addEventListener('click', closeSheet); function sheetOpen() { return !ui.sheet.hidden; } +function setInputIcon(code) { + paintIcon(ui.inputIcon, inputIcon(code)); +} + function openSheet() { if (!inputs.length) { toast('No inputs reported by the AVR yet'); @@ -455,9 +498,10 @@ function openSheet() { const option = document.createElement('button'); option.className = 'option'; option.setAttribute('aria-current', String(currentInput && currentInput.code === source.code)); - option.innerHTML = ''; - option.firstChild.textContent = source.name; - option.lastChild.textContent = source.code; + option.innerHTML = ''; + paintIcon(option.querySelector('svg'), inputIcon(source.code)); + option.querySelector('.option-label span').textContent = source.name; + option.querySelector('.code').textContent = source.code; option.addEventListener('click', () => chooseInput(source)); ui.options.appendChild(option); }); @@ -470,10 +514,12 @@ async function chooseInput(source) { closeSheet(); currentInput = source; ui.inputName.textContent = source.name; + setInputIcon(source.code); try { const data = await api('/api/avr/input', { code: source.code }); currentInput = data; ui.inputName.textContent = data.name; + setInputIcon(data.code); } catch (error) { toast(error.message); refresh(); @@ -503,6 +549,7 @@ function render(state) { inputs = avr.inputs || []; currentInput = avr.input || null; ui.inputName.textContent = currentInput ? currentInput.name : '—'; + setInputIcon(currentInput ? currentInput.code : null); ui.avrStatus.textContent = avr.connected ? 'ready' : 'offline'; ui.avrStatus.classList.toggle('on', Boolean(avr.connected)); renderTrack(avr.now_playing || null, avrTrack); diff --git a/static/panel.css b/static/panel.css index 399ef44..bdabfd5 100644 --- a/static/panel.css +++ b/static/panel.css @@ -6,7 +6,7 @@ --bg: #0a0d14; --card: #151a25; --edge: #232b3b; - --raised: #1e2634; + --raised: #29334a; --ink: #eef2f9; --muted: #8d98ad; --accent: #5b8def; @@ -93,10 +93,12 @@ svg { width: 22px; height: 22px; fill: currentColor; } .card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; } .card-head h2 { + display: flex; align-items: center; gap: 10px; margin: 0; min-width: 0; font-size: 17px; font-weight: 600; - overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.card-head h2 .room-icon { flex: 0 0 auto; height: 20px; width: auto; fill: currentColor; color: var(--muted); } +.card-head h2 .room-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .pill { font-size: 11px; text-transform: uppercase; letter-spacing: .08em; @@ -213,7 +215,7 @@ svg { width: 22px; height: 22px; fill: currentColor; } /* --- join / leave the AVR, beside the room's name ---------------------- */ .toggle { flex: 0 0 auto; - height: 40px; padding: 0 14px; border-radius: 999px; + padding: 7px 14px; border-radius: 999px; background: var(--raised); display: flex; align-items: center; justify-content: center; gap: 8px; font-size: 14px; font-weight: 550; white-space: nowrap; @@ -253,6 +255,7 @@ svg { width: 22px; height: 22px; fill: currentColor; } } .source-button:active { transform: scale(.985); } .source-button .eyebrow { font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); } +.source-button [data-role="input-icon"] { flex: 0 0 auto; height: 18px; width: 18px; fill: currentColor; color: var(--muted); } .source-button .value { flex: 1; font-size: 19px; font-weight: 600; } .source-button .chevron { color: var(--muted); } @@ -321,7 +324,11 @@ svg { width: 22px; height: 22px; fill: currentColor; } } .option:active { transform: scale(.985); } .option[aria-current="true"] { background: var(--accent); color: #fff; } -.option .code { font-size: 12px; color: var(--muted); } +.option-label { display: flex; align-items: center; gap: 10px; min-width: 0; overflow: hidden; } +.option-label svg { flex: 0 0 auto; height: 18px; width: 18px; fill: currentColor; color: var(--muted); } +.option-label span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.option[aria-current="true"] .option-label svg { color: rgba(255, 255, 255, .8); } +.option .code { font-size: 12px; color: var(--muted); flex: 0 0 auto; } .option[aria-current="true"] .code { color: rgba(255, 255, 255, .8); } /* --- toast ------------------------------------------------------------ */ diff --git a/templates/index.html b/templates/index.html index 76f3b1e..a0e2d8d 100644 --- a/templates/index.html +++ b/templates/index.html @@ -45,7 +45,7 @@
-

{{ room.label }}

+

{{ room.label }}