@@ -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],
|
||||
)
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -10,18 +10,21 @@ from controller import stepped_level
|
||||
|
||||
|
||||
class _FakeAvr:
|
||||
# Codes already in HEOS's own "inputs/<mid>" 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
|
||||
|
||||
+50
-3
@@ -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 <svg><path/></svg> 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 = '<span></span><span class="code"></span>';
|
||||
option.firstChild.textContent = source.name;
|
||||
option.lastChild.textContent = source.code;
|
||||
option.innerHTML = '<span class="option-label"><svg viewBox="0 0 512 512" aria-hidden="true"><path d=""/></svg><span></span></span><span class="code"></span>';
|
||||
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);
|
||||
|
||||
+11
-4
@@ -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 ------------------------------------------------------------ */
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
<div class="room-slot" data-slot="{{ room.key }}">
|
||||
<section class="card room" data-room="{{ room.key }}">
|
||||
<div class="card-head">
|
||||
<h2>{{ room.label }}</h2>
|
||||
<h2><svg class="room-icon" data-icon="{{ room.kind }}" viewBox="0 0 512 512" aria-hidden="true"><path d=""/></svg><span class="room-label">{{ room.label }}</span></h2>
|
||||
<button class="toggle" data-role="group" aria-pressed="false">
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
<span data-role="group-label">Separate</span>
|
||||
@@ -98,11 +98,12 @@
|
||||
|
||||
<section class="card source">
|
||||
<div class="card-head">
|
||||
<h2>{{ host.label }}</h2>
|
||||
<h2><svg class="room-icon" data-icon="{{ host.kind }}" viewBox="0 0 512 512" aria-hidden="true"><path d=""/></svg><span class="room-label">{{ host.label }}</span></h2>
|
||||
<span class="pill" data-role="avr-status">offline</span>
|
||||
</div>
|
||||
<button class="source-button" data-role="input-button">
|
||||
<span class="eyebrow">Input</span>
|
||||
<svg class="input-icon" data-role="input-icon" viewBox="0 0 512 512" aria-hidden="true"><path d=""/></svg>
|
||||
<span class="value" data-role="input-name">—</span>
|
||||
<svg class="chevron" viewBox="0 0 320 512" aria-hidden="true"><path d="M310.6 233.4c12.5 12.5 12.5 32.8 0 45.3l-192 192c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L242.7 256 73.4 86.6c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l192 192z"/></svg>
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user