Compare commits
34
Commits
d9cadb7e64
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
053be20b9c | ||
|
|
74c232e678 | ||
|
|
4f858be918 | ||
|
|
9d64a8f68f | ||
|
|
49aeb6d5ee | ||
|
|
a537d0dd18 | ||
|
|
ec684b7803 | ||
|
|
51dff45b54 | ||
|
|
532b48ad37 | ||
|
|
69476134f7 | ||
|
|
3d4922fd19 | ||
|
|
4dd7e298a5 | ||
|
|
58a2dedc03 | ||
|
|
5b82a24ac4 | ||
|
|
7c19ff9096 | ||
|
|
6a0f1fa5e8 | ||
|
|
dc7f09052d | ||
|
|
d2d7677519 | ||
|
|
ce8596945e | ||
|
|
9a5f2ac639 | ||
|
|
a7a8ab6f4e | ||
|
|
4cff820070 | ||
|
|
ffb1447209 | ||
|
|
467cae4dd5 | ||
|
|
f6c98abe90 | ||
|
|
6d132f53ff | ||
|
|
6213bcf23f | ||
|
|
aa1b734b3a | ||
|
|
1a1fcc84d6 | ||
|
|
4affd18b3b | ||
|
|
a1fb7a318a | ||
|
|
e3b2804aa0 | ||
|
|
375bcfdd76 | ||
|
|
86fdf9a4d3 |
@@ -0,0 +1 @@
|
|||||||
|
.env
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
SPOTIFY_CLIENT_ID=
|
||||||
|
SPOTIFY_CLIENT_SECRET=
|
||||||
|
SPOTIFY_ACCOUNT1_REFRESH_TOKEN=
|
||||||
|
SPOTIFY_ACCOUNT2_REFRESH_TOKEN=
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# runs-on must match the label the runner was registered with -- `heos`
|
||||||
|
# here, the way Livetrail uses `livetrail`. A job asking for a label
|
||||||
|
# nobody offers sits in the queue rather than failing.
|
||||||
|
#
|
||||||
|
# The runner has to be in host mode on the machine that serves the panel:
|
||||||
|
# it writes into DEPLOY_PATH and restarts the service. It also needs node
|
||||||
|
# 20+ on its PATH, since actions/checkout is a JavaScript action and a
|
||||||
|
# host-mode runner has nothing else to run one with.
|
||||||
|
#
|
||||||
|
# DEPLOY_PATH does not have to be a git checkout -- an empty directory the
|
||||||
|
# runner can write to is enough. See "Deploying from Gitea" in the README
|
||||||
|
# for the service and the one sudoers line the restart needs.
|
||||||
|
|
||||||
|
name: Deploy HEOS panel
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: heos
|
||||||
|
|
||||||
|
env:
|
||||||
|
DEPLOY_PATH: /var/www/html/heos
|
||||||
|
SERVICE: heos-panel
|
||||||
|
PANEL_URL: http://127.0.0.1:5443/ # WEB_PORT in config.py
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Check runner tools
|
||||||
|
run: |
|
||||||
|
echo "running as $(id -un) on $(hostname)"
|
||||||
|
command -v python3
|
||||||
|
command -v rsync
|
||||||
|
command -v curl
|
||||||
|
|
||||||
|
- name: Check deploy path
|
||||||
|
run: |
|
||||||
|
test -d "$DEPLOY_PATH"
|
||||||
|
test -w "$DEPLOY_PATH"
|
||||||
|
|
||||||
|
# `sudo -n -l <cmd>` asks "may I run this?" without prompting, so the
|
||||||
|
# job stops here with the line to add rather than deploying and then
|
||||||
|
# falling over on the restart at the very end.
|
||||||
|
- name: Check the restart is allowed without a password
|
||||||
|
run: |
|
||||||
|
SYSTEMCTL="$(command -v systemctl)"
|
||||||
|
if sudo -n -l "$SYSTEMCTL" restart "$SERVICE" >/dev/null 2>&1; then
|
||||||
|
echo "$(id -un) may restart $SERVICE"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "$(id -un) cannot restart $SERVICE without a password. Once, here:"
|
||||||
|
echo
|
||||||
|
echo " echo '$(id -un) ALL=(ALL) NOPASSWD: $SYSTEMCTL restart $SERVICE' \\"
|
||||||
|
echo " | sudo tee /etc/sudoers.d/$SERVICE"
|
||||||
|
echo " sudo chmod 440 /etc/sudoers.d/$SERVICE"
|
||||||
|
echo
|
||||||
|
echo "The path matters: sudo matches what it resolves from PATH"
|
||||||
|
echo "against the sudoers line, without following symlinks."
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
# A throwaway virtualenv in the workspace -- this is the npm ci of a
|
||||||
|
# Python project. The one under $DEPLOY_PATH/.venv is what the running
|
||||||
|
# panel imports from, and a test run has no business touching it.
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python3 -m venv .venv-ci
|
||||||
|
.venv-ci/bin/pip install --quiet --upgrade pip
|
||||||
|
.venv-ci/bin/pip install --quiet -r requirements.txt
|
||||||
|
|
||||||
|
# Runs against the fake HEOS and AVR servers in tests/fakes.py, so it
|
||||||
|
# needs no speakers and touches nothing on the network. Nothing has
|
||||||
|
# been deployed yet at this point, so a failure here leaves the server
|
||||||
|
# exactly as it was.
|
||||||
|
- name: Run tests
|
||||||
|
run: .venv-ci/bin/python -m unittest discover -s tests -t . --verbose
|
||||||
|
|
||||||
|
# .venv, .env and config.json are excluded, so the
|
||||||
|
# runtime, the stereo pair's learned membership, Spotify's
|
||||||
|
# credentials, and your rooms/ports survive --delete untouched.
|
||||||
|
- name: Deploy to production
|
||||||
|
run: |
|
||||||
|
rsync -azc --no-times --delete \
|
||||||
|
--exclude "/.git/" \
|
||||||
|
--exclude "/.gitea/" \
|
||||||
|
--exclude "/.venv/" \
|
||||||
|
--exclude "/.venv-ci/" \
|
||||||
|
--exclude "/.env" \
|
||||||
|
--exclude "/config.json" \
|
||||||
|
--exclude "__pycache__/" \
|
||||||
|
./ "$DEPLOY_PATH/"
|
||||||
|
|
||||||
|
- name: Install runtime dependencies
|
||||||
|
run: |
|
||||||
|
test -d "$DEPLOY_PATH/.venv" || python3 -m venv "$DEPLOY_PATH/.venv"
|
||||||
|
"$DEPLOY_PATH/.venv/bin/pip" install --quiet -r "$DEPLOY_PATH/requirements.txt"
|
||||||
|
|
||||||
|
# The same absolute path the check above validated, so PATH order
|
||||||
|
# cannot leave sudo matching a different one (/bin vs /usr/bin).
|
||||||
|
- name: Restart
|
||||||
|
run: sudo -n "$(command -v systemctl)" restart "$SERVICE"
|
||||||
|
|
||||||
|
- name: Wait for the panel to answer
|
||||||
|
run: |
|
||||||
|
# The home page renders from config alone, so this proves the app
|
||||||
|
# came back up without waiting on the speakers to reply.
|
||||||
|
for attempt in $(seq 1 20); do
|
||||||
|
if curl -fsS -o /dev/null "$PANEL_URL"; then
|
||||||
|
echo "panel is up after ${attempt}s"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "panel did not come back -- last of its log:"
|
||||||
|
systemctl status "$SERVICE" --no-pager --lines 30 || true
|
||||||
|
exit 1
|
||||||
+10
@@ -136,3 +136,13 @@ dist
|
|||||||
.yarn/install-state.gz
|
.yarn/install-state.gz
|
||||||
.pnp.*
|
.pnp.*
|
||||||
|
|
||||||
|
|
||||||
|
# ---> Python / this project
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
.venv-ci/
|
||||||
|
|
||||||
|
# Your own rooms/speakers/ports -- copy config.json.example to config.json
|
||||||
|
config.json
|
||||||
|
|||||||
@@ -1,3 +1,157 @@
|
|||||||
# heos
|
# Heos app
|
||||||
|
|
||||||
Fix Denon Heos interface
|
_The default HEOS app is so bad I had to make one myself._
|
||||||
|
|
||||||
|
A phone-sized web remote for a multi-room HEOS system, meant to be added to the iOS home screen and used instead of the HEOS app. One Flask process serves both the interface and the HTTP bridge behind it.
|
||||||
|
|
||||||
|
Everything fits on one screen:
|
||||||
|
|
||||||
|
- **Volume** up/down for each room. A tap lands on the next multiple of `VOLUME_STEP` — from 23 it goes to 25, not 28 — so the levels stay round. Hold to keep moving, or drag the level along its bar to set it outright.
|
||||||
|
- **Navigate** either room (play / pause / prev / next), while it is playing Spotify
|
||||||
|
- **Group** either room with the AVR. the AVR is always the host, so its sound takes over whatever joins it. A room that joins moves _into_ the Home Cinema card, so one glance says what is playing together
|
||||||
|
- **Ungroup** either room again, or all of them at once
|
||||||
|
- **Change the AVR's input**, listed under the names you gave them, minus the sources you deleted in the AVR's setup menu
|
||||||
|
- **Resume Spotify** on a room from either account (the reverse of connecting to it from the Spotify app) — labeled with the familiar names you gave them, e.g. Fifou's or Clarita's. Optional, see [Spotify](#configure-spotify-optional) below
|
||||||
|
|
||||||
|
## The kit it assumes
|
||||||
|
|
||||||
|
| Room | Device | How HEOS addresses it |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Living Room | 2× Denon Home 200 as an In-Room Group | a **player** (`pid`) -- HEOS pairs them at the hardware level |
|
||||||
|
| Lego Room | Denon Home 400 | a **player** (`pid`) |
|
||||||
|
| Home Cinema | Denon AVR-X3800H | a **player** |
|
||||||
|
|
||||||
|
See `config.json.example`.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
### CI/CD
|
||||||
|
|
||||||
|
`deploy/heos-panel.service` runs the panel out of its own virtualenv, under `gunicorn` rather than `python3 app.py`'s dev server, and restarts it if it dies.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp deploy/heos-panel.service /etc/systemd/system/
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now heos-panel
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `User=` and the paths in it if you keep the panel somewhere else.
|
||||||
|
|
||||||
|
Make sure the CI/CD's \<user\> can restart the service:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo "<user> ALL=(ALL) NOPASSWD: $(command -v systemctl) restart heos-panel" \
|
||||||
|
| sudo tee /etc/sudoers.d/heos-panel
|
||||||
|
sudo chmod 440 /etc/sudoers.d/heos-panel
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the Gitea CI/CD.
|
||||||
|
|
||||||
|
### Manually
|
||||||
|
|
||||||
|
Clone repo to `/var/www/html/heos` and:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python3 app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Add a reverse proxy (optional)
|
||||||
|
|
||||||
|
It serves the panel at `/heos` and refuses everything else on that host; the file ends with the two-line change that puts it at the root instead.
|
||||||
|
|
||||||
|
Both ship restricted to the local network — this controls the speakers, and it usually hangs off a host with a public certificate. Delete the `RequireAny` block (Apache) or the `allow`/`deny` lines (nginx) to open it up.
|
||||||
|
|
||||||
|
#### Apache
|
||||||
|
|
||||||
|
`deploy/heos.apache.conf` reverse-proxies `/heos` to the panel with Apache:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo a2enmod proxy proxy_http headers
|
||||||
|
sudo cp deploy/heos.apache.conf /etc/apache2/conf-available/heos.conf
|
||||||
|
sudo a2enconf heos
|
||||||
|
sudo apachectl configtest && sudo systemctl reload apache2
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Nginx
|
||||||
|
|
||||||
|
`deploy/heos.nginx.conf` reverse-proxies `/heos` to the panel with Nginx:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp deploy/heos.nginx.conf /etc/nginx/sites-available/heos
|
||||||
|
sudo ln -s /etc/nginx/sites-available/heos /etc/nginx/sites-enabled/heos
|
||||||
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configure
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Open `http://<host-ip>:<web_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).
|
||||||
|
|
||||||
|
`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.
|
||||||
|
|
||||||
|
`volume_step` is the grid the volume buttons snap to: If set to `5`, a tap moves `23` to `25` and `25` to `30`.
|
||||||
|
|
||||||
|
Restart app flask/gunicorn.
|
||||||
|
|
||||||
|
## Configure Spotify (Optional)
|
||||||
|
|
||||||
|
Spotify dropped native, browsable HEOS integration years ago — it is Connect-only on HEOS now, and Connect only works phone → speaker: the Spotify app pushes playback to a room, and there is nothing in HEOS that asks for the reverse. So "Resume Spotify" doesn't go through HEOS at all; it calls Spotify's own Web API to transfer the account's current playback onto the room's Spotify Connect receiver, which the receiver already advertises on the LAN whether or not anything is playing.
|
||||||
|
|
||||||
|
This needs Spotify Premium and a one-time login, since Spotify has no way to grant that without a human approving it once.
|
||||||
|
|
||||||
|
1. Create an app at the [Spotify Developer dashboard](https://developer.spotify.com/dashboard) (any name), and add this Redirect URI in its settings: `http://127.0.0.1:8899/callback`. Spotify allows plain `http` for a `127.0.0.1` redirect specifically, which is why the login below needs no HTTPS setup.
|
||||||
|
2. While the app is in development mode, Spotify only lets accounts you have listed log in to it: add both accounts' email addresses under the app's **User Management**.
|
||||||
|
3. Run the one-time login once per account, from a machine with a browser (your laptop is fine — it doesn't have to be the Pi):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tools/spotify_auth.py --client-id <id> --client-secret <secret> --account 1
|
||||||
|
python3 tools/spotify_auth.py --client-id <id> --client-secret <secret> --account 2
|
||||||
|
```
|
||||||
|
|
||||||
|
Log in as that account each time — use a private window for the second run, or Spotify just approves whichever account the browser is already logged in as. Each run prints `SPOTIFY_CLIENT_ID` and `SPOTIFY_CLIENT_SECRET` (the same both times) plus that account's own `SPOTIFY_ACCOUNT1_REFRESH_TOKEN` or `SPOTIFY_ACCOUNT2_REFRESH_TOKEN`.
|
||||||
|
4. Put those four lines in a `.env` file in this directory
|
||||||
|
5. Restart app flask/gunicorn
|
||||||
|
|
||||||
|
A room's previous, play/pause and next buttons only appear while HEOS reports it is playing (or paused on) Spotify, and one of your `SPOTIFY_ACCOUNTS` is the one playing it — the same match that borders its button. To see what HEOS reports for a room, use `GET /raw/player/get_now_playing_media?pid=<pid>` with a pid from `/api/targets`: Spotify shows up as `"sid": 4`. The song, artist and cover under each room's name come from that same reply (`song`, `artist`, `image_url`), whichever of them HEOS fills in. They stay while paused and go once the room stops. An AVR input shows none, since its "song" is just the input's name.
|
||||||
|
|
||||||
|
## Add it to the iOS home screen
|
||||||
|
|
||||||
|
Open the page in Safari → Share → **Add to Home Screen**. It then launches full-screen with no browser chrome, which is the point of the exercise.
|
||||||
|
|
||||||
|
Safari will only offer that over plain HTTP on the LAN, which is fine here; if you ever put it behind a domain name, give it HTTPS.
|
||||||
|
|
||||||
|
## HTTP API
|
||||||
|
|
||||||
|
Used by the interface:
|
||||||
|
|
||||||
|
| HTTP Call | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| `GET /api/state` | everything the UI draws, in one call |
|
||||||
|
| `GET /api/targets` | every player and group HEOS can see |
|
||||||
|
| `POST /api/volume` | `{"target": "lego_room", "steps": 1}` — taps, snapped to `VOLUME_STEP`. Also takes `delta` (raw points) or `level` (absolute) |
|
||||||
|
| `POST /api/mute` | `{"target": "lego_room"}` |
|
||||||
|
| `POST /api/playback` | `{"target": "lego_room", "state": "pause"}`, or no `state` to toggle |
|
||||||
|
| `POST /api/skip` | `{"target": "lego_room", "direction": "next"}` — `previous` too |
|
||||||
|
| `POST /api/seek` | `{"target": "lego_room", "position_ms": 90000}` — the Zidoo's film for the AVR, a room's Spotify stream otherwise (HEOS itself cannot seek) |
|
||||||
|
| `POST /api/group` | `{"target": "lego_room", "joined": true}` |
|
||||||
|
| `POST /api/group/none` | every room back on its own |
|
||||||
|
| `GET /api/avr/inputs` | your renamed sources, over HEOS |
|
||||||
|
| `POST /api/avr/input` | `{"code": "inputs/aux_in_1"}` |
|
||||||
|
| `GET /api/spotify/devices?account=account1` | every Spotify Connect receiver that account currently sees (needs [Spotify](#configure-spotify-optional) configured) |
|
||||||
|
| `POST /api/spotify/resume` | `{"target": "lego_room", "account": "account1"}` — transfers that account's current playback there and resumes it |
|
||||||
|
|
||||||
|
## Demo/Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 app.py --demo # fake speakers, real interface
|
||||||
|
python3 -m unittest discover -s tests -t . # runs against a fake HEOS network
|
||||||
|
python3 tools/make_icons.py # re-render the icons from static/logo.svg
|
||||||
|
```
|
||||||
|
|||||||
@@ -0,0 +1,527 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""HEOS panel: a phone-sized web remote plus the HTTP bridge it runs on.
|
||||||
|
|
||||||
|
pip3 install -r requirements.txt
|
||||||
|
python3 app.py # dev server, http://<pi-ip>:5443/
|
||||||
|
|
||||||
|
The service instead runs this under gunicorn -- see deploy/heos-panel.service
|
||||||
|
-- which imports `app` without ever calling main(), so the controller below
|
||||||
|
is built at import time rather than from main()'s argparse.
|
||||||
|
|
||||||
|
Everything the UI does goes through /api/*. The flatter, query-string
|
||||||
|
endpoints from the original heos_bridge.py (/volume/up?target=..., and
|
||||||
|
friends) are still here so existing Shortcuts and scripts keep working.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
from flask import Flask, jsonify, render_template, request, url_for
|
||||||
|
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||||
|
|
||||||
|
import config
|
||||||
|
from controller import Controller, TargetError
|
||||||
|
from heos import HeosError
|
||||||
|
from spotify import SpotifyClient, SpotifyError
|
||||||
|
from zidoo import ZidooError
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
# Served straight from port 5443 this changes nothing. Behind a reverse
|
||||||
|
# proxy that mounts us on a sub-path (Apache at /heos, say) it reads the
|
||||||
|
# X-Forwarded-Prefix that proxy sets, so every URL the app generates is
|
||||||
|
# /heos/... instead of /..., and the page works either way.
|
||||||
|
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):
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
key: SpotifyClient(config.SPOTIFY_CLIENT_ID, config.SPOTIFY_CLIENT_SECRET, account["refresh_token"])
|
||||||
|
for key, account in config.SPOTIFY_ACCOUNTS.items()
|
||||||
|
if account.get("refresh_token")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ != "__main__":
|
||||||
|
# Imported by a WSGI server rather than run as a script, so main()'s
|
||||||
|
# argparse never executes -- build the one controller instance here
|
||||||
|
# instead. HEOS_DEMO lets the fake-speakers mode work this way too.
|
||||||
|
if os.environ.get("HEOS_DEMO", "").lower() in ("1", "true", "yes"):
|
||||||
|
from demo import DemoController
|
||||||
|
controller = DemoController(config)
|
||||||
|
else:
|
||||||
|
controller = Controller(config)
|
||||||
|
spotify = _build_spotify()
|
||||||
|
|
||||||
|
|
||||||
|
def handle_errors(view):
|
||||||
|
"""One place to turn our three failure modes into sensible JSON."""
|
||||||
|
@wraps(view)
|
||||||
|
def wrapped(*args, **kwargs):
|
||||||
|
try:
|
||||||
|
return view(*args, **kwargs)
|
||||||
|
except (TargetError, ValueError) as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
except (HeosError, SpotifyError, ZidooError) as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 502
|
||||||
|
return wrapped
|
||||||
|
|
||||||
|
|
||||||
|
@app.after_request
|
||||||
|
def no_store(response):
|
||||||
|
"""It is a LAN remote: never let a phone show a cached volume, and
|
||||||
|
never let iOS pin an old copy of the UI to the home screen."""
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def _payload() -> dict:
|
||||||
|
return request.get_json(silent=True) or request.form.to_dict() or request.args.to_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)}")
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def _spotify_from(data: dict) -> SpotifyClient:
|
||||||
|
if not spotify:
|
||||||
|
raise ValueError("Spotify isn't configured -- see the README's Spotify section")
|
||||||
|
key = data.get("account")
|
||||||
|
if not key:
|
||||||
|
raise ValueError(f"Missing 'account'. Spotify accounts: {', '.join(spotify)}")
|
||||||
|
if key not in spotify:
|
||||||
|
raise ValueError(f"Unknown Spotify account '{key}'. Spotify accounts: {', '.join(spotify)}")
|
||||||
|
return spotify[key]
|
||||||
|
|
||||||
|
|
||||||
|
def _spotify_name(key: str) -> str:
|
||||||
|
target = config.TARGETS[key]
|
||||||
|
return target.get("spotify_name", target["heos_name"])
|
||||||
|
|
||||||
|
|
||||||
|
def _spotify_playing_on() -> dict:
|
||||||
|
"""Device name -> the key of the account playing on it. An account
|
||||||
|
Spotify refuses (a revoked token, say) just plays on nothing, instead of
|
||||||
|
failing whatever asked."""
|
||||||
|
playing_on = {}
|
||||||
|
for account, client in spotify.items():
|
||||||
|
try:
|
||||||
|
player = client.playback()
|
||||||
|
except (SpotifyError, OSError):
|
||||||
|
continue
|
||||||
|
device = (player.get("device") or {}).get("name")
|
||||||
|
# Should two accounts both claim a device, the one actually playing wins.
|
||||||
|
if device and (device not in playing_on or player.get("is_playing")):
|
||||||
|
playing_on[device] = account
|
||||||
|
return playing_on
|
||||||
|
|
||||||
|
|
||||||
|
def _mark_spotify_accounts(rooms: list):
|
||||||
|
"""Tag each room HEOS says is on Spotify with the account playing it, so
|
||||||
|
its card can pick out that account's button. Spotify is only asked when
|
||||||
|
some room is on Spotify at all."""
|
||||||
|
on_spotify = [room for room in rooms if room.get("spotify")]
|
||||||
|
if not (spotify and on_spotify):
|
||||||
|
return
|
||||||
|
playing_on = _spotify_playing_on()
|
||||||
|
for room in on_spotify:
|
||||||
|
room["spotify_account"] = playing_on.get(_spotify_name(room["key"]))
|
||||||
|
|
||||||
|
|
||||||
|
def _link_zidoo_poster(avr: dict):
|
||||||
|
"""Point the AVR's now-playing cover at our own copy of the Zidoo's
|
||||||
|
poster: the panel is served over https, and the Zidoo only speaks plain
|
||||||
|
http, which a phone will not load into an https page."""
|
||||||
|
track = avr.get("now_playing")
|
||||||
|
if not track:
|
||||||
|
return
|
||||||
|
poster_id = track.pop("poster_id", None)
|
||||||
|
if poster_id is not None:
|
||||||
|
track["image"] = url_for("api_zidoo_poster", poster_id=poster_id)
|
||||||
|
|
||||||
|
|
||||||
|
# --- The UI -----------------------------------------------------------
|
||||||
|
@app.route("/")
|
||||||
|
def index():
|
||||||
|
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
|
||||||
|
],
|
||||||
|
step=config.VOLUME_STEP,
|
||||||
|
spotify_accounts=[{"key": key, "label": config.SPOTIFY_ACCOUNTS[key]["label"]} for key in spotify],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/manifest.webmanifest")
|
||||||
|
def manifest():
|
||||||
|
"""Rendered rather than static, so APP_NAME is only written down once."""
|
||||||
|
return app.response_class(
|
||||||
|
render_template("manifest.webmanifest", app_name=config.APP_NAME),
|
||||||
|
mimetype="application/manifest+json",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- API the UI talks to ----------------------------------------------
|
||||||
|
@app.get("/api/state")
|
||||||
|
@handle_errors
|
||||||
|
def api_state():
|
||||||
|
data = controller.state()
|
||||||
|
# Demo rooms are not on anyone's real Spotify, so they bring their own account.
|
||||||
|
if not data.get("demo"):
|
||||||
|
_mark_spotify_accounts(data["rooms"])
|
||||||
|
_link_zidoo_poster(data["avr"])
|
||||||
|
return jsonify(data)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/zidoo/poster/<int:poster_id>")
|
||||||
|
def api_zidoo_poster(poster_id):
|
||||||
|
"""A film's poster, fetched from the Zidoo on the phone's behalf -- see
|
||||||
|
_link_zidoo_poster()."""
|
||||||
|
zidoo = getattr(controller, "zidoo", None)
|
||||||
|
image = zidoo.poster(poster_id) if zidoo else None
|
||||||
|
if image is None:
|
||||||
|
return "", 404
|
||||||
|
body, mimetype = image
|
||||||
|
return app.response_class(body, mimetype=mimetype)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/targets")
|
||||||
|
@handle_errors
|
||||||
|
def api_targets():
|
||||||
|
"""Diagnostic: every player and group HEOS can see, with its exact
|
||||||
|
name -- this is what you copy into config.py."""
|
||||||
|
found = controller.scan()
|
||||||
|
return jsonify({
|
||||||
|
"players": [
|
||||||
|
{"kind": "player", "name": p.get("name"), "pid": p.get("pid"), "model": p.get("model", "")}
|
||||||
|
for p in found["players"]
|
||||||
|
],
|
||||||
|
"groups": [
|
||||||
|
{"kind": "group", "name": g.get("name"), "gid": g.get("gid"),
|
||||||
|
"players": [{"name": m.get("name"), "pid": m.get("pid"), "role": m.get("role")}
|
||||||
|
for m in g.get("players", [])]}
|
||||||
|
for g in found["groups"]
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/volume")
|
||||||
|
@handle_errors
|
||||||
|
def api_volume():
|
||||||
|
data = _payload()
|
||||||
|
key = _target_from(data)
|
||||||
|
if data.get("level") is not None:
|
||||||
|
return jsonify({"target": key, "level": controller.set_volume(key, int(data["level"]))})
|
||||||
|
if data.get("steps") is not None:
|
||||||
|
# Taps, not points: each one lands on the next multiple of VOLUME_STEP.
|
||||||
|
return jsonify({"target": key, "level": controller.step_volume(key, int(data["steps"]))})
|
||||||
|
if data.get("delta") is not None:
|
||||||
|
return jsonify({"target": key, "level": controller.nudge_volume(key, int(data["delta"]))})
|
||||||
|
raise ValueError("Provide one of 'steps', 'delta' or 'level'")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/mute")
|
||||||
|
@handle_errors
|
||||||
|
def api_mute():
|
||||||
|
key = _target_from(_payload())
|
||||||
|
controller.toggle_mute(key)
|
||||||
|
return jsonify({"target": key, "ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/playback")
|
||||||
|
@handle_errors
|
||||||
|
def api_playback():
|
||||||
|
"""Start or stop a room. Send 'state' to be explicit, or leave it out to
|
||||||
|
flip whatever the speakers are actually doing."""
|
||||||
|
data = _payload()
|
||||||
|
key = _target_from(data)
|
||||||
|
state = data.get("state")
|
||||||
|
if state is not None and state not in ("play", "pause", "stop"):
|
||||||
|
raise ValueError("'state' must be play, pause or stop -- or left out to toggle")
|
||||||
|
return jsonify({"target": key, "state": controller.toggle_play(key, state)})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/skip")
|
||||||
|
@handle_errors
|
||||||
|
def api_skip():
|
||||||
|
"""Jump to the next (or previous) track in a room's queue."""
|
||||||
|
data = _payload()
|
||||||
|
key = _target_from(data)
|
||||||
|
direction = data.get("direction", "next")
|
||||||
|
if direction not in ("next", "previous"):
|
||||||
|
raise ValueError("'direction' must be next or previous")
|
||||||
|
controller.skip(key, direction)
|
||||||
|
return jsonify({"target": key, "direction": direction})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/seek")
|
||||||
|
@handle_errors
|
||||||
|
def api_seek():
|
||||||
|
"""Jump to a point in what a card is playing. HEOS itself cannot seek,
|
||||||
|
so this goes around it: to the Zidoo for the AVR's card, and for a room,
|
||||||
|
to Spotify, through whichever of your accounts is playing there."""
|
||||||
|
data = _payload()
|
||||||
|
key = _target_from(data)
|
||||||
|
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:
|
||||||
|
controller.zidoo_seek(position)
|
||||||
|
else:
|
||||||
|
account = _spotify_playing_on().get(_spotify_name(key))
|
||||||
|
if account is None:
|
||||||
|
raise ValueError("Only a Spotify stream one of your accounts is playing can seek -- HEOS itself cannot")
|
||||||
|
spotify[account].seek(position)
|
||||||
|
return jsonify({"target": key, "position_ms": position})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/group")
|
||||||
|
@handle_errors
|
||||||
|
def api_group():
|
||||||
|
"""Join or leave the AVR's group. The AVR is always the host, so its
|
||||||
|
content is what the joined rooms start playing."""
|
||||||
|
data = _payload()
|
||||||
|
key = _target_from(data)
|
||||||
|
joined = data.get("joined")
|
||||||
|
if isinstance(joined, str):
|
||||||
|
joined = joined.lower() in ("1", "true", "yes", "on")
|
||||||
|
if joined is None:
|
||||||
|
raise ValueError("Provide 'joined': true to merge with the AVR, false to split off")
|
||||||
|
return jsonify({"joined": controller.join(key) if joined else controller.leave(key)})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/group/none")
|
||||||
|
@handle_errors
|
||||||
|
def api_group_none():
|
||||||
|
"""Every room back on its own."""
|
||||||
|
return jsonify({"joined": controller.set_membership([])})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/avr/inputs")
|
||||||
|
@handle_errors
|
||||||
|
def api_avr_inputs():
|
||||||
|
return jsonify(controller.avr_inputs())
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/avr/input")
|
||||||
|
@handle_errors
|
||||||
|
def api_avr_set_input():
|
||||||
|
code = _payload().get("code")
|
||||||
|
if not code:
|
||||||
|
raise ValueError("Provide 'code', e.g. inputs/aux_in_1 -- see GET /api/avr/inputs")
|
||||||
|
return jsonify(controller.avr_select_input(code))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/spotify/devices")
|
||||||
|
@handle_errors
|
||||||
|
def api_spotify_devices():
|
||||||
|
"""Diagnostic: every Spotify Connect receiver one account currently sees
|
||||||
|
(?account=account1) -- use this to fill in a target's spotify_name if it
|
||||||
|
differs from heos_name."""
|
||||||
|
return jsonify(_spotify_from(request.args).devices())
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/spotify/resume")
|
||||||
|
@handle_errors
|
||||||
|
def api_spotify_resume():
|
||||||
|
"""Ask a room's own Spotify Connect receiver to resume one account's
|
||||||
|
playback -- the reverse of connecting to it from the Spotify app."""
|
||||||
|
data = _payload()
|
||||||
|
client = _spotify_from(data)
|
||||||
|
key = _target_from(data)
|
||||||
|
device = client.resume(_spotify_name(key))
|
||||||
|
return jsonify({"target": key, "account": data["account"], "device": device.get("name")})
|
||||||
|
|
||||||
|
|
||||||
|
# --- The original bridge's API, unchanged -----------------------------
|
||||||
|
@app.get("/targets")
|
||||||
|
@handle_errors
|
||||||
|
def legacy_targets():
|
||||||
|
found = controller.scan()
|
||||||
|
return jsonify(
|
||||||
|
[{"kind": "player", "name": p.get("name"), "id": p.get("pid"), "model": p.get("model", "")}
|
||||||
|
for p in found["players"]]
|
||||||
|
+ [{"kind": "group", "name": g.get("name"), "id": g.get("gid"),
|
||||||
|
"members": [m.get("name") for m in g.get("players", [])]}
|
||||||
|
for g in found["groups"]]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/volume")
|
||||||
|
@handle_errors
|
||||||
|
def legacy_get_volume():
|
||||||
|
return jsonify({"level": controller.volume(_target_from(request.args))})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/volume/set")
|
||||||
|
@handle_errors
|
||||||
|
def legacy_set_volume():
|
||||||
|
level = request.args.get("level", type=int)
|
||||||
|
if level is None or not 0 <= level <= 100:
|
||||||
|
raise ValueError("provide integer 'level' between 0 and 100")
|
||||||
|
return jsonify({"level": controller.set_volume(_target_from(request.args), level)})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/volume/up")
|
||||||
|
@handle_errors
|
||||||
|
def legacy_volume_up():
|
||||||
|
key = _target_from(request.args)
|
||||||
|
step = request.args.get("step", type=int)
|
||||||
|
# No ?step= means one tap of the panel's own button, snapping to the
|
||||||
|
# next multiple; an explicit ?step= stays a raw number of points.
|
||||||
|
level = controller.step_volume(key, 1) if step is None else controller.nudge_volume(key, step)
|
||||||
|
return jsonify({"level": level})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/volume/down")
|
||||||
|
@handle_errors
|
||||||
|
def legacy_volume_down():
|
||||||
|
key = _target_from(request.args)
|
||||||
|
step = request.args.get("step", type=int)
|
||||||
|
level = controller.step_volume(key, -1) if step is None else controller.nudge_volume(key, -step)
|
||||||
|
return jsonify({"level": level})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/volume/mute")
|
||||||
|
@handle_errors
|
||||||
|
def legacy_mute():
|
||||||
|
controller.toggle_mute(_target_from(request.args))
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/playback/<state>")
|
||||||
|
@handle_errors
|
||||||
|
def legacy_playback(state):
|
||||||
|
key = _target_from(request.args)
|
||||||
|
if state in ("play", "pause", "stop"):
|
||||||
|
controller.play_state(key, state)
|
||||||
|
return jsonify({"state": state})
|
||||||
|
if state in ("next", "previous"):
|
||||||
|
controller.skip(key, state)
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
raise ValueError(f"Unknown playback action '{state}'")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/group/create")
|
||||||
|
@handle_errors
|
||||||
|
def legacy_group_create():
|
||||||
|
host = _target_from(request.args, "host")
|
||||||
|
members = [m.strip() for m in request.args.get("members", "").split(",") if m.strip()]
|
||||||
|
if not members:
|
||||||
|
raise ValueError("provide '?host=<room>&members=<comma-separated rooms>'")
|
||||||
|
for key in members:
|
||||||
|
_target_from({"target": key})
|
||||||
|
controller.group_targets(host, members)
|
||||||
|
return jsonify({"host": host, "members": members})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/group/remove")
|
||||||
|
@handle_errors
|
||||||
|
def legacy_group_remove():
|
||||||
|
controller.ungroup(_target_from(request.args))
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/inputs")
|
||||||
|
@handle_errors
|
||||||
|
def legacy_inputs():
|
||||||
|
return jsonify(controller.heos_inputs(_target_from(request.args)))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/input/set")
|
||||||
|
@handle_errors
|
||||||
|
def legacy_input_set():
|
||||||
|
input_id = request.args.get("input")
|
||||||
|
if not input_id:
|
||||||
|
raise ValueError("provide '?input=' -- see GET /inputs for valid values")
|
||||||
|
controller.play_heos_input(_target_from(request.args), input_id)
|
||||||
|
return jsonify({"input": input_id})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/input/relay")
|
||||||
|
@handle_errors
|
||||||
|
def legacy_input_relay():
|
||||||
|
input_id = request.args.get("input")
|
||||||
|
source = request.args.get("from")
|
||||||
|
if not input_id or not source:
|
||||||
|
raise ValueError("provide '?from=<source room>&input=<input id>'")
|
||||||
|
controller.play_heos_input(_target_from(request.args), input_id, _target_from(request.args, "from"))
|
||||||
|
return jsonify({"input": input_id, "from": source})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/raw/<path:subpath>")
|
||||||
|
@handle_errors
|
||||||
|
def legacy_raw(subpath):
|
||||||
|
"""Diagnostic: forward any heos:// command as-is.
|
||||||
|
e.g. GET /raw/browse/browse?sid=1027"""
|
||||||
|
return jsonify(controller.heos.command(subpath, **request.args.to_dict()))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/avr/inputs")
|
||||||
|
@handle_errors
|
||||||
|
def legacy_avr_inputs():
|
||||||
|
return jsonify(controller.avr_inputs())
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/avr/input", methods=["GET", "POST"])
|
||||||
|
@handle_errors
|
||||||
|
def legacy_avr_input():
|
||||||
|
if request.method == "GET":
|
||||||
|
return jsonify(controller.avr_current_input() or {"error": "AVR not reachable"})
|
||||||
|
code = request.args.get("input")
|
||||||
|
if not code:
|
||||||
|
raise ValueError("provide '?input=<code>', e.g. inputs/aux_in_1 -- see GET /avr/inputs")
|
||||||
|
return jsonify(controller.avr_select_input(code))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
global controller, spotify
|
||||||
|
parser = argparse.ArgumentParser(description="HEOS panel")
|
||||||
|
parser.add_argument("--port", type=int, default=config.WEB_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")
|
||||||
|
parser.add_argument("--demo", action="store_true",
|
||||||
|
help="run with fake speakers, for working on the UI away from the kit")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.demo:
|
||||||
|
from demo import DemoController
|
||||||
|
controller = DemoController(config)
|
||||||
|
else:
|
||||||
|
controller = Controller(config)
|
||||||
|
spotify = _build_spotify()
|
||||||
|
|
||||||
|
app.run(host=args.host, port=args.port, threaded=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"web_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"
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"host_key": "avr",
|
||||||
|
"room_keys": ["living_room", "lego_room"],
|
||||||
|
"volume_step": 5,
|
||||||
|
"spotify_accounts": {
|
||||||
|
"account1": "REPLACE_WITH_ACCOUNT_1_NAME",
|
||||||
|
"account2": "REPLACE_WITH_ACCOUNT_2_NAME"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"""Everything you might want to change lives here.
|
||||||
|
|
||||||
|
Copy the values out of `GET /api/targets` (or the old `/targets`) the
|
||||||
|
first time you run this, so the names below match exactly what HEOS
|
||||||
|
reports for your own devices.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _load_dotenv(path: Path):
|
||||||
|
"""A `source .env` a shell might forget to `export` is a whole class of
|
||||||
|
bug this sidesteps: read the file directly, rather than trusting
|
||||||
|
whatever the calling shell's environment happens to contain. Existing
|
||||||
|
environment variables still win, so a real export (or systemd's
|
||||||
|
EnvironmentFile) overrides the file rather than the other way round."""
|
||||||
|
if not path.exists():
|
||||||
|
return
|
||||||
|
for line in path.read_text().splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, _, value = line.partition("=")
|
||||||
|
os.environ.setdefault(key.strip(), value.strip())
|
||||||
|
|
||||||
|
|
||||||
|
_load_dotenv(Path(__file__).resolve().parent / ".env")
|
||||||
|
|
||||||
|
# --- Rooms, ports & behaviour -------------------------------------------
|
||||||
|
# None of this is sensitive -- it doesn't give away anything about your
|
||||||
|
# LAN or credentials -- but it's still yours, not the app's: buying a
|
||||||
|
# speaker, renaming a room, or changing a port is a config edit, not a
|
||||||
|
# code change. Lives in config.json rather than here or in .env; copy
|
||||||
|
# config.json.example to get started.
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
# 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())
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise SystemExit(
|
||||||
|
f"{_config_path} not found -- copy config.json.example to config.json "
|
||||||
|
"and fill in your own rooms (see the README's Configure section)."
|
||||||
|
)
|
||||||
|
|
||||||
|
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"]
|
||||||
|
|
||||||
|
# The rooms that get a card with volume + a join/leave button, in order.
|
||||||
|
ROOM_KEYS = _cfg["room_keys"]
|
||||||
|
|
||||||
|
# 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"]
|
||||||
|
|
||||||
|
# --- Network ----------------------------------------------------------
|
||||||
|
# Any ONE HEOS device's IP is enough: HEOS is a distributed system, so
|
||||||
|
# whichever unit you connect to can see and control every player and
|
||||||
|
# group on the network. A LAN address isn't a credential, so it lives
|
||||||
|
# here rather than in .env -- just don't publish this file if your LAN
|
||||||
|
# is reachable from outside it.
|
||||||
|
HEOS_HOST = _cfg["heos_host"]
|
||||||
|
HEOS_PORT = _cfg["heos_port"]
|
||||||
|
|
||||||
|
# --- Zidoo (optional) ---------------------------------------------------
|
||||||
|
# A Zidoo media player plugged into one of the AVR's inputs. HEOS only
|
||||||
|
# knows that input is selected, not what the Zidoo is actually showing, so
|
||||||
|
# its "now playing" comes from the Zidoo's own HTTP API instead -- queried
|
||||||
|
# only while ZIDOO_INPUT_CODE is the AVR's selected input. Leave
|
||||||
|
# zidoo_host out of config.json if there is no Zidoo to ask.
|
||||||
|
ZIDOO_HOST = _cfg.get("zidoo_host")
|
||||||
|
ZIDOO_PORT = _cfg["zidoo_port"]
|
||||||
|
ZIDOO_INPUT_CODE = "inputs/mediaplayer" # see GET /api/avr/inputs
|
||||||
|
|
||||||
|
# Shown as the app's name on the iOS home screen.
|
||||||
|
APP_NAME = "Heos"
|
||||||
|
|
||||||
|
# --- 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
|
||||||
|
# tools/spotify_auth.py. Credentials come from .env, never from here, so
|
||||||
|
# they stay out of git. One developer app serves every account; only the
|
||||||
|
# refresh token differs, since that is what each account's login produces.
|
||||||
|
SPOTIFY_CLIENT_ID = os.environ.get("SPOTIFY_CLIENT_ID")
|
||||||
|
SPOTIFY_CLIENT_SECRET = os.environ.get("SPOTIFY_CLIENT_SECRET")
|
||||||
|
|
||||||
|
# key -> button label and that account's refresh token, in button order.
|
||||||
|
# An account without a token gets no button. The label comes from
|
||||||
|
# spotify_accounts in config.json -- e.g. "account1": "Fifou".
|
||||||
|
_spotify_names = _cfg.get("spotify_accounts", {})
|
||||||
|
SPOTIFY_ACCOUNTS = {
|
||||||
|
"account1": {"label": _spotify_names.get("account1", "Account 1"), "refresh_token": os.environ.get("SPOTIFY_ACCOUNT1_REFRESH_TOKEN")},
|
||||||
|
"account2": {"label": _spotify_names.get("account2", "Account 2"), "refresh_token": os.environ.get("SPOTIFY_ACCOUNT2_REFRESH_TOKEN")},
|
||||||
|
}
|
||||||
+574
@@ -0,0 +1,574 @@
|
|||||||
|
"""The actual behaviour of the panel, on top of the HEOS CLI client.
|
||||||
|
|
||||||
|
The interesting part is grouping. Every room here -- including the
|
||||||
|
living room's L/R pair, which HEOS pairs at the hardware level into a
|
||||||
|
single pid -- is addressed by one player pid, merged or not. set_group
|
||||||
|
replaces a group wholesale rather than adding to it, so join() and
|
||||||
|
leave() always have to name every room that should remain in the AVR's
|
||||||
|
group, not just the one being added or removed.
|
||||||
|
|
||||||
|
Everything, including the AVR's own inputs, goes over the one HEOS
|
||||||
|
connection now -- there used to be a second client here for the AVR's
|
||||||
|
Denon Telnet port, only for its renamed input list, but HEOS reports
|
||||||
|
those same renamed names itself (browse/browse on the AVR's own pid),
|
||||||
|
so the Telnet side added a protocol for no remaining benefit.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
from heos import HeosClient, HeosError, parse_message
|
||||||
|
from zidoo import ZidooClient
|
||||||
|
|
||||||
|
|
||||||
|
def stepped_level(current: int, steps: int, size: int) -> int:
|
||||||
|
"""Where the volume lands after `steps` taps of +/-.
|
||||||
|
|
||||||
|
A tap moves to the next multiple of `size` rather than adding `size`,
|
||||||
|
so a level of 23 with a step of 5 goes 23 -> 25 -> 30 on the way up
|
||||||
|
and 23 -> 20 -> 15 on the way down. Levels that are already on a
|
||||||
|
multiple simply move a whole step.
|
||||||
|
"""
|
||||||
|
if steps > 0:
|
||||||
|
landing = (current // size + steps) * size
|
||||||
|
elif steps < 0:
|
||||||
|
aligned = current // size * size
|
||||||
|
first = current - size if aligned == current else aligned
|
||||||
|
landing = first - (-steps - 1) * size
|
||||||
|
else:
|
||||||
|
return current
|
||||||
|
return max(0, min(100, landing))
|
||||||
|
|
||||||
|
|
||||||
|
class TargetError(ValueError):
|
||||||
|
"""We cannot find that room on the network right now."""
|
||||||
|
|
||||||
|
|
||||||
|
class Controller:
|
||||||
|
# HEOS's source id for Spotify, in get_now_playing_media's payload.
|
||||||
|
SPOTIFY_SID = "4"
|
||||||
|
|
||||||
|
# A player's input list only changes when someone renames a jack in
|
||||||
|
# the AVR's own setup menu, so browse/browse -- one of the slower HEOS
|
||||||
|
# calls -- does not need asking again on every poll.
|
||||||
|
INPUTS_TTL = 2 * 60 * 60
|
||||||
|
|
||||||
|
def __init__(self, cfg):
|
||||||
|
self.cfg = cfg
|
||||||
|
self.heos = HeosClient(cfg.HEOS_HOST, cfg.HEOS_PORT)
|
||||||
|
zidoo_host = getattr(cfg, "ZIDOO_HOST", None)
|
||||||
|
self.zidoo = ZidooClient(zidoo_host, getattr(cfg, "ZIDOO_PORT", 9529)) if zidoo_host else None
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self._players = []
|
||||||
|
self._groups = []
|
||||||
|
self._scanned_at = 0.0
|
||||||
|
self._inputs_cache = {} # key -> (inputs, cached_at)
|
||||||
|
threading.Thread(target=self._keep_warm, daemon=True).start()
|
||||||
|
|
||||||
|
def _keep_warm(self):
|
||||||
|
"""HEOS hangs up on idle connections; a heartbeat keeps the first
|
||||||
|
button press of the evening as quick as the second."""
|
||||||
|
while True:
|
||||||
|
time.sleep(240)
|
||||||
|
try:
|
||||||
|
self.heos.heart_beat()
|
||||||
|
except HeosError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# -- network picture -----------------------------------------------
|
||||||
|
def scan(self) -> dict:
|
||||||
|
"""Re-read every player and group, in one round trip."""
|
||||||
|
with self._lock:
|
||||||
|
players_reply, groups_reply = self.heos.command_batch([
|
||||||
|
("player/get_players", {}),
|
||||||
|
("group/get_groups", {}),
|
||||||
|
])
|
||||||
|
if isinstance(players_reply, HeosError):
|
||||||
|
raise players_reply
|
||||||
|
if isinstance(groups_reply, HeosError):
|
||||||
|
raise groups_reply
|
||||||
|
self._players = players_reply.get("payload", [])
|
||||||
|
self._groups = groups_reply.get("payload", [])
|
||||||
|
self._scanned_at = time.monotonic()
|
||||||
|
return {"players": self._players, "groups": self._groups}
|
||||||
|
|
||||||
|
def _fresh(self, max_age: float = 2.0):
|
||||||
|
if time.monotonic() - self._scanned_at > max_age:
|
||||||
|
self.scan()
|
||||||
|
|
||||||
|
def _player_pid(self, name: str):
|
||||||
|
for player in self._players:
|
||||||
|
if player.get("name") == name:
|
||||||
|
return player.get("pid")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# -- resolving rooms to pids ---------------------------------------
|
||||||
|
def _host_pid(self):
|
||||||
|
"""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]
|
||||||
|
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")
|
||||||
|
return pid
|
||||||
|
|
||||||
|
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:
|
||||||
|
raise TargetError(f"Unknown room '{key}'")
|
||||||
|
if key == self.cfg.HOST_KEY:
|
||||||
|
return [self._host_pid()]
|
||||||
|
|
||||||
|
name = self.cfg.TARGETS[key]["heos_name"]
|
||||||
|
pid = self._player_pid(name)
|
||||||
|
if pid is None:
|
||||||
|
raise TargetError(
|
||||||
|
f"No HEOS player named '{name}' was found. "
|
||||||
|
f"Check GET /api/targets for the names HEOS actually reports."
|
||||||
|
)
|
||||||
|
return [pid]
|
||||||
|
|
||||||
|
# -- volume ---------------------------------------------------------
|
||||||
|
def _volume_handles(self, key: str) -> list:
|
||||||
|
"""Where volume for this room lives right now, as (scope, id)."""
|
||||||
|
return [("player", pid) for pid in self.member_pids(key)]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _id_param(scope: str) -> str:
|
||||||
|
return "pid" if scope == "player" else "gid"
|
||||||
|
|
||||||
|
def _read_volume(self, scope, obj_id) -> int:
|
||||||
|
reply = self.heos.command(f"{scope}/get_volume", **{self._id_param(scope): obj_id})
|
||||||
|
return int(parse_message(reply["heos"]["message"]).get("level", -1))
|
||||||
|
|
||||||
|
def volume(self, key: str) -> int:
|
||||||
|
with self._lock:
|
||||||
|
self._fresh()
|
||||||
|
scope, obj_id = self._volume_handles(key)[0]
|
||||||
|
return self._read_volume(scope, obj_id)
|
||||||
|
|
||||||
|
def set_volume(self, key: str, level: int) -> int:
|
||||||
|
level = max(0, min(100, int(level)))
|
||||||
|
with self._lock:
|
||||||
|
self._fresh()
|
||||||
|
for scope, obj_id in self._volume_handles(key):
|
||||||
|
self.heos.command(
|
||||||
|
f"{scope}/set_volume", **{self._id_param(scope): obj_id}, level=level
|
||||||
|
)
|
||||||
|
return level
|
||||||
|
|
||||||
|
def nudge_volume(self, key: str, delta: int) -> int:
|
||||||
|
"""Move volume by delta and report where it landed.
|
||||||
|
|
||||||
|
Absolute rather than HEOS's own volume_up/down, because the UI
|
||||||
|
coalesces a fast burst of taps into one call and volume_up caps
|
||||||
|
its step at 10.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
self._fresh()
|
||||||
|
handles = self._volume_handles(key)
|
||||||
|
current = self._read_volume(*handles[0])
|
||||||
|
level = max(0, min(100, current + int(delta)))
|
||||||
|
for scope, obj_id in handles:
|
||||||
|
self.heos.command(
|
||||||
|
f"{scope}/set_volume", **{self._id_param(scope): obj_id}, level=level
|
||||||
|
)
|
||||||
|
return level
|
||||||
|
|
||||||
|
def step_volume(self, key: str, steps: int) -> int:
|
||||||
|
"""Move by whole taps, landing on multiples of VOLUME_STEP.
|
||||||
|
|
||||||
|
Counted in taps rather than points so the speakers' own level is
|
||||||
|
what gets rounded, even when the phone's copy of it is a few
|
||||||
|
seconds stale.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
self._fresh()
|
||||||
|
handles = self._volume_handles(key)
|
||||||
|
level = stepped_level(
|
||||||
|
self._read_volume(*handles[0]), int(steps), self.cfg.VOLUME_STEP
|
||||||
|
)
|
||||||
|
for scope, obj_id in handles:
|
||||||
|
self.heos.command(
|
||||||
|
f"{scope}/set_volume", **{self._id_param(scope): obj_id}, level=level
|
||||||
|
)
|
||||||
|
return level
|
||||||
|
|
||||||
|
def toggle_mute(self, key: str):
|
||||||
|
with self._lock:
|
||||||
|
self._fresh()
|
||||||
|
for scope, obj_id in self._volume_handles(key):
|
||||||
|
self.heos.command(f"{scope}/toggle_mute", **{self._id_param(scope): obj_id})
|
||||||
|
|
||||||
|
# -- grouping --------------------------------------------------------
|
||||||
|
def _host_group_pids(self) -> set:
|
||||||
|
"""Every pid currently in the AVR's group (empty if it is alone)."""
|
||||||
|
host_pid = self._host_pid()
|
||||||
|
for group in self._groups:
|
||||||
|
pids = {p.get("pid") for p in group.get("players", [])}
|
||||||
|
if host_pid in pids and len(pids) > 1:
|
||||||
|
return pids
|
||||||
|
return set()
|
||||||
|
|
||||||
|
def joined_keys(self) -> list:
|
||||||
|
joined, host_pids = [], self._host_group_pids()
|
||||||
|
for key in self.cfg.ROOM_KEYS:
|
||||||
|
try:
|
||||||
|
if host_pids & set(self.member_pids(key)):
|
||||||
|
joined.append(key)
|
||||||
|
except TargetError:
|
||||||
|
continue
|
||||||
|
return joined
|
||||||
|
|
||||||
|
def group_targets(self, host_key: str, member_keys: list) -> list:
|
||||||
|
"""One set_group call: the host's pid first -- that is what makes it
|
||||||
|
the leader whose content everyone else plays -- then every member
|
||||||
|
pid of every room listed."""
|
||||||
|
with self._lock:
|
||||||
|
self._fresh()
|
||||||
|
pids = list(self.member_pids(host_key))
|
||||||
|
for key in member_keys:
|
||||||
|
pids.extend(self.member_pids(key))
|
||||||
|
self.heos.command("group/set_group", pid=",".join(str(p) for p in pids))
|
||||||
|
return pids
|
||||||
|
|
||||||
|
def ungroup(self, key: str) -> list:
|
||||||
|
"""Stand a room back up on its own."""
|
||||||
|
with self._lock:
|
||||||
|
self._fresh()
|
||||||
|
pids = self.member_pids(key)
|
||||||
|
self.heos.command("group/set_group", pid=",".join(str(p) for p in pids))
|
||||||
|
return pids
|
||||||
|
|
||||||
|
def join(self, key: str) -> list:
|
||||||
|
"""Add a room to the AVR's group. set_group replaces a group
|
||||||
|
wholesale, so the call has to name everyone who is already in it
|
||||||
|
as well as the newcomer."""
|
||||||
|
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.scan()
|
||||||
|
self._nudge_avr_input()
|
||||||
|
return self.joined_keys()
|
||||||
|
|
||||||
|
def _nudge_avr_input(self):
|
||||||
|
"""A room that has just joined the AVR's group sometimes stays
|
||||||
|
silent on it until the AVR's input is reselected -- but that has to
|
||||||
|
happen the way the HEOS app does it (browse/play_input, over HEOS
|
||||||
|
itself) to actually push audio to the new member. Re-issuing the
|
||||||
|
input over the AVR's own Telnet port does not: that just tells the
|
||||||
|
AVR which of its jacks to listen to, it says nothing to HEOS about
|
||||||
|
who should be streaming it. Best-effort: a join has already
|
||||||
|
succeeded by the time this runs, so a HEOS hiccup here should not
|
||||||
|
turn it into a failure."""
|
||||||
|
try:
|
||||||
|
current = self.avr_current_input()
|
||||||
|
if current:
|
||||||
|
self.play_heos_input(self.cfg.HOST_KEY, current["code"])
|
||||||
|
except HeosError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def leave(self, key: str) -> list:
|
||||||
|
"""Remove one room. Deliberately does not rewrite the AVR's group:
|
||||||
|
whatever is still joined keeps playing without a hiccup."""
|
||||||
|
with self._lock:
|
||||||
|
self.scan()
|
||||||
|
if key in self.joined_keys():
|
||||||
|
self.ungroup(key)
|
||||||
|
self.scan()
|
||||||
|
return self.joined_keys()
|
||||||
|
|
||||||
|
def set_membership(self, joined: list) -> list:
|
||||||
|
"""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]
|
||||||
|
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.scan()
|
||||||
|
return self.joined_keys()
|
||||||
|
|
||||||
|
# -- playback (kept for the original bridge's API) --------------------
|
||||||
|
def playback_pid(self, key: str):
|
||||||
|
return self.member_pids(key)[0]
|
||||||
|
|
||||||
|
def play_state(self, key: str, state: str):
|
||||||
|
with self._lock:
|
||||||
|
self._fresh()
|
||||||
|
self.heos.command("player/set_play_state", pid=self.playback_pid(key), state=state)
|
||||||
|
|
||||||
|
def get_play_state(self, key: str) -> str:
|
||||||
|
""""play", "pause" or "stop" for whatever this room is doing."""
|
||||||
|
with self._lock:
|
||||||
|
self._fresh()
|
||||||
|
reply = self.heos.command("player/get_play_state", pid=self.playback_pid(key))
|
||||||
|
return parse_message(reply["heos"]["message"]).get("state", "stop")
|
||||||
|
|
||||||
|
def now_playing_media(self, key: str) -> dict:
|
||||||
|
"""HEOS's get_now_playing_media payload for a room. An idle player
|
||||||
|
can refuse the query outright, which is the same as nothing loaded."""
|
||||||
|
with self._lock:
|
||||||
|
self._fresh()
|
||||||
|
try:
|
||||||
|
reply = self.heos.command("player/get_now_playing_media", pid=self.playback_pid(key))
|
||||||
|
except HeosError:
|
||||||
|
return {}
|
||||||
|
return reply.get("payload") or {}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _is_spotify(cls, media: dict) -> bool:
|
||||||
|
return (str(media.get("sid")) == cls.SPOTIFY_SID
|
||||||
|
or str(media.get("mid", "")).startswith("spotify:"))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _track(media: dict):
|
||||||
|
"""{"song", "artist", "image"} for a room's card, or None when there
|
||||||
|
is no song to show. An AVR input is not one: HEOS fills in its
|
||||||
|
"song" with the input's own name, which the input picker already
|
||||||
|
shows. Artist and cover are None when HEOS has nothing for them."""
|
||||||
|
song = media.get("song")
|
||||||
|
if not song or str(media.get("mid", "")).startswith("inputs/"):
|
||||||
|
return None
|
||||||
|
return {"song": song, "artist": media.get("artist") or None, "image": media.get("image_url") or None}
|
||||||
|
|
||||||
|
def on_spotify(self, key: str) -> bool:
|
||||||
|
"""Whether a room's now-playing is a Spotify stream, playing or
|
||||||
|
paused -- the only thing its play/pause and next buttons make sense
|
||||||
|
for."""
|
||||||
|
return self._is_spotify(self.now_playing_media(key))
|
||||||
|
|
||||||
|
def toggle_play(self, key: str, state: str = None) -> str:
|
||||||
|
"""Start or stop a room. With no state, flips whatever it is doing
|
||||||
|
now -- read from the speakers rather than trusted from the phone,
|
||||||
|
whose copy can be a few seconds old.
|
||||||
|
|
||||||
|
A room that is grouped shares the AVR's transport, so this stops the
|
||||||
|
whole group. That is HEOS's doing, not ours: a group has one thing
|
||||||
|
playing, by definition.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
self._fresh()
|
||||||
|
if state is None:
|
||||||
|
state = "pause" if self.get_play_state(key) == "play" else "play"
|
||||||
|
self.heos.command(
|
||||||
|
"player/set_play_state", pid=self.playback_pid(key), state=state
|
||||||
|
)
|
||||||
|
return state
|
||||||
|
|
||||||
|
def skip(self, key: str, direction: str):
|
||||||
|
with self._lock:
|
||||||
|
self._fresh()
|
||||||
|
command = "play_next" if direction == "next" else "play_previous"
|
||||||
|
self.heos.command(f"player/{command}", pid=self.playback_pid(key))
|
||||||
|
|
||||||
|
def heos_inputs(self, key: str) -> list:
|
||||||
|
"""A player's physical inputs, as HEOS itself lists them -- under
|
||||||
|
whatever names you gave them in the AVR's own setup menu. HEOS
|
||||||
|
carries those renamed labels, not just its generic ids, so this is
|
||||||
|
the same list the HEOS app itself shows under Sources.
|
||||||
|
|
||||||
|
Cached for INPUTS_TTL: this list rarely changes and browse/browse
|
||||||
|
is one of the slower HEOS calls."""
|
||||||
|
cached = self._inputs_cache.get(key)
|
||||||
|
if cached and time.monotonic() - cached[1] < self.INPUTS_TTL:
|
||||||
|
return cached[0]
|
||||||
|
with self._lock:
|
||||||
|
self._fresh()
|
||||||
|
reply = self.heos.command("browse/browse", sid=self.playback_pid(key))
|
||||||
|
inputs = [{"name": i.get("name"), "input_id": i.get("mid")} for i in reply.get("payload", [])]
|
||||||
|
self._inputs_cache[key] = (inputs, time.monotonic())
|
||||||
|
return inputs
|
||||||
|
|
||||||
|
def play_heos_input(self, key: str, input_id: str, source_key: str = None):
|
||||||
|
with self._lock:
|
||||||
|
self._fresh()
|
||||||
|
params = {"pid": self.playback_pid(key), "input": input_id}
|
||||||
|
if source_key:
|
||||||
|
params["spid"] = self.playback_pid(source_key)
|
||||||
|
self.heos.command("browse/play_input", **params)
|
||||||
|
|
||||||
|
# -- the AVR's inputs, all of it over HEOS ------------------------------
|
||||||
|
def avr_connected(self) -> bool:
|
||||||
|
with self._lock:
|
||||||
|
self._fresh()
|
||||||
|
try:
|
||||||
|
self._host_pid()
|
||||||
|
return True
|
||||||
|
except TargetError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def avr_inputs(self) -> list:
|
||||||
|
"""{"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)
|
||||||
|
return [{"code": s["input_id"], "name": s["name"]} for s in sources]
|
||||||
|
|
||||||
|
def avr_current_input(self):
|
||||||
|
"""{"code", "name"} for whatever the AVR is playing right now, or
|
||||||
|
None if that is not a local input (or the AVR is unreachable)."""
|
||||||
|
with self._lock:
|
||||||
|
self._fresh()
|
||||||
|
try:
|
||||||
|
reply = self.heos.command(
|
||||||
|
"player/get_now_playing_media", pid=self._host_pid()
|
||||||
|
)
|
||||||
|
except (TargetError, HeosError):
|
||||||
|
return None
|
||||||
|
payload = reply.get("payload") or {}
|
||||||
|
mid = payload.get("mid", "")
|
||||||
|
if not mid.startswith("inputs/"):
|
||||||
|
return None
|
||||||
|
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)
|
||||||
|
name = next((s["name"] for s in self.avr_inputs() if s["code"] == code), code)
|
||||||
|
return {"code": code, "name": name}
|
||||||
|
|
||||||
|
def zidoo_now_playing(self, current_input):
|
||||||
|
"""Whatever a Zidoo plugged into the AVR is showing, when it is the
|
||||||
|
AVR's selected input -- None otherwise, or if no Zidoo is
|
||||||
|
configured, or the Zidoo has nothing loaded."""
|
||||||
|
zidoo_code = getattr(self.cfg, "ZIDOO_INPUT_CODE", None)
|
||||||
|
if not (self.zidoo and zidoo_code and current_input and current_input["code"] == zidoo_code):
|
||||||
|
return None
|
||||||
|
return self.zidoo.now_playing()
|
||||||
|
|
||||||
|
def zidoo_seek(self, position_ms: int):
|
||||||
|
"""Jump the Zidoo's film -- the AVR card's now-playing -- to
|
||||||
|
position_ms."""
|
||||||
|
if not self.zidoo:
|
||||||
|
raise TargetError("No Zidoo is configured to seek in")
|
||||||
|
self.zidoo.seek(position_ms)
|
||||||
|
|
||||||
|
# -- one snapshot for the UI ------------------------------------------
|
||||||
|
def state(self) -> dict:
|
||||||
|
"""Everything the UI needs, in as few HEOS round trips as the
|
||||||
|
protocol allows: one to scan players/groups (their pids drive
|
||||||
|
everything after), then one batch carrying every room's volume,
|
||||||
|
play state and now-playing plus the AVR's, all sent together and
|
||||||
|
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"]},
|
||||||
|
"rooms": [],
|
||||||
|
"avr": {"connected": False, "input": None, "inputs": []},
|
||||||
|
"heos_ok": True,
|
||||||
|
"errors": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
host_pid = None
|
||||||
|
avr_now_playing = None
|
||||||
|
with self._lock:
|
||||||
|
try:
|
||||||
|
self.scan()
|
||||||
|
joined = set(self.joined_keys())
|
||||||
|
|
||||||
|
room_pids, room_errors = {}, {}
|
||||||
|
for key in self.cfg.ROOM_KEYS:
|
||||||
|
try:
|
||||||
|
room_pids[key] = self.member_pids(key)[0]
|
||||||
|
except TargetError as exc:
|
||||||
|
room_errors[key] = exc
|
||||||
|
try:
|
||||||
|
host_pid = self._host_pid()
|
||||||
|
except TargetError:
|
||||||
|
host_pid = None
|
||||||
|
|
||||||
|
requests, request_keys = [], []
|
||||||
|
for key, pid in room_pids.items():
|
||||||
|
requests += [
|
||||||
|
("player/get_volume", {"pid": pid}),
|
||||||
|
("player/get_play_state", {"pid": pid}),
|
||||||
|
("player/get_now_playing_media", {"pid": pid}),
|
||||||
|
]
|
||||||
|
request_keys += [(key, "volume"), (key, "play_state"), (key, "now_playing")]
|
||||||
|
if host_pid is not None:
|
||||||
|
requests.append(("player/get_now_playing_media", {"pid": host_pid}))
|
||||||
|
request_keys.append((None, "avr_now_playing"))
|
||||||
|
|
||||||
|
results = self.heos.command_batch(requests) if requests else []
|
||||||
|
by_room = {}
|
||||||
|
for (key, field), result in zip(request_keys, results):
|
||||||
|
if key is None:
|
||||||
|
avr_now_playing = result
|
||||||
|
else:
|
||||||
|
by_room.setdefault(key, {})[field] = result
|
||||||
|
|
||||||
|
for key in self.cfg.ROOM_KEYS:
|
||||||
|
room = {
|
||||||
|
"key": key,
|
||||||
|
"label": self.cfg.TARGETS[key]["label"],
|
||||||
|
"available": True,
|
||||||
|
"grouped": key in joined,
|
||||||
|
"volume": None,
|
||||||
|
"play_state": None,
|
||||||
|
"spotify": False,
|
||||||
|
"now_playing": None,
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
if key in room_errors:
|
||||||
|
raise room_errors[key]
|
||||||
|
data = by_room.get(key, {})
|
||||||
|
|
||||||
|
volume_reply = data.get("volume")
|
||||||
|
if isinstance(volume_reply, HeosError):
|
||||||
|
raise volume_reply
|
||||||
|
room["volume"] = int(parse_message(volume_reply["heos"]["message"]).get("level", -1))
|
||||||
|
|
||||||
|
play_reply = data.get("play_state")
|
||||||
|
if isinstance(play_reply, HeosError):
|
||||||
|
raise play_reply
|
||||||
|
room["play_state"] = parse_message(play_reply["heos"]["message"]).get("state", "stop")
|
||||||
|
|
||||||
|
np_reply = data.get("now_playing")
|
||||||
|
media = {} if isinstance(np_reply, HeosError) else (np_reply.get("payload") or {})
|
||||||
|
room["spotify"] = self._is_spotify(media)
|
||||||
|
# Kept while paused, so the card does not jump about
|
||||||
|
# under the thumb that just pressed pause.
|
||||||
|
if room["play_state"] != "stop":
|
||||||
|
track = self._track(media)
|
||||||
|
if track is not None:
|
||||||
|
progress = self.heos.progress_for(room_pids[key])
|
||||||
|
if progress and progress["duration"]:
|
||||||
|
track["position_ms"] = progress["cur_pos"]
|
||||||
|
track["duration_ms"] = progress["duration"]
|
||||||
|
room["now_playing"] = track
|
||||||
|
except (TargetError, HeosError, KeyError) as exc:
|
||||||
|
room["available"] = False
|
||||||
|
room["error"] = str(exc)
|
||||||
|
snapshot["rooms"].append(room)
|
||||||
|
except (HeosError, TargetError) as exc:
|
||||||
|
snapshot["heos_ok"] = False
|
||||||
|
snapshot["errors"].append(str(exc))
|
||||||
|
snapshot["rooms"] = [
|
||||||
|
{"key": key, "label": self.cfg.TARGETS[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
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
current_input = None
|
||||||
|
if host_pid is not None and avr_now_playing is not None and not isinstance(avr_now_playing, HeosError):
|
||||||
|
payload = avr_now_playing.get("payload") or {}
|
||||||
|
mid = payload.get("mid", "")
|
||||||
|
if mid.startswith("inputs/"):
|
||||||
|
current_input = {"code": mid, "name": payload.get("station") or payload.get("song") or mid}
|
||||||
|
snapshot["avr"] = {
|
||||||
|
"connected": host_pid is not None,
|
||||||
|
"inputs": self.avr_inputs(),
|
||||||
|
"input": current_input,
|
||||||
|
"now_playing": self.zidoo_now_playing(current_input),
|
||||||
|
}
|
||||||
|
except (HeosError, TargetError) as exc:
|
||||||
|
snapshot["errors"].append(str(exc))
|
||||||
|
|
||||||
|
return snapshot
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"""A pretend HEOS network, for `python3 app.py --demo`.
|
||||||
|
|
||||||
|
Lets you work on the interface on a laptop, with no speakers on the
|
||||||
|
network -- and lets the panel be tested without waking the house up.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
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": "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 = "inputs/mediaplayer"
|
||||||
|
|
||||||
|
def inputs(self, refresh=False):
|
||||||
|
return list(self.INPUTS)
|
||||||
|
|
||||||
|
def name_for(self, code):
|
||||||
|
return next((s["name"] for s in self.INPUTS if s["code"] == code), code)
|
||||||
|
|
||||||
|
def current_input(self):
|
||||||
|
return {"code": self._code, "name": self.name_for(self._code)}
|
||||||
|
|
||||||
|
def select_input(self, code):
|
||||||
|
time.sleep(0.15) # the real AVR is not instant either
|
||||||
|
self._code = code
|
||||||
|
return self.current_input()
|
||||||
|
|
||||||
|
|
||||||
|
class DemoController:
|
||||||
|
def __init__(self, cfg):
|
||||||
|
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._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)}
|
||||||
|
# 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.
|
||||||
|
self._account = next(iter(cfg.SPOTIFY_ACCOUNTS), None)
|
||||||
|
self._film_ms = 241000 # where the pretend Zidoo's film is, so a seek sticks
|
||||||
|
|
||||||
|
# -- what the UI uses ---------------------------------------------
|
||||||
|
def state(self):
|
||||||
|
return {
|
||||||
|
"host": {"key": self.cfg.HOST_KEY, "label": self.cfg.TARGETS[self.cfg.HOST_KEY]["label"]},
|
||||||
|
"rooms": [
|
||||||
|
{"key": key, "label": self.cfg.TARGETS[key]["label"], "available": True,
|
||||||
|
"grouped": key in self._joined, "volume": self._volume[key],
|
||||||
|
"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
|
||||||
|
],
|
||||||
|
"avr": {
|
||||||
|
"connected": True, "inputs": self.avr.inputs(), "input": self.avr.current_input(),
|
||||||
|
# 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,
|
||||||
|
"play_state": "play", "position_ms": self._film_ms, "duration_ms": 596000}
|
||||||
|
if self.avr.current_input()["code"] == self.cfg.ZIDOO_INPUT_CODE else None,
|
||||||
|
},
|
||||||
|
"heos_ok": True,
|
||||||
|
"errors": [],
|
||||||
|
"demo": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
def volume(self, key):
|
||||||
|
return self._volume[key]
|
||||||
|
|
||||||
|
def set_volume(self, key, level):
|
||||||
|
self._volume[key] = max(0, min(100, int(level)))
|
||||||
|
return self._volume[key]
|
||||||
|
|
||||||
|
def nudge_volume(self, key, delta):
|
||||||
|
return self.set_volume(key, self._volume[key] + int(delta))
|
||||||
|
|
||||||
|
def step_volume(self, key, steps):
|
||||||
|
return self.set_volume(key, stepped_level(self._volume[key], int(steps), self.cfg.VOLUME_STEP))
|
||||||
|
|
||||||
|
def toggle_mute(self, key):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_play_state(self, key):
|
||||||
|
return self._play[key]
|
||||||
|
|
||||||
|
def toggle_play(self, key, state=None):
|
||||||
|
if state is None:
|
||||||
|
state = "pause" if self._play[key] == "play" else "play"
|
||||||
|
self._play[key] = state
|
||||||
|
return state
|
||||||
|
|
||||||
|
def join(self, key):
|
||||||
|
self._joined.add(key)
|
||||||
|
return self.joined_keys()
|
||||||
|
|
||||||
|
def leave(self, key):
|
||||||
|
self._joined.discard(key)
|
||||||
|
return self.joined_keys()
|
||||||
|
|
||||||
|
def set_membership(self, joined):
|
||||||
|
self._joined = {k for k in self.cfg.ROOM_KEYS if k in joined}
|
||||||
|
return self.joined_keys()
|
||||||
|
|
||||||
|
def zidoo_seek(self, position_ms):
|
||||||
|
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]
|
||||||
|
|
||||||
|
# -- 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())],
|
||||||
|
"groups": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
def group_targets(self, host_key, member_keys):
|
||||||
|
return self.set_membership(member_keys)
|
||||||
|
|
||||||
|
def ungroup(self, key):
|
||||||
|
return self.leave(key)
|
||||||
|
|
||||||
|
def play_state(self, key, state):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def skip(self, key, direction):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def heos_inputs(self, key):
|
||||||
|
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
|
||||||
|
|
||||||
|
# -- the AVR: app.py calls these directly, same as the real Controller
|
||||||
|
def avr_inputs(self):
|
||||||
|
return self.avr.inputs()
|
||||||
|
|
||||||
|
def avr_current_input(self):
|
||||||
|
return self.avr.current_input()
|
||||||
|
|
||||||
|
def avr_select_input(self, code):
|
||||||
|
return self.avr.select_input(code)
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# The HEOS panel as a service.
|
||||||
|
#
|
||||||
|
# sudo cp /var/www/html/heos/deploy/heos-panel.service /etc/systemd/system/
|
||||||
|
# sudo systemctl daemon-reload
|
||||||
|
# sudo systemctl enable --now heos-panel
|
||||||
|
#
|
||||||
|
# So the deploy workflow can restart it without a password prompt:
|
||||||
|
#
|
||||||
|
# echo 'franzz ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart heos-panel' \
|
||||||
|
# | sudo tee /etc/sudoers.d/heos-panel
|
||||||
|
# sudo chmod 440 /etc/sudoers.d/heos-panel
|
||||||
|
#
|
||||||
|
# (that user is whoever the Gitea runner runs as -- see .gitea/workflows/deploy.yml)
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=HEOS panel
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=franzz
|
||||||
|
Group=www-data
|
||||||
|
WorkingDirectory=/var/www/html/heos
|
||||||
|
# Optional (the leading "-" means systemd won't refuse to start without
|
||||||
|
# it): Spotify's credentials, as plain KEY=VALUE lines -- see the
|
||||||
|
# README's Spotify section. Not in the repo on purpose, so a deploy's
|
||||||
|
# rsync --delete is told to leave it alone too.
|
||||||
|
EnvironmentFile=-/var/www/html/heos/.env
|
||||||
|
ExecStart=/var/www/html/heos/.venv/bin/gunicorn --worker-class gthread --workers 1 --threads 8 --bind 0.0.0.0:5443 app:app
|
||||||
|
# Bind 127.0.0.1:5443 above to allow only the reverse proxy in.
|
||||||
|
#
|
||||||
|
# --workers stays at 1 on purpose: the Controller holds the one persistent
|
||||||
|
# AVR Telnet connection and HEOS heartbeat thread, and gunicorn's workers
|
||||||
|
# are separate processes -- more than one would open a second Telnet
|
||||||
|
# connection, which some Denon models refuse. --threads is what gives it
|
||||||
|
# concurrency instead, same as app.py's own threaded=True dev server.
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# HEOS panel behind Apache, at /heos
|
||||||
|
#
|
||||||
|
# sudo a2enmod proxy proxy_http headers
|
||||||
|
# sudo cp /var/www/html/heos/deploy/heos.apache.conf /etc/apache2/conf-available/heos.conf
|
||||||
|
# sudo a2enconf heos
|
||||||
|
# sudo apachectl configtest && sudo systemctl reload apache2
|
||||||
|
#
|
||||||
|
# Apache reaches the panel on port 5443 (WEB_PORT in config.py). If you
|
||||||
|
# want it reachable ONLY through Apache, start it with --host 127.0.0.1;
|
||||||
|
# by default it also answers directly on the LAN at <pi-ip>:5443.
|
||||||
|
# This block also takes /heos away from the filesystem, so the source in
|
||||||
|
# /var/www/html/heos stops being reachable as static files.
|
||||||
|
|
||||||
|
<Location /heos>
|
||||||
|
# The panel controls the speakers, and it hangs off a vhost with a
|
||||||
|
# public certificate. Keep it to the house unless you mean otherwise:
|
||||||
|
# delete this RequireAny block to let it answer from anywhere.
|
||||||
|
<RequireAny>
|
||||||
|
Require ip 192.168.0.0/24
|
||||||
|
Require ip 127.0.0.1
|
||||||
|
Require ip ::1
|
||||||
|
</RequireAny>
|
||||||
|
|
||||||
|
# Tells the app it is mounted on a sub-path, so every link, icon and
|
||||||
|
# fetch it generates is /heos/... rather than /... Without this the
|
||||||
|
# page loads and nothing on it works.
|
||||||
|
RequestHeader set X-Forwarded-Prefix /heos
|
||||||
|
|
||||||
|
ProxyPass http://127.0.0.1:5443
|
||||||
|
ProxyPassReverse http://127.0.0.1:5443
|
||||||
|
</Location>
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# The HEOS panel at https://domain.com/heos
|
||||||
|
#
|
||||||
|
# sudo cp /var/www/html/heos/deploy/heos.nginx.conf /etc/nginx/sites-available/heos
|
||||||
|
# sudo ln -s /etc/nginx/sites-available/heos /etc/nginx/sites-enabled/heos
|
||||||
|
# sudo nginx -t && sudo systemctl reload nginx
|
||||||
|
#
|
||||||
|
# server_name and the certificate paths have to agree: the paths are the
|
||||||
|
# directory certbot made for that name.
|
||||||
|
#
|
||||||
|
# nginx reaches the panel on port 5443 (WEB_PORT in config.py). To make
|
||||||
|
# nginx the only way in, add --host 127.0.0.1 to ExecStart in
|
||||||
|
# deploy/heos-panel.service; by default the panel also answers directly on
|
||||||
|
# the LAN at <server-ip>:5443.
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
|
||||||
|
server_name domain.com;
|
||||||
|
|
||||||
|
# Nothing is served in the clear. certbot's nginx plugin works through
|
||||||
|
# this block when it renews, so the redirect does not get in its way.
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
listen [::]:443 ssl;
|
||||||
|
# http2 on; # nginx 1.25.1+. Older builds: listen 443 ssl http2;
|
||||||
|
|
||||||
|
server_name domain.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/domain.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/domain.com/privkey.pem;
|
||||||
|
|
||||||
|
# certbot's own settings, kept current by it -- the same thing the maui
|
||||||
|
# Apache vhost does with options-ssl-apache.conf. Both files appear when
|
||||||
|
# certbot configures a host; if this cert came another way (DNS
|
||||||
|
# challenge, standalone, copied from elsewhere) they may not exist and
|
||||||
|
# nginx -t will say so. Then drop these two lines for:
|
||||||
|
# ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
# ssl_session_cache shared:SSL:10m;
|
||||||
|
# ssl_session_timeout 1d;
|
||||||
|
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||||
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||||
|
|
||||||
|
# This host is the panel and nothing else, so refuse the rest rather
|
||||||
|
# than falling back on nginx's default root and serving whatever
|
||||||
|
# happens to sit there. Drop this block if the machine serves more.
|
||||||
|
location / {
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# A bare /heos misses the location below -- it would fall through to
|
||||||
|
# the 404 above -- so send it to the slashed form first.
|
||||||
|
location = /heos {
|
||||||
|
return 301 /heos/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /heos/ {
|
||||||
|
# The panel controls the speakers, and this hostname may well
|
||||||
|
# resolve from outside. Keep it to the house unless you mean
|
||||||
|
# otherwise: drop these four lines to let it answer from anywhere.
|
||||||
|
allow 192.168.0.0/24;
|
||||||
|
allow 127.0.0.1;
|
||||||
|
allow ::1;
|
||||||
|
deny all;
|
||||||
|
|
||||||
|
# The trailing slash is what strips /heos/ back off before the
|
||||||
|
# request reaches the app.
|
||||||
|
proxy_pass http://127.0.0.1:5443/;
|
||||||
|
|
||||||
|
# Tells the app it is mounted on a sub-path, so every link, icon and
|
||||||
|
# fetch it generates is /heos/... rather than /... Without this the
|
||||||
|
# page loads and nothing on it works.
|
||||||
|
proxy_set_header X-Forwarded-Prefix /heos;
|
||||||
|
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Want the panel at the root of this host instead of under /heos? Replace
|
||||||
|
# the three location blocks in the 443 server with the one below, and leave
|
||||||
|
# X-Forwarded-Prefix out of it -- the app is not on a sub-path then, and
|
||||||
|
# generates /static/... and /api/... just as it does on port 5443.
|
||||||
|
#
|
||||||
|
# location / {
|
||||||
|
# proxy_pass http://127.0.0.1:5443;
|
||||||
|
# proxy_set_header Host $host;
|
||||||
|
# proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
# proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
# proxy_http_version 1.1;
|
||||||
|
# proxy_set_header Connection "";
|
||||||
|
# }
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
"""HEOS CLI client (TCP port 1255).
|
||||||
|
|
||||||
|
HEOS is not JSON-RPC or anything else standard: you send one
|
||||||
|
`heos://group/command?a=1&b=2` line and read back one JSON line. This
|
||||||
|
keeps the socket open between commands -- the panel fires a command on
|
||||||
|
every button press, and a fresh TCP handshake per press is what made
|
||||||
|
the original bridge feel sluggish.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from urllib.parse import unquote_plus
|
||||||
|
|
||||||
|
|
||||||
|
class HeosError(RuntimeError):
|
||||||
|
"""The device answered, but said no (or never answered at all)."""
|
||||||
|
|
||||||
|
|
||||||
|
def parse_message(message: str) -> dict:
|
||||||
|
"""Turn a HEOS `heos.message` string into a dict.
|
||||||
|
|
||||||
|
e.g. "pid=12345&level=23" -> {"pid": "12345", "level": "23"}
|
||||||
|
"""
|
||||||
|
result = {}
|
||||||
|
for part in message.split("&"):
|
||||||
|
if "=" in part:
|
||||||
|
key, value = part.split("=", 1)
|
||||||
|
result[key] = unquote_plus(value)
|
||||||
|
elif part:
|
||||||
|
result[part] = ""
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class HeosClient:
|
||||||
|
"""One persistent, lock-guarded, self-healing HEOS connection."""
|
||||||
|
|
||||||
|
def __init__(self, host: str, port: int = 1255, timeout: float = 5.0):
|
||||||
|
self.host = host
|
||||||
|
self.port = port
|
||||||
|
self.timeout = timeout
|
||||||
|
self._sock = None
|
||||||
|
self._buffer = b""
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
# pid -> {"cur_pos", "duration"}, filled in from whatever
|
||||||
|
# unsolicited progress events turn up while we are reading the
|
||||||
|
# reply to some other command. Dropped on reconnect, since a gap
|
||||||
|
# in the connection is a gap in what we know.
|
||||||
|
self._progress = {}
|
||||||
|
|
||||||
|
# -- connection management -----------------------------------------
|
||||||
|
def _connect(self):
|
||||||
|
self._close()
|
||||||
|
sock = socket.create_connection((self.host, self.port), timeout=self.timeout)
|
||||||
|
sock.settimeout(self.timeout)
|
||||||
|
self._sock = sock
|
||||||
|
self._buffer = b""
|
||||||
|
self._progress = {}
|
||||||
|
# Without this, HEOS never pushes the now-playing-progress events
|
||||||
|
# that _exchange() below watches for. Best-effort: a player that
|
||||||
|
# refuses it still works, it just never shows song progress.
|
||||||
|
try:
|
||||||
|
self._exchange("system/register_for_change_events", {"enable": "on"})
|
||||||
|
except (OSError, HeosError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _close(self):
|
||||||
|
if self._sock is not None:
|
||||||
|
try:
|
||||||
|
self._sock.close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
self._sock = None
|
||||||
|
self._buffer = b""
|
||||||
|
|
||||||
|
def _read_line(self, deadline: float) -> bytes:
|
||||||
|
while b"\r\n" not in self._buffer:
|
||||||
|
remaining = deadline - time.monotonic()
|
||||||
|
if remaining <= 0:
|
||||||
|
raise TimeoutError("no reply from HEOS in time")
|
||||||
|
self._sock.settimeout(remaining)
|
||||||
|
chunk = self._sock.recv(4096)
|
||||||
|
if not chunk:
|
||||||
|
raise ConnectionError("HEOS closed the connection")
|
||||||
|
self._buffer += chunk
|
||||||
|
line, self._buffer = self._buffer.split(b"\r\n", 1)
|
||||||
|
return line
|
||||||
|
|
||||||
|
# -- the one method everything else goes through -------------------
|
||||||
|
def command(self, path: str, **params) -> dict:
|
||||||
|
"""Send `heos://<path>?<params>` and return the parsed reply.
|
||||||
|
|
||||||
|
Retries once on a socket-level problem, because HEOS quietly
|
||||||
|
drops connections that have been idle for a few minutes.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
for attempt in (1, 2):
|
||||||
|
try:
|
||||||
|
if self._sock is None:
|
||||||
|
self._connect()
|
||||||
|
return self._exchange(path, params)
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
# ValueError covers a desynced stream (bad JSON): in
|
||||||
|
# both cases the fix is the same, start a fresh socket.
|
||||||
|
self._close()
|
||||||
|
if attempt == 2:
|
||||||
|
raise HeosError(f"cannot reach HEOS at {self.host}: {exc}") from exc
|
||||||
|
|
||||||
|
def _exchange(self, path: str, params: dict) -> dict:
|
||||||
|
query = "&".join(f"{k}={v}" for k, v in params.items() if v is not None)
|
||||||
|
command = f"heos://{path}" + (f"?{query}" if query else "")
|
||||||
|
self._sock.sendall(command.encode("utf-8") + b"\r\n")
|
||||||
|
|
||||||
|
# Browse calls can take a while, hence the generous overall budget.
|
||||||
|
deadline = time.monotonic() + self.timeout * 3
|
||||||
|
while True:
|
||||||
|
reply = json.loads(self._read_line(deadline).decode("utf-8"))
|
||||||
|
heos = reply.get("heos", {})
|
||||||
|
|
||||||
|
if heos.get("command") != path:
|
||||||
|
self._watch_progress(heos)
|
||||||
|
continue # an event or a late reply to something else
|
||||||
|
if "under process" in heos.get("message", ""):
|
||||||
|
continue # placeholder ack; the real payload follows
|
||||||
|
if heos.get("result") == "fail":
|
||||||
|
raise HeosError(_failure_text(heos))
|
||||||
|
return reply
|
||||||
|
|
||||||
|
def command_batch(self, requests: list) -> list:
|
||||||
|
"""Send several commands back-to-back on the one socket, then
|
||||||
|
collect their replies as they come in -- so N independent reads
|
||||||
|
(a room's volume, play state and now-playing, say) cost one round
|
||||||
|
trip's worth of latency instead of N.
|
||||||
|
|
||||||
|
`requests` is a list of (path, params) pairs. Returns one entry
|
||||||
|
per request, in the same order: the parsed reply dict, or the
|
||||||
|
HeosError it failed with -- a single command failing does not
|
||||||
|
sink the rest of the batch. Only a connection-level problem
|
||||||
|
raises, same as command().
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
for attempt in (1, 2):
|
||||||
|
try:
|
||||||
|
if self._sock is None:
|
||||||
|
self._connect()
|
||||||
|
return self._exchange_batch(requests)
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
self._close()
|
||||||
|
if attempt == 2:
|
||||||
|
raise HeosError(f"cannot reach HEOS at {self.host}: {exc}") from exc
|
||||||
|
|
||||||
|
def _exchange_batch(self, requests: list) -> list:
|
||||||
|
for path, params in requests:
|
||||||
|
query = "&".join(f"{k}={v}" for k, v in params.items() if v is not None)
|
||||||
|
command = f"heos://{path}" + (f"?{query}" if query else "")
|
||||||
|
self._sock.sendall(command.encode("utf-8") + b"\r\n")
|
||||||
|
|
||||||
|
results = [None] * len(requests)
|
||||||
|
pending = list(range(len(requests)))
|
||||||
|
deadline = time.monotonic() + self.timeout * 3
|
||||||
|
while pending:
|
||||||
|
reply = json.loads(self._read_line(deadline).decode("utf-8"))
|
||||||
|
heos = reply.get("heos", {})
|
||||||
|
if "under process" in heos.get("message", ""):
|
||||||
|
continue # placeholder ack; the real payload follows
|
||||||
|
|
||||||
|
index = self._claim(pending, requests, heos)
|
||||||
|
if index is None:
|
||||||
|
self._watch_progress(heos)
|
||||||
|
continue # an event, or a reply to a command outside this batch
|
||||||
|
pending.remove(index)
|
||||||
|
results[index] = HeosError(_failure_text(heos)) if heos.get("result") == "fail" else reply
|
||||||
|
return results
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _claim(pending, requests, heos):
|
||||||
|
"""Which pending request this reply answers. Matches on path
|
||||||
|
first; when more than one pending request shares a path (e.g.
|
||||||
|
get_now_playing_media for two different pids), whichever id
|
||||||
|
parameter -- pid/gid/sid -- the request carried ties it to the
|
||||||
|
right reply, since HEOS echoes it back in the message."""
|
||||||
|
candidates = [i for i in pending if requests[i][0] == heos.get("command")]
|
||||||
|
if not candidates:
|
||||||
|
return None
|
||||||
|
if len(candidates) == 1:
|
||||||
|
return candidates[0]
|
||||||
|
message = parse_message(heos.get("message", ""))
|
||||||
|
for i in candidates:
|
||||||
|
params = requests[i][1]
|
||||||
|
for id_key in ("pid", "gid", "sid"):
|
||||||
|
if id_key in params and message.get(id_key) == str(params[id_key]):
|
||||||
|
return i
|
||||||
|
return candidates[0] # can't tell them apart; oldest first
|
||||||
|
|
||||||
|
def _watch_progress(self, heos: dict):
|
||||||
|
"""Skims a passing player_now_playing_progress event for its
|
||||||
|
pid/cur_pos/duration, the only source of song-position data this
|
||||||
|
client has -- there is no dedicated listener, so this only catches
|
||||||
|
what happens to arrive while some other command is being read."""
|
||||||
|
if heos.get("command") != "event/player_now_playing_progress":
|
||||||
|
return
|
||||||
|
message = parse_message(heos.get("message", ""))
|
||||||
|
pid = message.get("pid")
|
||||||
|
if not pid:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._progress[pid] = {
|
||||||
|
"cur_pos": int(message.get("cur_pos", 0)),
|
||||||
|
"duration": int(message.get("duration", 0)),
|
||||||
|
}
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def progress_for(self, pid) -> dict:
|
||||||
|
"""{"cur_pos", "duration"} in ms for a player, or None if no
|
||||||
|
progress event for it has come through yet this connection."""
|
||||||
|
return self._progress.get(str(pid))
|
||||||
|
|
||||||
|
def heart_beat(self):
|
||||||
|
"""Keep the socket warm so the first press after an idle spell is
|
||||||
|
as fast as the rest."""
|
||||||
|
self.command("system/heart_beat")
|
||||||
|
|
||||||
|
|
||||||
|
def _failure_text(heos: dict) -> str:
|
||||||
|
message = parse_message(heos.get("message", ""))
|
||||||
|
text = message.get("text") or "unknown error"
|
||||||
|
eid = message.get("eid")
|
||||||
|
return f"HEOS refused '{heos.get('command')}': {text}" + (f" (eid {eid})" if eid else "")
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
flask>=3.0
|
||||||
|
gunicorn>=21
|
||||||
+155
@@ -0,0 +1,155 @@
|
|||||||
|
"""Spotify Web API client -- just enough to ask a room's own Spotify
|
||||||
|
Connect receiver to resume whatever the account was last playing.
|
||||||
|
|
||||||
|
This is the opposite direction from how Spotify Connect normally works:
|
||||||
|
instead of the phone pushing playback to a speaker, `resume()` calls the
|
||||||
|
Web API's "Transfer Playback" endpoint to pull it there. It needs a
|
||||||
|
Spotify Developer app and a one-time login -- see tools/spotify_auth.py
|
||||||
|
and the README's Spotify section -- because Spotify has no way to grant
|
||||||
|
that without a human authorizing it once.
|
||||||
|
|
||||||
|
Credentials are never hardcoded here: the client takes them as arguments
|
||||||
|
and config.py reads them from the environment, so nothing secret ends up
|
||||||
|
committed alongside the rest of the config.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
class SpotifyError(RuntimeError):
|
||||||
|
"""Spotify answered, but said no (or never answered at all)."""
|
||||||
|
|
||||||
|
|
||||||
|
class SpotifyClient:
|
||||||
|
"""One access token, refreshed on demand, guarded by a lock the same
|
||||||
|
way HeosClient guards its socket."""
|
||||||
|
|
||||||
|
ACCOUNTS_URL = "https://accounts.spotify.com"
|
||||||
|
API_URL = "https://api.spotify.com"
|
||||||
|
|
||||||
|
def __init__(self, client_id, client_secret, refresh_token, timeout=8.0,
|
||||||
|
accounts_url=None, api_url=None):
|
||||||
|
self.client_id = client_id
|
||||||
|
self.client_secret = client_secret
|
||||||
|
self.refresh_token = refresh_token
|
||||||
|
self.timeout = timeout
|
||||||
|
self.accounts_url = accounts_url or self.ACCOUNTS_URL
|
||||||
|
self.api_url = api_url or self.API_URL
|
||||||
|
self._access_token = None
|
||||||
|
self._expires_at = 0
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
# -- auth ------------------------------------------------------------
|
||||||
|
def _refresh_token(self) -> str:
|
||||||
|
"""Exchange the long-lived refresh token for a fresh access token.
|
||||||
|
Spotify's access tokens last about an hour; refresh a bit early
|
||||||
|
rather than racing the clock on every call."""
|
||||||
|
credentials = base64.b64encode(
|
||||||
|
f"{self.client_id}:{self.client_secret}".encode()).decode()
|
||||||
|
body = f"grant_type=refresh_token&refresh_token={self.refresh_token}".encode()
|
||||||
|
request = urllib.request.Request(
|
||||||
|
f"{self.accounts_url}/api/token",
|
||||||
|
data=body,
|
||||||
|
method="POST",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Basic {credentials}",
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
||||||
|
payload = json.loads(response.read())
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
raise SpotifyError(f"Spotify login refresh failed: {_error_detail(exc)}") from exc
|
||||||
|
except urllib.error.URLError as exc:
|
||||||
|
raise SpotifyError(f"Could not reach Spotify: {exc.reason}") from exc
|
||||||
|
self._access_token = payload["access_token"]
|
||||||
|
self._expires_at = time.time() + payload.get("expires_in", 3600)
|
||||||
|
return self._access_token
|
||||||
|
|
||||||
|
def _token(self) -> str:
|
||||||
|
with self._lock:
|
||||||
|
if self._access_token and time.time() < self._expires_at - 30:
|
||||||
|
return self._access_token
|
||||||
|
return self._refresh_token()
|
||||||
|
|
||||||
|
# -- transport ---------------------------------------------------------
|
||||||
|
def _call(self, method: str, path: str, body: dict = None, retrying: bool = False):
|
||||||
|
headers = {"Authorization": f"Bearer {self._token()}"}
|
||||||
|
data = None
|
||||||
|
if body is not None:
|
||||||
|
data = json.dumps(body).encode()
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
request = urllib.request.Request(
|
||||||
|
f"{self.api_url}{path}", data=data, method=method, headers=headers)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
||||||
|
raw = response.read()
|
||||||
|
# A player command (seek, say) can answer 200 with a body that is
|
||||||
|
# not JSON at all -- it worked, there is just nothing to read.
|
||||||
|
try:
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
except ValueError:
|
||||||
|
return {}
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
if exc.code == 401 and not retrying:
|
||||||
|
# The access token can go stale between calls even inside
|
||||||
|
# its nominal lifetime -- one retry after a forced refresh
|
||||||
|
# covers that without hiding a genuinely bad refresh token.
|
||||||
|
exc.close()
|
||||||
|
with self._lock:
|
||||||
|
self._access_token = None
|
||||||
|
return self._call(method, path, body, retrying=True)
|
||||||
|
if exc.code == 404 and method == "PUT" and path == "/v1/me/player":
|
||||||
|
raise SpotifyError("Nothing is queued to resume on that Spotify account") from exc
|
||||||
|
raise SpotifyError(f"Spotify said no: {_error_detail(exc)}") from exc
|
||||||
|
except urllib.error.URLError as exc:
|
||||||
|
raise SpotifyError(f"Could not reach Spotify: {exc.reason}") from exc
|
||||||
|
|
||||||
|
# -- the calls this panel needs -----------------------------------------
|
||||||
|
def devices(self) -> list:
|
||||||
|
"""Every Spotify Connect receiver visible to this account right
|
||||||
|
now -- including a HEOS room nobody has ever connected to from the
|
||||||
|
Spotify app, since Connect devices announce themselves on the LAN
|
||||||
|
whether or not anything is currently playing."""
|
||||||
|
return self._call("GET", "/v1/me/player/devices").get("devices", [])
|
||||||
|
|
||||||
|
def playback(self) -> dict:
|
||||||
|
"""The account's current playback -- which device, and whether it is
|
||||||
|
playing -- or {} when the account has no playback session at all."""
|
||||||
|
return self._call("GET", "/v1/me/player")
|
||||||
|
|
||||||
|
def resume(self, device_name: str) -> dict:
|
||||||
|
"""Transfer the account's current (usually paused) playback to the
|
||||||
|
named device and resume it -- the reverse of tapping the device in
|
||||||
|
the Spotify app's Connect picker."""
|
||||||
|
devices = self.devices()
|
||||||
|
matches = [d for d in devices if d.get("name") == device_name]
|
||||||
|
if not matches:
|
||||||
|
visible = ", ".join(d.get("name", "?") for d in devices) or "none"
|
||||||
|
raise SpotifyError(
|
||||||
|
f"No Spotify Connect device named '{device_name}' is visible right now "
|
||||||
|
f"(Spotify sees: {visible}) -- is the room powered on, or does its "
|
||||||
|
"spotify_name in config.py need setting?")
|
||||||
|
device = matches[0]
|
||||||
|
self._call("PUT", "/v1/me/player", body={"device_ids": [device["id"]], "play": True})
|
||||||
|
return device
|
||||||
|
|
||||||
|
def seek(self, position_ms: int):
|
||||||
|
"""Jump to position_ms in whatever the account is playing, on
|
||||||
|
whichever device is playing it."""
|
||||||
|
self._call("PUT", f"/v1/me/player/seek?position_ms={int(position_ms)}")
|
||||||
|
|
||||||
|
|
||||||
|
def _error_detail(exc: urllib.error.HTTPError) -> str:
|
||||||
|
try:
|
||||||
|
with exc:
|
||||||
|
payload = json.loads(exc.read())
|
||||||
|
return payload.get("error_description") or payload.get("error", {}).get("message") or exc.reason
|
||||||
|
except (ValueError, AttributeError, KeyError):
|
||||||
|
return exc.reason
|
||||||
+731
@@ -0,0 +1,731 @@
|
|||||||
|
/* The panel. Every control acts immediately and reconciles with what the
|
||||||
|
speakers report a moment later, because a remote that waits for a
|
||||||
|
network round trip before it looks like it did anything feels broken. */
|
||||||
|
|
||||||
|
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 || '/';
|
||||||
|
const POLL_MS = 5000;
|
||||||
|
// How long a dropped cursor waits for the player to report it got there.
|
||||||
|
const SEEK_HOLD_MS = 10000;
|
||||||
|
|
||||||
|
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 -16 576 512', path: 'M0 112c0 70.7 57.3 128 128 128l224 0c70.7 0 128-57.3 128-128S422.7-16 352-16c-48.2 0-90.2 26.6-112 66-21.8-39.4-63.8-66-112-66-70.7 0-128 57.3-128 128zm304 0a48 48 0 1 1 96 0 48 48 0 1 1 -96 0zM128 64a48 48 0 1 1 0 96 48 48 0 1 1 0-96zM64 352l0 64c0 35.3 28.7 64 64 64l224 0c35.3 0 64-28.7 64-64l0-64c0-35.3-28.7-64-64-64l-224 0c-35.3 0-64 28.7-64 64zM537.5 490.8c4.2 3.4 9.4 5.2 14.8 5.2 13.1 0 23.7-10.6 23.7-23.7l0-240.6c0-13.1-10.6-23.7-23.7-23.7-5.4 0-10.6 1.8-14.8 5.2l-73.5 58.8 0 160 73.5 58.8z' },
|
||||||
|
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"]'),
|
||||||
|
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"]'),
|
||||||
|
source: el('.card.source'),
|
||||||
|
joined: el('[data-role="joined"]'),
|
||||||
|
avrNowPlaying: el('[data-role="avr-now-playing"]'),
|
||||||
|
avrCover: el('[data-role="avr-cover"]'),
|
||||||
|
avrSong: el('[data-role="avr-song"]'),
|
||||||
|
avrArtist: el('[data-role="avr-artist"]'),
|
||||||
|
avrProgress: el('[data-role="avr-progress"]'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Shares its shape with a room's {nowPlaying, cover, song, artist,
|
||||||
|
// progress} refs, so renderTrack() below works for both -- a device plugged
|
||||||
|
// into the AVR (a Zidoo, say) is "now playing" the same way a room's own
|
||||||
|
// stream is.
|
||||||
|
const avrTrack = {
|
||||||
|
nowPlaying: ui.avrNowPlaying,
|
||||||
|
cover: ui.avrCover,
|
||||||
|
song: ui.avrSong,
|
||||||
|
artist: ui.avrArtist,
|
||||||
|
progress: ui.avrProgress,
|
||||||
|
// Only a Zidoo ever fills this card's now-playing in, and it can always seek.
|
||||||
|
canSeek: () => true,
|
||||||
|
seek: null,
|
||||||
|
};
|
||||||
|
// Same reasoning as a room's own cover: drop one that will not load rather
|
||||||
|
// than leave a broken-image box.
|
||||||
|
avrTrack.cover.addEventListener('error', () => { avrTrack.cover.hidden = true; });
|
||||||
|
seekable(avrTrack);
|
||||||
|
|
||||||
|
const rooms = {};
|
||||||
|
let inputs = [];
|
||||||
|
let currentInput = null;
|
||||||
|
let avrNowPlaying = null;
|
||||||
|
|
||||||
|
/* --- transport -------------------------------------------------------- */
|
||||||
|
async function api(path, body) {
|
||||||
|
const options = body === undefined
|
||||||
|
? {}
|
||||||
|
: { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) };
|
||||||
|
const response = await fetch(BASE + path.replace(/^\//, ''), options);
|
||||||
|
let data = {};
|
||||||
|
try { data = await response.json(); } catch (_) { /* empty or not JSON */ }
|
||||||
|
if (!response.ok) throw new Error(data.error || `${response.status} ${response.statusText}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
let toastTimer;
|
||||||
|
/* Red by default, since most toasts report trouble; 'ok' for a confirmation. */
|
||||||
|
function toast(message, kind = 'error') {
|
||||||
|
ui.toast.textContent = message;
|
||||||
|
ui.toast.classList.toggle('ok', kind === 'ok');
|
||||||
|
ui.toast.hidden = false;
|
||||||
|
clearTimeout(toastTimer);
|
||||||
|
toastTimer = setTimeout(() => { ui.toast.hidden = true; }, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- rooms ------------------------------------------------------------ */
|
||||||
|
els('.room').forEach((node) => {
|
||||||
|
const key = node.dataset.room;
|
||||||
|
const room = {
|
||||||
|
key,
|
||||||
|
node,
|
||||||
|
slot: el(`.room-slot[data-slot="${key}"]`),
|
||||||
|
level: el('[data-role="level"]', node),
|
||||||
|
meter: el('[data-role="meter"]', node),
|
||||||
|
actions: el('[data-role="actions"]', node),
|
||||||
|
toggle: el('[data-role="group"]', node),
|
||||||
|
toggleLabel: el('[data-role="group-label"]', node),
|
||||||
|
prev: el('[data-role="prev"]', node),
|
||||||
|
play: el('[data-role="play"]', node),
|
||||||
|
next: el('[data-role="next"]', node),
|
||||||
|
spotify: el('.spotify-row', node), // the resume buttons and the host toggle
|
||||||
|
nowPlaying: el('[data-role="now-playing"]', node),
|
||||||
|
cover: el('[data-role="cover"]', node),
|
||||||
|
song: el('[data-role="song"]', node),
|
||||||
|
artist: el('[data-role="artist"]', node),
|
||||||
|
progress: el('[data-role="progress"]', node),
|
||||||
|
steps: els('.step', node),
|
||||||
|
volume: null,
|
||||||
|
playState: null,
|
||||||
|
track: null, // {song, artist, image} while something is loaded
|
||||||
|
onSpotify: false,
|
||||||
|
spotifyAccount: null, // the account playing here, whose button gets a border
|
||||||
|
grouped: false,
|
||||||
|
available: false,
|
||||||
|
taps: 0, // button presses not yet sent
|
||||||
|
wanted: null, // a level dragged to, not yet sent
|
||||||
|
dragging: false,
|
||||||
|
inflight: false,
|
||||||
|
busy: false, // a grouping change is in flight
|
||||||
|
playBusy: false,
|
||||||
|
// HEOS cannot seek, so only a stream Spotify can be asked to seek in: the
|
||||||
|
// same one the transport buttons show for (see paintRoom).
|
||||||
|
canSeek: () => room.onSpotify && room.spotifyAccount && !room.grouped,
|
||||||
|
seek: null, // {ms, stage, ...} from a drag of the cursor until the player catches up
|
||||||
|
};
|
||||||
|
rooms[key] = room;
|
||||||
|
|
||||||
|
room.steps.forEach((button) => {
|
||||||
|
const direction = Number(button.dataset.delta);
|
||||||
|
holdable(button, () => nudge(key, direction));
|
||||||
|
});
|
||||||
|
draggable(room);
|
||||||
|
seekable(room);
|
||||||
|
|
||||||
|
// A cover that will not load (gone, or plain http on an https page) is
|
||||||
|
// dropped rather than left as a broken-image box. Its src stays put, so
|
||||||
|
// the next poll does not try it again until the track changes.
|
||||||
|
room.cover.addEventListener('error', () => { room.cover.hidden = true; });
|
||||||
|
|
||||||
|
room.toggle.addEventListener('click', () => setGrouped(key, !room.grouped));
|
||||||
|
room.prev.addEventListener('click', () => skipTrack(key, 'previous'));
|
||||||
|
room.play.addEventListener('click', () => togglePlay(key));
|
||||||
|
room.next.addEventListener('click', () => skipTrack(key, 'next'));
|
||||||
|
});
|
||||||
|
|
||||||
|
function paintRoom(room) {
|
||||||
|
const known = room.volume !== null && room.volume !== undefined;
|
||||||
|
room.level.textContent = known ? room.volume : '—';
|
||||||
|
// The fill and the knob riding it both size themselves off this.
|
||||||
|
room.meter.style.setProperty('--level', known ? room.volume : 0);
|
||||||
|
room.node.classList.toggle('offline', !room.available);
|
||||||
|
room.steps.forEach((button) => { button.disabled = !room.available; });
|
||||||
|
renderTrack(room.available ? room.track : null, room);
|
||||||
|
const playing = room.playState === 'play';
|
||||||
|
room.node.classList.toggle('playing', playing);
|
||||||
|
room.play.classList.toggle('playing', playing);
|
||||||
|
room.play.disabled = !room.available || room.playState === null;
|
||||||
|
room.play.setAttribute(
|
||||||
|
'aria-label', `${room.node.querySelector('h2').textContent}: ${playing ? 'pause' : 'play'}`);
|
||||||
|
// Previous, play/pause and next only mean something for a Spotify stream:
|
||||||
|
// an AVR input has no queue to pause or skip, and starting Spotify is what
|
||||||
|
// the account buttons are for. Grouped, transport belongs to the AVR's
|
||||||
|
// card, not this one -- pressing them here would still work (it shares
|
||||||
|
// the group's transport) but only invites confusion about which card is
|
||||||
|
// actually in charge of it. And only for a stream one of our own accounts
|
||||||
|
// is playing: someone else's phone keeps its own controls. The row shows
|
||||||
|
// or hides as one, so an empty row never leaves a gap in the card.
|
||||||
|
room.actions.hidden = !(room.onSpotify && room.spotifyAccount && !room.grouped);
|
||||||
|
room.prev.disabled = !room.available;
|
||||||
|
room.next.disabled = !room.available;
|
||||||
|
// Grouped, the room plays whatever the AVR does, so resuming Spotify on it
|
||||||
|
// is not on offer. They hide one by one rather than the row hiding with
|
||||||
|
// them, because the row also holds the host toggle, which stays put and
|
||||||
|
// merely disables.
|
||||||
|
els('.spotify', room.spotify).forEach((button) => {
|
||||||
|
button.hidden = !room.available || room.grouped;
|
||||||
|
button.setAttribute('aria-pressed', String(button.dataset.account === room.spotifyAccount));
|
||||||
|
});
|
||||||
|
|
||||||
|
room.toggle.disabled = !room.available;
|
||||||
|
room.toggle.classList.toggle('busy', room.busy);
|
||||||
|
room.toggle.setAttribute('aria-pressed', String(room.grouped));
|
||||||
|
// Out of the group the button names where tapping it sends the room; in it,
|
||||||
|
// where the card sits already says so, so the button says what it does.
|
||||||
|
room.toggleLabel.textContent = room.grouped ? 'Ungroup' : 'Cinema';
|
||||||
|
// Grouped, its slot is empty and the AVR's card is the one that moves up.
|
||||||
|
room.slot.classList.toggle('on-top', playing && !room.grouped);
|
||||||
|
placeRoom(room);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Song, artist, cover and progress, each only when there is one. The
|
||||||
|
cover's src is only touched when the track changes, so a poll never makes
|
||||||
|
it flicker. */
|
||||||
|
function renderTrack(track, refs) {
|
||||||
|
renderProgress(track, refs);
|
||||||
|
refs.nowPlaying.hidden = !track;
|
||||||
|
if (!track) return;
|
||||||
|
refs.song.textContent = track.song;
|
||||||
|
refs.artist.textContent = track.artist || '';
|
||||||
|
refs.artist.hidden = !track.artist;
|
||||||
|
if (!track.image) {
|
||||||
|
refs.cover.hidden = true;
|
||||||
|
refs.cover.removeAttribute('src');
|
||||||
|
} else if (refs.cover.getAttribute('src') !== track.image) {
|
||||||
|
refs.cover.hidden = false;
|
||||||
|
refs.cover.src = track.image;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The discreet cursor on the line below the song, sized off the same
|
||||||
|
{position_ms, duration_ms} the poll hands back -- absent for anything
|
||||||
|
HEOS never sends a progress event for (an AVR input, an internet radio
|
||||||
|
stream with no fixed length), in which case the line stays plain. Around
|
||||||
|
a seek, the position seekPosition() picks shows instead of the one reported. */
|
||||||
|
function renderProgress(track, refs) {
|
||||||
|
const { progress } = refs;
|
||||||
|
progress.hidden = !track;
|
||||||
|
refs.shown = null;
|
||||||
|
if (!track) return;
|
||||||
|
const { duration_ms: duration } = track;
|
||||||
|
const position = seekPosition(track, refs);
|
||||||
|
const known = typeof duration === 'number' && duration > 0 && typeof position === 'number';
|
||||||
|
const percent = known ? Math.min(100, Math.max(0, (position / duration) * 100)) : 0;
|
||||||
|
if (known) {
|
||||||
|
refs.shown = track;
|
||||||
|
refs.shownMs = position;
|
||||||
|
}
|
||||||
|
progress.classList.toggle('known', known);
|
||||||
|
progress.classList.toggle('seekable', known && Boolean(refs.canSeek()));
|
||||||
|
progress.style.setProperty('--progress', percent);
|
||||||
|
// Whole seconds on both sides, so elapsed and remaining always add up to
|
||||||
|
// the total shown at the end of the line.
|
||||||
|
const total = known ? Math.floor(duration / 1000) : 0;
|
||||||
|
const elapsed = known ? Math.min(total, Math.max(0, Math.floor(position / 1000))) : 0;
|
||||||
|
const labels = {
|
||||||
|
elapsed: known ? '+' + clock(elapsed) : '',
|
||||||
|
remaining: known ? '−' + clock(total - elapsed) : '',
|
||||||
|
};
|
||||||
|
Object.entries(labels).forEach(([role, text]) => {
|
||||||
|
const label = el(`[data-role="progress-${role}"]`, progress);
|
||||||
|
label.textContent = text;
|
||||||
|
// Read back once the new text is in, for panel.css to clamp it by.
|
||||||
|
if (known) label.style.setProperty('--half', `${label.offsetWidth / 2}px`);
|
||||||
|
});
|
||||||
|
el('[data-role="progress-total"]', progress).textContent = known ? clock(total) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Drag the cursor, or either time riding with it, to jump backwards or
|
||||||
|
forwards. Like the volume knob, it moves by how far the finger travels
|
||||||
|
rather than jumping to where it lands, so grabbing a time off-centre never
|
||||||
|
lurches the song -- and only the release is sent, so the player is not
|
||||||
|
asked to seek a dozen times along the way. A vertical swipe stays a page
|
||||||
|
scroll (see panel.css), which cancels the drag. */
|
||||||
|
function seekable(refs) {
|
||||||
|
const { progress } = refs;
|
||||||
|
const line = el('.progress-line', progress);
|
||||||
|
let startX = 0;
|
||||||
|
let startMs = 0;
|
||||||
|
let moved = false;
|
||||||
|
|
||||||
|
let held = null; // a previous seek still waiting on the player, back if this drag goes nowhere
|
||||||
|
|
||||||
|
line.addEventListener('pointerdown', (event) => {
|
||||||
|
if (event.button > 0 || seeking(refs) || !refs.shown || !progress.classList.contains('seekable')) return;
|
||||||
|
if (!event.target.closest('.progress-cursor, .progress-time')) return;
|
||||||
|
event.preventDefault();
|
||||||
|
line.setPointerCapture(event.pointerId);
|
||||||
|
startX = event.clientX;
|
||||||
|
startMs = refs.shownMs; // where the cursor is, even if the player has not caught up with it yet
|
||||||
|
moved = false;
|
||||||
|
held = refs.seek;
|
||||||
|
refs.seek = { ms: startMs, stage: 'drag', song: refs.shown.song };
|
||||||
|
progress.classList.add('dragging');
|
||||||
|
});
|
||||||
|
|
||||||
|
line.addEventListener('pointermove', (event) => {
|
||||||
|
if (!refs.seek || refs.seek.stage !== 'drag' || !refs.shown) return;
|
||||||
|
const dx = event.clientX - startX;
|
||||||
|
// A few pixels of slack, so a tap that wobbles is still just a tap.
|
||||||
|
if (!moved && Math.abs(dx) < 6) return;
|
||||||
|
moved = true;
|
||||||
|
const { duration_ms: duration } = refs.shown;
|
||||||
|
const ms = startMs + (dx / line.clientWidth) * duration;
|
||||||
|
refs.seek.ms = Math.round(Math.max(0, Math.min(duration, ms)));
|
||||||
|
renderProgress(refs.shown, refs);
|
||||||
|
});
|
||||||
|
|
||||||
|
const stop = (event) => {
|
||||||
|
if (!refs.seek || refs.seek.stage !== 'drag') return;
|
||||||
|
progress.classList.remove('dragging');
|
||||||
|
if (event.type === 'pointerup' && moved) {
|
||||||
|
sendSeek(refs);
|
||||||
|
} else {
|
||||||
|
refs.seek = held;
|
||||||
|
if (refs.shown) renderProgress(refs.shown, refs);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
line.addEventListener('pointerup', stop);
|
||||||
|
line.addEventListener('pointercancel', stop);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A seek that fails snaps the cursor back; one that works holds it where it
|
||||||
|
was dropped (see seekPosition). */
|
||||||
|
async function sendSeek(refs) {
|
||||||
|
const { seek } = refs;
|
||||||
|
seek.stage = 'send';
|
||||||
|
seek.sentAt = Date.now();
|
||||||
|
try {
|
||||||
|
await api('/api/seek', { target: refs.progress.dataset.target, position_ms: seek.ms });
|
||||||
|
seek.stage = 'hold';
|
||||||
|
// The player takes a moment to report where it has got to.
|
||||||
|
setTimeout(refresh, 1500);
|
||||||
|
} catch (error) {
|
||||||
|
toast(error.message);
|
||||||
|
refs.seek = null;
|
||||||
|
}
|
||||||
|
// Unless a poll in the meantime found nothing left to seek in.
|
||||||
|
if (refs.shown) renderProgress(refs.shown, refs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Where the cursor goes: under the finger while dragging, and where it was
|
||||||
|
dropped while that is sent. After that the player still reports its old
|
||||||
|
position for a poll or two -- HEOS only passes its progress on as it goes,
|
||||||
|
and a Zidoo mid-seek is no quicker -- so the cursor stays put rather than
|
||||||
|
bouncing back, until a reported position could only come after the seek
|
||||||
|
(the drop, give or take, plus however long it has played on since). A
|
||||||
|
different track, or SEEK_HOLD_MS with no such report, lets go as well. */
|
||||||
|
function seekPosition(track, refs) {
|
||||||
|
const { seek } = refs;
|
||||||
|
if (!seek) return track.position_ms;
|
||||||
|
if (seek.stage !== 'hold') return seek.ms;
|
||||||
|
const since = Date.now() - seek.sentAt;
|
||||||
|
const slack = 2000;
|
||||||
|
const caughtUp = track.position_ms >= seek.ms - slack && track.position_ms <= seek.ms + since + slack;
|
||||||
|
if (caughtUp || track.song !== seek.song || since > SEEK_HOLD_MS) {
|
||||||
|
refs.seek = null;
|
||||||
|
return track.position_ms;
|
||||||
|
}
|
||||||
|
return seek.ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dragged or on its way -- not merely waiting for the player to catch up,
|
||||||
|
which is exactly what the polls must keep coming for. */
|
||||||
|
function seeking(refs) {
|
||||||
|
return Boolean(refs.seek) && refs.seek.stage !== 'hold';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 83 -> "1:23", 4000 -> "1:06:40": hours only for what runs that long. */
|
||||||
|
function clock(seconds) {
|
||||||
|
const h = Math.floor(seconds / 3600);
|
||||||
|
const m = Math.floor(seconds / 60) % 60;
|
||||||
|
const s = String(seconds % 60).padStart(2, '0');
|
||||||
|
return h ? `${h}:${String(m).padStart(2, '0')}:${s}` : `${m}:${s}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A merged room moves into the host's card, because that is what merging
|
||||||
|
means: one group, playing one thing. Leaving puts the card back in its
|
||||||
|
own slot, which is why the slots exist. */
|
||||||
|
function placeRoom(room) {
|
||||||
|
const target = room.grouped ? ui.joined : room.slot;
|
||||||
|
if (room.node.parentElement !== target) target.appendChild(room.node);
|
||||||
|
// Left empty, a slot would still take a gap of its own and double the space.
|
||||||
|
room.slot.hidden = room.grouped;
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintGrouping() {
|
||||||
|
const order = Object.keys(rooms);
|
||||||
|
const inside = Array.from(ui.joined.children);
|
||||||
|
// Keep them in the order the cards are declared, not the order they joined.
|
||||||
|
inside
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => order.indexOf(a.dataset.room) - order.indexOf(b.dataset.room))
|
||||||
|
.forEach((node) => ui.joined.appendChild(node));
|
||||||
|
ui.joined.hidden = inside.length === 0;
|
||||||
|
// Grouped rooms share the host's transport, so one of them playing means
|
||||||
|
// the whole group is -- and the tint goes on the group's card.
|
||||||
|
// A film running on the Zidoo counts as the AVR playing too, grouped rooms or not.
|
||||||
|
const playing = inside.some((node) => node.classList.contains('playing'))
|
||||||
|
|| avrNowPlaying?.play_state === 'play';
|
||||||
|
ui.source.classList.toggle('playing', playing);
|
||||||
|
ui.source.classList.toggle('on-top', playing);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Press and hold to keep moving, accelerating as you hold. */
|
||||||
|
function holdable(node, action) {
|
||||||
|
let timer = null;
|
||||||
|
let delay = 420;
|
||||||
|
|
||||||
|
const stop = () => { clearTimeout(timer); timer = null; delay = 420; };
|
||||||
|
const tick = () => {
|
||||||
|
action();
|
||||||
|
delay = Math.max(130, delay * 0.72);
|
||||||
|
timer = setTimeout(tick, delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
node.addEventListener('pointerdown', (event) => {
|
||||||
|
if (event.button > 0 || node.disabled) return;
|
||||||
|
event.preventDefault(); // also suppresses the click that would double-fire
|
||||||
|
action();
|
||||||
|
timer = setTimeout(tick, delay);
|
||||||
|
});
|
||||||
|
['pointerup', 'pointercancel', 'pointerleave'].forEach((type) => node.addEventListener(type, stop));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A tap lands on the next multiple of STEP rather than adding STEP, so a
|
||||||
|
level of 23 goes to 25 on the way up and 20 on the way down. Mirrors
|
||||||
|
stepped_level() in controller.py -- the panel guesses with it, the
|
||||||
|
speakers are the ones that decide. */
|
||||||
|
function nextLevel(current, direction) {
|
||||||
|
if (direction > 0) return Math.min(100, (Math.floor(current / STEP) + 1) * STEP);
|
||||||
|
const aligned = Math.floor(current / STEP) * STEP;
|
||||||
|
return Math.max(0, aligned === current ? current - STEP : aligned);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nudge(key, direction) {
|
||||||
|
const room = rooms[key];
|
||||||
|
if (!room.available) return;
|
||||||
|
room.volume = nextLevel(room.volume ?? 0, direction);
|
||||||
|
room.taps += direction;
|
||||||
|
paintRoom(room);
|
||||||
|
if (room.taps === 0) refresh(); // taps cancelled out; take the real level
|
||||||
|
else flushVolume(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A burst of taps collapses into one call, so holding + does not queue up
|
||||||
|
thirty requests the speakers then have to chew through. */
|
||||||
|
async function flushVolume(key) {
|
||||||
|
const room = rooms[key];
|
||||||
|
if (room.inflight) return;
|
||||||
|
// A dragged level goes first: any taps still waiting were pressed after it,
|
||||||
|
// so they are meant to move on from it.
|
||||||
|
let body;
|
||||||
|
if (room.wanted !== null) {
|
||||||
|
body = { target: key, level: room.wanted };
|
||||||
|
room.wanted = null;
|
||||||
|
} else if (room.taps) {
|
||||||
|
body = { target: key, steps: room.taps };
|
||||||
|
room.taps = 0;
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
room.inflight = true;
|
||||||
|
try {
|
||||||
|
const data = await api('/api/volume', body);
|
||||||
|
if (!room.taps && room.wanted === null && !room.dragging) {
|
||||||
|
room.volume = data.level;
|
||||||
|
paintRoom(room);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast(error.message);
|
||||||
|
refresh();
|
||||||
|
} finally {
|
||||||
|
room.inflight = false;
|
||||||
|
if (room.taps || room.wanted !== null) flushVolume(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Drag the knob to set the level outright, with the speakers following as
|
||||||
|
it goes -- a drag collapses into one call at a time, the same as a burst
|
||||||
|
of taps. It moves by how far the finger travels rather than jumping to
|
||||||
|
where it lands, so grabbing the knob off-centre, or brushing it while
|
||||||
|
scrolling past, never lurches the volume. */
|
||||||
|
function draggable(room) {
|
||||||
|
const knob = room.level;
|
||||||
|
let startX = 0;
|
||||||
|
let startLevel = 0;
|
||||||
|
let travel = 0;
|
||||||
|
|
||||||
|
knob.addEventListener('pointerdown', (event) => {
|
||||||
|
if (event.button > 0 || !room.available) return;
|
||||||
|
event.preventDefault();
|
||||||
|
knob.setPointerCapture(event.pointerId);
|
||||||
|
startX = event.clientX;
|
||||||
|
startLevel = room.volume ?? 0;
|
||||||
|
travel = room.meter.clientWidth - knob.offsetWidth; // how far the knob itself can go
|
||||||
|
room.dragging = true;
|
||||||
|
room.meter.classList.add('dragging');
|
||||||
|
});
|
||||||
|
|
||||||
|
knob.addEventListener('pointermove', (event) => {
|
||||||
|
if (!room.dragging || travel <= 0) return;
|
||||||
|
const moved = ((event.clientX - startX) / travel) * 100;
|
||||||
|
const level = Math.round(Math.max(0, Math.min(100, startLevel + moved)));
|
||||||
|
if (level === room.volume) return;
|
||||||
|
room.volume = level;
|
||||||
|
room.wanted = level;
|
||||||
|
room.taps = 0; // an absolute level supersedes any taps not yet sent
|
||||||
|
paintRoom(room);
|
||||||
|
flushVolume(room.key);
|
||||||
|
});
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
room.dragging = false;
|
||||||
|
room.meter.classList.remove('dragging');
|
||||||
|
};
|
||||||
|
knob.addEventListener('pointerup', stop);
|
||||||
|
knob.addEventListener('pointercancel', stop);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setGrouped(key, joined) {
|
||||||
|
const room = rooms[key];
|
||||||
|
if (room.busy || !room.available) return;
|
||||||
|
room.busy = true;
|
||||||
|
room.grouped = joined; // show the new state while the speakers catch up
|
||||||
|
paintRoom(room);
|
||||||
|
paintGrouping();
|
||||||
|
try {
|
||||||
|
const data = await api('/api/group', { target: key, joined });
|
||||||
|
applyJoined(data.joined || []);
|
||||||
|
} catch (error) {
|
||||||
|
toast(error.message);
|
||||||
|
} finally {
|
||||||
|
room.busy = false;
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sends the state it wants rather than "toggle", so a stale idea of what
|
||||||
|
the room is doing cannot flip it the wrong way. */
|
||||||
|
async function togglePlay(key) {
|
||||||
|
const room = rooms[key];
|
||||||
|
if (!room.available || room.playBusy) return;
|
||||||
|
const wanted = room.playState === 'play' ? 'pause' : 'play';
|
||||||
|
room.playBusy = true;
|
||||||
|
room.playState = wanted;
|
||||||
|
paintRoom(room);
|
||||||
|
try {
|
||||||
|
const data = await api('/api/playback', { target: key, state: wanted });
|
||||||
|
room.playState = data.state;
|
||||||
|
} catch (error) {
|
||||||
|
toast(error.message);
|
||||||
|
} finally {
|
||||||
|
room.playBusy = false;
|
||||||
|
paintRoom(room);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nothing to optimistically flip the way play/pause does -- the panel
|
||||||
|
cannot guess which song comes up -- so it looks again once HEOS has
|
||||||
|
moved on, rather than showing the old song until the next poll. */
|
||||||
|
async function skipTrack(key, direction) {
|
||||||
|
const room = rooms[key];
|
||||||
|
if (!room.available) return;
|
||||||
|
try {
|
||||||
|
await api('/api/skip', { target: key, direction });
|
||||||
|
setTimeout(refresh, 1000);
|
||||||
|
} catch (error) {
|
||||||
|
toast(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyJoined(joined) {
|
||||||
|
Object.values(rooms).forEach((room) => {
|
||||||
|
room.grouped = joined.includes(room.key);
|
||||||
|
paintRoom(room);
|
||||||
|
});
|
||||||
|
paintGrouping();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Spotify: one button per account, asking the card's own Connect
|
||||||
|
receiver to resume that account, instead of always connecting to it
|
||||||
|
from the Spotify app -------------------------------------------------- */
|
||||||
|
els('.spotify').forEach((button) => {
|
||||||
|
button.addEventListener('click', () => resumeSpotify(button));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function resumeSpotify(button) {
|
||||||
|
const { target, account } = button.dataset;
|
||||||
|
const who = button.querySelector('span').textContent;
|
||||||
|
button.disabled = true;
|
||||||
|
try {
|
||||||
|
const data = await api('/api/spotify/resume', { target, account });
|
||||||
|
toast(`Resuming ${who}'s Spotify on ${data.device}`, 'ok');
|
||||||
|
const room = rooms[target];
|
||||||
|
room.spotifyAccount = account; // show it straight away; the refresh below confirms it
|
||||||
|
paintRoom(room);
|
||||||
|
// HEOS takes a moment to notice the new stream; look again once it
|
||||||
|
// has, so play/pause turns up without waiting for the next poll.
|
||||||
|
setTimeout(refresh, 1500);
|
||||||
|
} catch (error) {
|
||||||
|
toast(error.message);
|
||||||
|
} finally {
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- the input picker -------------------------------------------------- */
|
||||||
|
ui.inputButton.addEventListener('click', openSheet);
|
||||||
|
el('[data-role="scrim"]').addEventListener('click', closeSheet);
|
||||||
|
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');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ui.options.innerHTML = '';
|
||||||
|
inputs.forEach((source) => {
|
||||||
|
const option = document.createElement('button');
|
||||||
|
option.className = 'option';
|
||||||
|
option.setAttribute('aria-current', String(currentInput && currentInput.code === 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);
|
||||||
|
});
|
||||||
|
ui.sheet.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSheet() { ui.sheet.hidden = true; }
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- state ------------------------------------------------------------- */
|
||||||
|
function render(state) {
|
||||||
|
(state.rooms || []).forEach((incoming) => {
|
||||||
|
const room = rooms[incoming.key];
|
||||||
|
if (!room) return;
|
||||||
|
room.available = incoming.available;
|
||||||
|
room.grouped = incoming.grouped;
|
||||||
|
// Do not stomp on a volume the user is in the middle of changing.
|
||||||
|
if (!room.taps && !room.inflight && !room.dragging && room.wanted === null) {
|
||||||
|
room.volume = incoming.volume;
|
||||||
|
}
|
||||||
|
if (!room.playBusy) room.playState = incoming.play_state;
|
||||||
|
room.track = incoming.now_playing || null;
|
||||||
|
room.onSpotify = Boolean(incoming.spotify);
|
||||||
|
room.spotifyAccount = incoming.spotify_account || null;
|
||||||
|
paintRoom(room);
|
||||||
|
});
|
||||||
|
|
||||||
|
const avr = state.avr || {};
|
||||||
|
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));
|
||||||
|
avrNowPlaying = avr.now_playing || null;
|
||||||
|
renderTrack(avrNowPlaying, avrTrack);
|
||||||
|
paintGrouping(); // after avrNowPlaying, which decides where the AVR's card sits
|
||||||
|
|
||||||
|
const problems = state.errors || [];
|
||||||
|
ui.foot.textContent = problems.length ? problems[0] : (state.demo ? 'demo mode — no real speakers' : '');
|
||||||
|
ui.foot.classList.toggle('bad', problems.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let refreshing = false;
|
||||||
|
async function refresh() {
|
||||||
|
if (refreshing) return;
|
||||||
|
refreshing = true;
|
||||||
|
try {
|
||||||
|
render(await api('/api/state'));
|
||||||
|
} catch (error) {
|
||||||
|
ui.foot.textContent = error.message;
|
||||||
|
ui.foot.classList.add('bad');
|
||||||
|
} finally {
|
||||||
|
refreshing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function busy() {
|
||||||
|
return sheetOpen()
|
||||||
|
|| seeking(avrTrack)
|
||||||
|
|| Object.values(rooms).some((r) => r.taps || r.inflight || r.dragging || r.busy || r.playBusy || seeking(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
if (document.visibilityState === 'visible' && !busy()) refresh();
|
||||||
|
}, POLL_MS);
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.visibilityState === 'visible') refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.values(rooms).forEach(paintRoom);
|
||||||
|
paintGrouping();
|
||||||
|
refresh();
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 5.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,27 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox=".33 -.05 775.27 799.83">
|
||||||
|
<title>HEOS</title>
|
||||||
|
<defs>
|
||||||
|
<path id="kiwi-feet" d="M133.7 426.1C132.4 425.6 130.4 424.2 129.4 422.9C127.3 420.3 126.8 418.3 127.2 413.1C127.7 406.9 127.0 405.9 122.8 406.7C121.7 406.8 119.5 407.2 117.8 407.5C116.0 407.8 110.9 408.9 106.4 410.0C92.1 413.4 89.5 413.8 83.6 413.9C78.8 413.9 77.6 413.6 75.7 411.8C74.4 410.5 73.9 409.2 74.3 407.9C75.0 405.3 81.1 399.9 87.0 396.7C90.5 394.8 91.9 393.8 92.2 393.1C92.6 392.3 92.4 392.0 90.9 390.7C89.3 389.3 88.6 387.8 89.0 386.6C89.7 384.5 92.3 382.5 96.0 381.4C98.6 380.6 100.4 380.6 102.6 381.4C104.6 382.1 106.2 382.2 110.7 381.6C114.5 381.2 115.6 380.8 117.2 379.6C119.1 378.2 119.5 378.2 125.4 378.0C133.5 377.8 133.9 377.4 132.2 371.0C131.4 367.6 131.4 366.9 131.9 365.1C132.4 363.4 132.3 361.4 131.7 359.3C130.7 355.9 129.7 354.5 127.4 353.1C125.6 352.1 125.0 351.5 125.0 350.7C125.0 349.8 126.2 348.1 127.6 346.9C131.9 343.0 136.5 343.5 141.6 348.3C145.8 352.3 146.2 354.1 142.9 355.0C140.1 355.7 139.9 356.7 141.3 360.8C142.6 364.7 143.0 365.4 144.6 367.2C146.5 369.4 146.5 369.3 146.7 383.1C146.9 389.7 147.1 396.1 147.3 397.4C147.7 400.4 147.4 410.1 146.9 413.2C145.8 419.4 143.1 423.7 139.0 425.8C137.6 426.5 135.0 426.7 133.7 426.1ZM175.0 426.0C172.5 425.3 168.4 420.8 166.4 416.8C163.9 411.9 163.7 410.1 163.6 396.8C163.5 390.6 163.4 384.2 163.4 382.5C163.4 380.7 163.6 378.1 163.8 376.2C164.9 368.6 165.1 368.1 167.0 366.5C168.5 365.2 169.1 364.1 170.5 359.9C172.0 355.3 171.8 354.5 169.0 353.6C165.5 352.5 166.0 350.8 170.9 346.1C173.7 343.4 175.2 342.4 177.3 342.0C180.0 341.5 182.3 342.3 184.5 344.5C186.8 346.8 186.7 348.1 183.9 350.1C181.5 351.9 180.6 353.3 178.3 358.9C176.3 363.8 176.3 363.9 177.4 367.7C177.6 368.5 177.8 369.4 177.8 369.7C177.8 370.8 178.5 372.2 179.4 372.6C180.1 373.0 180.6 373.1 183.3 372.9C196.2 372.2 197.4 372.3 207.2 375.5C214.7 378.0 218.3 379.8 220.4 382.1C223.5 385.4 221.6 388.8 216.5 389.4C212.5 389.8 212.2 390.8 215.2 393.6C221.4 399.3 225.7 404.3 226.3 406.4C226.9 408.5 226.0 411.4 224.4 412.6C222.4 414.0 218.3 412.8 206.2 407.4C196.4 403.1 193.4 401.9 187.2 400.0C180.9 398.0 180.6 397.9 179.6 398.3C177.2 399.0 177.3 402.3 179.8 410.8C182.4 419.7 182.4 421.7 179.3 424.5C177.8 426.0 176.4 426.4 175.0 426.0Z"/>
|
||||||
|
<path id="kiwi-body" d="M142.4 354.8C142.0 354.8 140.2 354.4 138.4 354.1C136.7 353.8 133.6 353.4 131.6 353.2C120.7 352.4 110.2 349.0 98.1 342.5C76.1 330.5 57.4 307.3 45.1 277.0C39.1 262.2 36.1 246.4 36.2 229.2C36.2 202.9 41.5 177.3 52.7 149.1C60.1 130.6 74.7 104.4 85.8 89.6C92.1 81.2 95.4 77.6 106.3 66.5C119.6 53.1 121.5 51.6 136.0 43.4C144.9 38.4 147.2 37.4 153.2 35.8C168.3 31.6 177.1 31.2 186.7 34.3C193.0 36.3 198.6 38.7 202.4 40.9C211.7 46.3 220.8 53.5 229.2 62.0C237.3 70.1 239.1 71.1 248.8 73.4C265.3 77.2 286.4 84.4 318.4 97.3C350.6 110.2 362.2 116.1 379.1 128.2C388.2 134.7 391.9 138.2 391.4 139.7C391.0 141.0 388.9 140.6 380.5 137.3C373.1 134.5 369.4 133.4 347.9 127.9C328.0 122.9 322.6 121.6 311.1 119.5C269.7 111.9 231.6 109.0 206.1 111.4C194.1 112.5 192.9 113.0 176.8 123.6C171.6 127.0 168.7 130.8 167.3 136.0C166.7 138.3 166.7 143.7 167.3 146.0C169.4 153.7 177.5 162.7 195.4 177.4C241.8 215.4 254.2 235.4 250.6 266.5C248.8 282.8 243.4 297.4 234.1 311.6C230.5 317.1 227.8 320.5 222.6 325.6C210.9 337.4 199.4 344.5 182.1 350.5C180.5 351.1 178.0 352.1 176.5 352.8C172.2 354.8 173.2 354.7 157.3 354.8C149.5 354.9 142.8 354.9 142.4 354.8Z"/>
|
||||||
|
<path id="kiwi-beak" d="M389.2 140.3C388.1 140.1 385.4 139.2 380.2 137.2C373.0 134.4 370.4 133.6 347.4 127.8C317.4 120.1 299.4 116.8 268.8 113.3C240.2 109.9 213.7 109.6 198.0 112.4C193.6 113.1 193.1 112.9 191.7 109.1C190.3 105.0 190.7 102.0 193.0 98.9C193.5 98.3 196.3 95.3 199.2 92.2C203.5 87.8 205.2 85.8 207.3 82.9C208.7 80.8 210.5 78.5 211.1 77.8C211.8 77.0 213.3 74.9 214.5 73.1C216.2 70.6 217.5 69.1 220.3 66.4C224.5 62.2 224.9 62.0 227.3 62.6C230.3 63.4 231.3 64.0 233.8 67.1C237.1 71.1 235.9 70.5 248.1 73.3C264.4 76.9 285.8 84.2 318.4 97.3C350.6 110.2 362.2 116.1 379.1 128.2C388.6 135.0 392.2 138.5 391.3 139.9C391.1 140.3 390.1 140.5 389.2 140.3Z"/>
|
||||||
|
<!-- Cuts the kiwi, plus a transparent ring around it, out of the bars -->
|
||||||
|
<mask id="kiwi-gap" maskUnits="userSpaceOnUse" x="0" y="-1" width="777" height="802">
|
||||||
|
<rect x="0" y="-1" width="777" height="802" fill="#fff"/>
|
||||||
|
<path transform="matrix(1.1963 0 0 1.1963 208.26 125.61)" fill="#000" stroke="#000" stroke-width="8" stroke-linejoin="round" d="M139.5 456.6C130.1 456.2 126.1 455.5 100.2 449.6C75.7 444.0 71.5 442.8 65.8 440.1C48.9 432.1 41.1 414.7 46.7 397.8C47.7 394.5 49.1 391.7 51.7 387.0C57.3 376.9 59.0 371.8 59.0 365.0C59.0 356.4 56.2 350.1 47.3 339.0C33.2 321.2 25.0 307.4 17.4 288.6C0.9 247.8 2.4 197.8 21.9 145.6C33.2 115.3 52.1 82.2 70.0 61.2C76.4 53.8 90.4 39.4 96.4 34.2C108.2 24.0 126.2 13.4 139.8 8.6C159.8 1.7 178.0 0.7 195.1 5.6C210.9 10.2 222.9 17.0 240.8 31.5C253.5 41.9 256.0 43.3 269.6 47.6C283.6 52.1 287.1 53.2 293.4 55.4C306.0 59.8 326.1 67.6 341.2 74.0C365.1 83.9 380.5 92.1 395.7 103.0C409.2 112.7 415.9 120.3 418.9 129.3C426.1 150.8 409.1 171.8 386.2 169.5C380.9 169.0 378.4 168.3 366.5 164.5C352.6 160.1 327.7 153.7 311.1 150.4C288.5 145.9 279.3 145.4 271.0 148.4C256.8 153.5 249.0 168.3 252.7 183.0C254.1 188.4 255.8 191.6 262.4 201.4C268.5 210.4 271.6 216.1 274.5 223.0C281.6 240.4 283.0 260.0 278.5 282.0C275.0 299.1 268.2 314.7 257.0 331.5C248.4 344.3 246.5 349.5 246.8 358.6C247.0 363.1 247.4 365.2 249.6 373.0C253.3 386.0 255.5 396.8 255.9 404.6C256.6 418.2 250.8 430.2 240.1 437.3C234.2 441.3 230.5 442.6 207.5 449.0C188.8 454.2 184.7 455.1 177.6 455.9C172.3 456.5 148.1 456.9 139.5 456.6Z"/>
|
||||||
|
</mask>
|
||||||
|
</defs>
|
||||||
|
<g fill="#fff" mask="url(#kiwi-gap)">
|
||||||
|
<path d="m653.17 350.24v102.24c22.53 0 40.76-18.33 40.76-40.89v-20.45c0-22.57-18.23-40.9-40.76-40.9zm81.6-61.28v224.8c22.52 0 40.83-18.27 40.83-40.9v-143c0-22.63-18.31-40.9-40.83-40.9zm-652.84 102.18v20.45c0 22.56 18.23 40.89 40.83 40.89v-102.24c-22.6 0-40.83 18.33-40.83 40.9zm-81.6-61.28v143c0 22.63 18.24 40.9 40.76 40.9v-224.8c-22.52 0-40.76 18.27-40.76 40.9z"/>
|
||||||
|
<path d="m163.52 248.06v306.53c0 45.13 36.55 81.73 81.6 81.73v-469.99c-45.05 0-81.6 36.61-81.6 81.73z"/>
|
||||||
|
<path d="m530.74 166.33v469.99c45.05 0 81.6-36.6 81.6-81.73v-306.53c0-45.12-36.55-81.73-81.6-81.73z"/>
|
||||||
|
<path d="m428.73 431.97h-81.6c-22.52 0-40.83 18.33-40.83 40.89v245.26c0 45.12 36.54 81.66 81.66 81.66 45.06 0 81.6-36.54 81.6-81.66v-245.26c0-22.56-18.31-40.89-40.83-40.89z"/>
|
||||||
|
<path d="m428.73 367.76h-81.6c-22.52 0-40.83-18.34-40.83-40.9v-245.25c0-45.13 36.54-81.66 81.66-81.66 45.06 0 81.6 36.53 81.6 81.66v245.25c0 22.56-18.31 40.9-40.83 40.9z"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(1.1963 0 0 1.1963 208.26 125.61)" stroke="#fff" stroke-width="7" stroke-linejoin="round">
|
||||||
|
<use href="#kiwi-feet" fill="#fff"/>
|
||||||
|
<use href="#kiwi-body" fill="#fff"/>
|
||||||
|
<use href="#kiwi-beak" fill="#fff"/>
|
||||||
|
<circle cx="178.8" cy="66.8" r="18" fill="#000" stroke="none"/>
|
||||||
|
<circle cx="183.8" cy="59.3" r="6" fill="#fff" stroke="none"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 6.9 KiB |
@@ -0,0 +1,378 @@
|
|||||||
|
/* A remote lives in the dark next to a TV, so the panel is dark too --
|
||||||
|
and every control is sized for a thumb, not a cursor. */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #0a0d14;
|
||||||
|
--card: #151a25;
|
||||||
|
--edge: #232b3b;
|
||||||
|
--raised: #29334a;
|
||||||
|
--ink: #eef2f9;
|
||||||
|
--muted: #8d98ad;
|
||||||
|
--accent: #5b8def;
|
||||||
|
--live: #3ddc97;
|
||||||
|
--warn: #ff7a6b;
|
||||||
|
--radius: 22px;
|
||||||
|
--gutter: 16px; /* from the screen's side to a card, and from one card to the next */
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
/* Must beat every display rule below it. `hidden` is a plain UA style, so
|
||||||
|
any author rule -- .sheet-wrap's display:flex, say -- silently wins, and
|
||||||
|
an overlay that never hides sits over the whole panel eating taps. */
|
||||||
|
[hidden] { display: none !important; }
|
||||||
|
|
||||||
|
html { background: var(--bg); }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
font: 16px/1.4 -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", system-ui, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
user-select: none;
|
||||||
|
overscroll-behavior-y: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Every icon is a Font Awesome glyph: a solid shape, filled, never stroked. */
|
||||||
|
svg { width: 22px; height: 22px; fill: currentColor; }
|
||||||
|
|
||||||
|
.app {
|
||||||
|
max-width: 520px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: max(12px, env(safe-area-inset-top)) max(var(--gutter), env(safe-area-inset-right))
|
||||||
|
max(24px, env(safe-area-inset-bottom)) max(var(--gutter), env(safe-area-inset-left));
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--gutter);
|
||||||
|
}
|
||||||
|
/* A playing card goes to the top. The rest stay in markup order (rooms as
|
||||||
|
room_keys lists them, then the AVR), because items with the same order
|
||||||
|
keep their place, so playing cards keep that order among themselves too.
|
||||||
|
Only the order changes, not the DOM, so a knob held mid-drag stays put. */
|
||||||
|
.app > .on-top { order: -1; }
|
||||||
|
|
||||||
|
/* --- header ---------------------------------------------------------- */
|
||||||
|
.top { order: -2; display: flex; align-items: center; justify-content: center; padding: 6px 4px 0; }
|
||||||
|
.top h1 { margin: 0; line-height: 0; }
|
||||||
|
.top .logo { height: 34px; width: auto; display: block; }
|
||||||
|
|
||||||
|
/* --- cards ----------------------------------------------------------- */
|
||||||
|
.card {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--edge);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.card.offline { opacity: .5; }
|
||||||
|
/* A tint, not a repaint -- it should read at a glance without competing
|
||||||
|
with the song title right above it. A group plays one thing, so it is the
|
||||||
|
host's card that lights up, not the rooms merged into it. */
|
||||||
|
.card.room.playing,
|
||||||
|
.card.source.playing {
|
||||||
|
background: linear-gradient(135deg,
|
||||||
|
color-mix(in srgb, var(--live) 25%, var(--card)) 0%,
|
||||||
|
var(--card) 70%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
.card-head h2 .room-icon { flex: 0 0 auto; height: 20px; width: auto; fill: currentColor; }
|
||||||
|
.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;
|
||||||
|
color: var(--muted); background: var(--raised);
|
||||||
|
padding: 4px 9px; border-radius: 999px;
|
||||||
|
}
|
||||||
|
.pill.on { color: var(--live); }
|
||||||
|
|
||||||
|
/* --- now playing, between the room's name and its volume --------------- */
|
||||||
|
.now-playing {
|
||||||
|
display: flex; align-items: center; gap: 16px; min-width: 0;
|
||||||
|
}
|
||||||
|
.cover {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 62px; height: 62px;
|
||||||
|
border-radius: 13px;
|
||||||
|
object-fit: cover;
|
||||||
|
background: var(--raised);
|
||||||
|
}
|
||||||
|
/* A film's jacket keeps its own portrait shape rather than being cropped
|
||||||
|
square like an album's. */
|
||||||
|
.cover.poster { width: 50px; height: 75px; border-radius: 9px; }
|
||||||
|
.track { display: flex; flex-direction: column; min-width: 0; }
|
||||||
|
.track span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.song { font-size: 20px; font-weight: 550; }
|
||||||
|
.artist { font-size: 17px; color: var(--muted); }
|
||||||
|
|
||||||
|
/* Doubles as the line between the song and the volume -- it goes with the
|
||||||
|
block, when nothing is playing. Muted throughout, so it never competes
|
||||||
|
with the volume meter below it; the cursor only shows once a position is
|
||||||
|
actually known (an AVR input or a stream with no duration never gets one). */
|
||||||
|
.progress { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.progress-line { position: relative; flex: 1; height: 3px; border-radius: 999px; background: #0c111b; }
|
||||||
|
.progress-fill {
|
||||||
|
display: block; height: 100%; width: calc(var(--progress, 0) * 1%);
|
||||||
|
border-radius: 999px; background: var(--muted);
|
||||||
|
transition: width .12s ease-out;
|
||||||
|
}
|
||||||
|
.progress-cursor {
|
||||||
|
display: none;
|
||||||
|
position: absolute; top: 50%;
|
||||||
|
left: calc(var(--progress, 0) * 1%);
|
||||||
|
width: 7px; height: 7px; margin-left: -3.5px;
|
||||||
|
border-radius: 50%; background: var(--muted);
|
||||||
|
transform: translateY(-50%);
|
||||||
|
transition: left .12s ease-out;
|
||||||
|
}
|
||||||
|
/* Elapsed rides above the cursor and remaining below it, all three centred
|
||||||
|
on one axis, and the total waits at the end of the line. Near either end a
|
||||||
|
label stops half its own width (--half, measured in app.js) short of it,
|
||||||
|
so it never hangs off the card. */
|
||||||
|
.progress-time,
|
||||||
|
.progress-total {
|
||||||
|
display: none;
|
||||||
|
font-size: 12px; line-height: 1; color: var(--muted);
|
||||||
|
font-variant-numeric: tabular-nums; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.progress-time {
|
||||||
|
position: absolute;
|
||||||
|
left: clamp(var(--half, 0px), calc(var(--progress, 0) * 1%), calc(100% - var(--half, 0px)));
|
||||||
|
transform: translateX(-50%);
|
||||||
|
transition: left .12s ease-out;
|
||||||
|
}
|
||||||
|
.progress-time.elapsed { bottom: 9px; }
|
||||||
|
.progress-time.remaining { top: 9px; }
|
||||||
|
.progress.known { padding: 16px 0; } /* room for the labels above and below the cursor */
|
||||||
|
.progress.known .progress-cursor,
|
||||||
|
.progress.known .progress-time,
|
||||||
|
.progress.known .progress-total { display: block; }
|
||||||
|
|
||||||
|
/* Where the player can seek, the cursor and both times are one handle, with
|
||||||
|
a thumb-sized grip around a 7px dot. pan-y leaves a vertical swipe to the
|
||||||
|
page, so scrolling past never seeks; only a sideways drag is ours. Held,
|
||||||
|
it lights up and follows the finger without easing after it. */
|
||||||
|
.progress.seekable .progress-cursor,
|
||||||
|
.progress.seekable .progress-time { cursor: grab; touch-action: pan-y; }
|
||||||
|
.progress.seekable .progress-cursor::before { content: ''; position: absolute; inset: -16px -14px; }
|
||||||
|
.progress.dragging .progress-cursor,
|
||||||
|
.progress.dragging .progress-time { transition: none; cursor: grabbing; }
|
||||||
|
.progress.dragging .progress-cursor { background: var(--ink); transform: translateY(-50%) scale(1.6); }
|
||||||
|
.progress.dragging .progress-time { color: var(--ink); }
|
||||||
|
|
||||||
|
/* --- volume ---------------------------------------------------------- */
|
||||||
|
.volume { display: flex; align-items: center; gap: 14px; }
|
||||||
|
|
||||||
|
.step {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 78px; height: 62px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: var(--raised);
|
||||||
|
display: grid; place-items: center;
|
||||||
|
}
|
||||||
|
.step svg { width: 20px; height: 20px; }
|
||||||
|
.step:active { background: var(--accent); transform: scale(.96); }
|
||||||
|
.step:disabled { opacity: .4; }
|
||||||
|
|
||||||
|
.meter { position: relative; flex: 1; height: 8px; border-radius: 999px; background: #0c111b; }
|
||||||
|
.meter i {
|
||||||
|
display: block; height: 100%; width: calc(var(--level, 0) * 1%);
|
||||||
|
border-radius: 999px; background: var(--accent);
|
||||||
|
transition: width .12s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The level rides the bar. It travels the track less its own width, so it
|
||||||
|
never hangs off either end over the buttons -- and the fill's end is
|
||||||
|
always somewhere underneath it. */
|
||||||
|
.knob {
|
||||||
|
position: absolute; top: 50%;
|
||||||
|
left: calc((100% - 46px) * var(--level, 0) / 100);
|
||||||
|
width: 46px; height: 30px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
display: grid; place-items: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--accent); color: #fff;
|
||||||
|
box-shadow: 0 0 0 3px var(--card);
|
||||||
|
font-size: 14px; font-weight: 640; font-variant-numeric: tabular-nums;
|
||||||
|
transition: left .12s ease-out;
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none; /* the drag is ours, not a page scroll */
|
||||||
|
}
|
||||||
|
/* A thumb-sized grip around a knob only 30px tall. A press on the
|
||||||
|
pseudo-element lands on the knob itself. */
|
||||||
|
.knob::before { content: ''; position: absolute; inset: -12px -8px; }
|
||||||
|
|
||||||
|
/* Under a finger the knob has to keep up, not ease after it. */
|
||||||
|
.meter.dragging i,
|
||||||
|
.meter.dragging .knob { transition: none; }
|
||||||
|
.meter.dragging .knob { cursor: grabbing; transform: translateY(-50%) scale(1.12); }
|
||||||
|
|
||||||
|
/* --- previous / play-pause / next, centred ----------------------------- */
|
||||||
|
.actions { display: flex; align-items: stretch; justify-content: center; gap: 10px; }
|
||||||
|
|
||||||
|
.transport {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 66px; height: 62px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: var(--raised);
|
||||||
|
display: grid; place-items: center;
|
||||||
|
}
|
||||||
|
.transport:active { background: var(--accent); transform: scale(.97); }
|
||||||
|
.transport:disabled { opacity: .5; }
|
||||||
|
.transport svg { width: 19px; height: 19px; }
|
||||||
|
|
||||||
|
/* Which icon shows is a class on the button, not `hidden` on the svg:
|
||||||
|
`hidden` is an HTMLElement property and SVGElement does not inherit it,
|
||||||
|
so svg.hidden = true sets a JS expando and styles nothing. */
|
||||||
|
.transport .icon-pause { display: none; }
|
||||||
|
.transport.playing .icon-play { display: none; }
|
||||||
|
.transport.playing .icon-pause { display: block; }
|
||||||
|
|
||||||
|
/* --- join / leave the AVR, between the Spotify buttons ----------------- */
|
||||||
|
/* Styled like the .spotify buttons it sits between -- same height, corner,
|
||||||
|
type and full-strength ink -- but sized to its own label instead of
|
||||||
|
sharing the row equally. Like them, it says "not the active one" with a
|
||||||
|
plain background rather than by dimming itself, so the three read as
|
||||||
|
equal choices. */
|
||||||
|
.toggle {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
height: 50px; padding: 0 14px; border-radius: 16px;
|
||||||
|
background: var(--raised);
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: 9px;
|
||||||
|
font-size: 15px; font-weight: 550; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.toggle .cinema { flex: 0 0 auto; color: #fff; }
|
||||||
|
.toggle .leave { display: none; flex: 0 0 auto; color: #fff; }
|
||||||
|
.toggle[aria-pressed="true"] { background: var(--accent); color: #fff; }
|
||||||
|
.toggle:active { transform: scale(.985); }
|
||||||
|
.toggle:disabled { opacity: .5; }
|
||||||
|
.toggle.busy { opacity: .6; }
|
||||||
|
|
||||||
|
/* --- Spotify: one resume button per account, around the host toggle ----- */
|
||||||
|
.spotify-row { display: flex; gap: 10px; }
|
||||||
|
.spotify {
|
||||||
|
flex: 1; min-width: 0;
|
||||||
|
height: 50px; border-radius: 16px;
|
||||||
|
background: var(--raised);
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: 9px;
|
||||||
|
font-size: 15px; font-weight: 550;
|
||||||
|
}
|
||||||
|
.spotify svg { flex: 0 0 auto; color: #fff; }
|
||||||
|
.spotify span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.spotify:active { transform: scale(.985); }
|
||||||
|
.spotify:disabled { opacity: .5; }
|
||||||
|
/* The account playing on this room right now. */
|
||||||
|
.spotify[aria-pressed="true"] { border: 2px solid var(--ink); }
|
||||||
|
|
||||||
|
/* --- source card ------------------------------------------------------ */
|
||||||
|
.source-button {
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
width: 100%; min-height: 50px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: var(--raised);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.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); }
|
||||||
|
|
||||||
|
/* --- rooms merged into the host's card --------------------------------- */
|
||||||
|
.joined { display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
|
||||||
|
/* In here a room is an outline rather than a card: its border is what says
|
||||||
|
it is playing with the AVR, and the fill -- a tint too, when it plays --
|
||||||
|
belongs to the group's card instead. Same specificity as
|
||||||
|
.card.room.playing, so being later is what wins. */
|
||||||
|
.joined .card.room { background: none; border-color: var(--ink); border-radius: 18px; padding: 14px; gap: 12px; }
|
||||||
|
.joined .card-head h2 { font-size: 15px; font-weight: 550; color: var(--muted); }
|
||||||
|
.joined .cover { width: 52px; height: 52px; border-radius: 10px; }
|
||||||
|
.joined .song { font-size: 18px; }
|
||||||
|
.joined .step { height: 54px; }
|
||||||
|
/* In here the Spotify buttons hide and the toggle says "Detach", so it takes
|
||||||
|
their place and their look: the whole row, a plain background rather than
|
||||||
|
the pressed accent, and an exit icon where the host's would only muddle. */
|
||||||
|
.joined .toggle { flex: 1; min-width: 0; }
|
||||||
|
.joined .toggle[aria-pressed="true"] { background: var(--raised); color: inherit; }
|
||||||
|
.joined .toggle .cinema { display: none; }
|
||||||
|
.joined .toggle .leave { display: block; }
|
||||||
|
|
||||||
|
/* --- misc ------------------------------------------------------------- */
|
||||||
|
.wide { width: 100%; height: 50px; border-radius: 16px; font-size: 15px; }
|
||||||
|
.ghost { background: transparent; border: 1px solid var(--edge); color: var(--muted); }
|
||||||
|
.ghost:active { background: var(--card); color: var(--ink); }
|
||||||
|
|
||||||
|
.foot { margin: 2px 4px 0; min-height: 18px; font-size: 12px; color: var(--muted); text-align: center; }
|
||||||
|
.foot.bad { color: var(--warn); }
|
||||||
|
|
||||||
|
/* --- input sheet ------------------------------------------------------ */
|
||||||
|
.sheet-wrap { position: fixed; inset: 0; z-index: 10; display: flex; flex-direction: column; justify-content: flex-end; }
|
||||||
|
.scrim { position: absolute; inset: 0; background: rgba(4, 6, 11, .6); backdrop-filter: blur(3px); }
|
||||||
|
|
||||||
|
.sheet {
|
||||||
|
position: relative;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--edge);
|
||||||
|
border-radius: 28px 28px 0 0;
|
||||||
|
padding: 10px 16px calc(16px + env(safe-area-inset-bottom));
|
||||||
|
max-height: 82vh;
|
||||||
|
display: flex; flex-direction: column; gap: 12px;
|
||||||
|
animation: rise .22s cubic-bezier(.22, .68, .3, 1);
|
||||||
|
}
|
||||||
|
@keyframes rise { from { transform: translateY(14%); opacity: .4; } }
|
||||||
|
|
||||||
|
.grabber { width: 38px; height: 4px; border-radius: 999px; background: var(--edge); margin: 2px auto 4px; }
|
||||||
|
.sheet h3 { margin: 0 4px; font-size: 14px; font-weight: 600; color: var(--muted); }
|
||||||
|
|
||||||
|
.options { display: flex; flex-direction: column; gap: 8px; overflow-y: auto; -webkit-overflow-scrolling: touch; }
|
||||||
|
.option {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 10px;
|
||||||
|
min-height: 58px; padding: 0 16px;
|
||||||
|
border-radius: 16px; background: var(--raised);
|
||||||
|
font-size: 17px; text-align: left;
|
||||||
|
}
|
||||||
|
.option:active { transform: scale(.985); }
|
||||||
|
.option[aria-current="true"] { background: var(--accent); color: #fff; }
|
||||||
|
.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 ------------------------------------------------------------ */
|
||||||
|
.toast {
|
||||||
|
position: fixed; left: 50%; transform: translateX(-50%);
|
||||||
|
bottom: calc(22px + env(safe-area-inset-bottom));
|
||||||
|
z-index: 20; max-width: 90vw;
|
||||||
|
padding: 12px 16px; border-radius: 14px;
|
||||||
|
background: #2b1f24; border: 1px solid #52303a; color: #ffd9d4;
|
||||||
|
font-size: 14px; box-shadow: 0 10px 30px rgba(0, 0, 0, .45);
|
||||||
|
}
|
||||||
|
.toast.ok { background: #172a21; border-color: #2a5540; color: #c9f7df; }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
* { animation: none !important; transition: none !important; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" data-step="{{ step }}" data-host="{{ host.label }}" data-base="{{ url_for('index') }}">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no">
|
||||||
|
<title>{{ app_name }}</title>
|
||||||
|
|
||||||
|
<!-- Added to the iOS home screen, this opens full-screen with no browser chrome. -->
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="{{ app_name }}">
|
||||||
|
<meta name="theme-color" content="#0a0d14">
|
||||||
|
|
||||||
|
<link rel="manifest" href="{{ url_for('manifest') }}">
|
||||||
|
<link rel="apple-touch-icon" href="{{ url_for('static', filename='icon-180.png') }}">
|
||||||
|
<link rel="icon" href="{{ url_for('static', filename='logo.svg') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='panel.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
{% macro host_toggle() -%}
|
||||||
|
<button class="toggle" data-role="group" aria-pressed="false">
|
||||||
|
<svg class="cinema" viewBox="0 0 512 512" aria-hidden="true"><path d="M256 512a256 256 0 1 0 0-512 256 256 0 1 0 0 512zM128 192c0-17.7 14.3-32 32-32l128 0c17.7 0 32 14.3 32 32l0 38.4 61-36.6c1.9-1.2 4.2-1.8 6.4-1.8 6.9 0 12.5 5.6 12.5 12.5l0 102.9c0 6.9-5.6 12.5-12.5 12.5-2.3 0-4.5-.6-6.4-1.8l-61-36.6 0 38.4c0 17.7-14.3 32-32 32l-128 0c-17.7 0-32-14.3-32-32l0-128z"/></svg>
|
||||||
|
<svg class="leave" viewBox="0 0 512 512" aria-hidden="true"><path d="M502.6 278.6c12.5-12.5 12.5-32.8 0-45.3l-128-128c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L402.7 224 192 224c-17.7 0-32 14.3-32 32s14.3 32 32 32l210.7 0-73.4 73.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l128-128zM160 96c17.7 0 32-14.3 32-32s-14.3-32-32-32L96 32C43 32 0 75 0 128L0 384c0 53 43 96 96 96l64 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-64 0c-17.7 0-32-14.3-32-32l0-256c0-17.7 14.3-32 32-32l64 0z"/></svg>
|
||||||
|
<span data-role="group-label">Cinema</span>
|
||||||
|
</button>
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{# The Spotify resume buttons with the host toggle in the middle of them. #}
|
||||||
|
{% macro control_row(target, accounts) -%}
|
||||||
|
{% set split = (accounts | length + 1) // 2 %}
|
||||||
|
<div class="spotify-row">
|
||||||
|
{% for account in accounts %}
|
||||||
|
{% if loop.index0 == split %}{{ host_toggle() }}{% endif %}
|
||||||
|
<button class="spotify" data-target="{{ target }}" data-account="{{ account.key }}"
|
||||||
|
aria-label="Resume {{ account.label }}'s Spotify" hidden>
|
||||||
|
<svg viewBox="0 0 496 512" aria-hidden="true"><path d="M248 8C111.1 8 0 119.1 0 256s111.1 248 248 248 248-111.1 248-248S384.9 8 248 8zm100.7 364.9c-4.2 0-6.8-1.3-10.7-3.6-62.4-37.6-135-39.2-206.7-24.5-3.9 1-9 2.6-11.9 2.6-9.7 0-15.8-7.7-15.8-15.8 0-10.3 6.1-15.2 13.6-16.8 81.9-18.1 165.6-16.5 237 26.2 6.1 3.9 9.7 7.4 9.7 16.5s-7.1 15.4-15.2 15.4zm26.9-65.6c-5.2 0-8.7-2.3-12.3-4.2-62.5-37-155.7-51.9-238.6-29.4-4.8 1.3-7.4 2.6-11.9 2.6-10.7 0-19.4-8.7-19.4-19.4s5.2-17.8 15.5-20.7c27.8-7.8 56.2-13.6 97.8-13.6 64.9 0 127.6 16.1 177 45.5 8.1 4.8 11.3 11 11.3 19.7-.1 10.8-8.5 19.5-19.4 19.5zm31-76.2c-5.2 0-8.4-1.3-12.9-3.9-71.2-42.5-198.5-52.7-280.9-29.7-3.6 1-8.1 2.6-12.9 2.6-13.2 0-23.3-10.3-23.3-23.6 0-13.6 8.4-21.3 17.4-23.9 35.2-10.3 74.6-15.2 117.5-15.2 73 0 149.5 15.2 205.4 47.8 7.8 4.5 12.9 10.7 12.9 22.6 0 13.6-11 23.3-23.2 23.3z"/></svg>
|
||||||
|
<span>{{ account.label }}</span>
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
{# Fewer accounts than the midpoint means the loop never reached it. #}
|
||||||
|
{% if accounts | length <= split %}{{ host_toggle() }}{% endif %}
|
||||||
|
</div>
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
<div class="app">
|
||||||
|
<header class="top">
|
||||||
|
<h1><img class="logo" src="{{ url_for('static', filename='logo.svg') }}" alt="{{ app_name }}"></h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% for room in rooms %}
|
||||||
|
<div class="room-slot" data-slot="{{ room.key }}">
|
||||||
|
<section class="card room" data-room="{{ room.key }}">
|
||||||
|
<div class="card-head">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="now-playing" data-role="now-playing" hidden>
|
||||||
|
<img class="cover" data-role="cover" alt="" hidden>
|
||||||
|
<div class="track">
|
||||||
|
<span class="song" data-role="song"></span>
|
||||||
|
<span class="artist" data-role="artist"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="progress" data-role="progress" data-target="{{ room.key }}" hidden>
|
||||||
|
<div class="progress-line">
|
||||||
|
<i data-role="progress-fill"></i>
|
||||||
|
<b class="progress-cursor" data-role="progress-cursor"></b>
|
||||||
|
<span class="progress-time elapsed" data-role="progress-elapsed"></span>
|
||||||
|
<span class="progress-time remaining" data-role="progress-remaining"></span>
|
||||||
|
</div>
|
||||||
|
<span class="progress-total" data-role="progress-total"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="volume">
|
||||||
|
<button class="step" data-delta="-1" aria-label="{{ room.label }}: volume down">
|
||||||
|
<svg viewBox="0 0 448 512" aria-hidden="true"><path d="M432 256c0 17.7-14.3 32-32 32L48 288c-17.7 0-32-14.3-32-32s14.3-32 32-32l352 0c17.7 0 32 14.3 32 32z"/></svg>
|
||||||
|
</button>
|
||||||
|
<div class="meter" data-role="meter"><i></i><b class="knob" data-role="level">—</b></div>
|
||||||
|
<button class="step" data-delta="1" aria-label="{{ room.label }}: volume up">
|
||||||
|
<svg viewBox="0 0 448 512" aria-hidden="true"><path d="M256 80c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 144L48 224c-17.7 0-32 14.3-32 32s14.3 32 32 32l144 0 0 144c0 17.7 14.3 32 32 32s32-14.3 32-32l0-144 144 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-144 0 0-144z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions" data-role="actions" hidden>
|
||||||
|
<button class="transport" data-role="prev" aria-label="{{ room.label }}: previous track">
|
||||||
|
<!-- backward-step: the forward-step glyph below, mirrored -->
|
||||||
|
<svg viewBox="0 0 320 512" aria-hidden="true"><path transform="matrix(-1 0 0 1 320 0)" d="M52.5 440.6c-9.5 7.9-22.8 9.7-34.1 4.4S0 428.4 0 416L0 96C0 83.6 7.2 72.3 18.4 67s24.5-3.6 34.1 4.4l192 160L256 241l0-145c0-17.7 14.3-32 32-32s32 14.3 32 32l0 320c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-145-11.5 9.6-192 160z"/></svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="transport" data-role="play" aria-label="{{ room.label }}: play">
|
||||||
|
<svg class="icon-play" viewBox="0 0 384 512" aria-hidden="true"><path d="M73 39c-14.8-9.1-33.4-9.4-48.5-.9S0 62.6 0 80L0 432c0 17.4 9.4 33.4 24.5 41.9s33.7 8.1 48.5-.9L361 297c14.3-8.7 23-24.2 23-41s-8.7-32.2-23-41L73 39z"/></svg>
|
||||||
|
<svg class="icon-pause" viewBox="0 0 320 512" aria-hidden="true"><path d="M48 64C21.5 64 0 85.5 0 112L0 400c0 26.5 21.5 48 48 48l32 0c26.5 0 48-21.5 48-48l0-288c0-26.5-21.5-48-48-48L48 64zm192 0c-26.5 0-48 21.5-48 48l0 288c0 26.5 21.5 48 48 48l32 0c26.5 0 48-21.5 48-48l0-288c0-26.5-21.5-48-48-48l-32 0z"/></svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="transport" data-role="next" aria-label="{{ room.label }}: next track">
|
||||||
|
<svg viewBox="0 0 320 512" aria-hidden="true"><path d="M52.5 440.6c-9.5 7.9-22.8 9.7-34.1 4.4S0 428.4 0 416L0 96C0 83.6 7.2 72.3 18.4 67s24.5-3.6 34.1 4.4l192 160L256 241l0-145c0-17.7 14.3-32 32-32s32 14.3 32 32l0 320c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-145-11.5 9.6-192 160z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ control_row(room.key, spotify_accounts) }}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<section class="card source">
|
||||||
|
<div class="card-head">
|
||||||
|
<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>
|
||||||
|
<div class="now-playing" data-role="avr-now-playing" hidden>
|
||||||
|
<img class="cover poster" data-role="avr-cover" alt="" hidden>
|
||||||
|
<div class="track">
|
||||||
|
<span class="song" data-role="avr-song"></span>
|
||||||
|
<span class="artist" data-role="avr-artist"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="progress" data-role="avr-progress" data-target="{{ host.key }}" hidden>
|
||||||
|
<div class="progress-line">
|
||||||
|
<i data-role="progress-fill"></i>
|
||||||
|
<b class="progress-cursor" data-role="progress-cursor"></b>
|
||||||
|
<span class="progress-time elapsed" data-role="progress-elapsed"></span>
|
||||||
|
<span class="progress-time remaining" data-role="progress-remaining"></span>
|
||||||
|
</div>
|
||||||
|
<span class="progress-total" data-role="progress-total"></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>
|
||||||
|
|
||||||
|
<div class="joined" data-role="joined" hidden></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p class="foot" data-role="foot"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sheet-wrap" data-role="sheet" hidden>
|
||||||
|
<div class="scrim" data-role="scrim"></div>
|
||||||
|
<div class="sheet" role="dialog" aria-modal="true" aria-label="{{ host.label }} input">
|
||||||
|
<div class="grabber" aria-hidden="true"></div>
|
||||||
|
<h3>{{ host.label }} input</h3>
|
||||||
|
<div class="options" data-role="options"></div>
|
||||||
|
<button class="wide ghost" data-role="sheet-close">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toast" data-role="toast" hidden></div>
|
||||||
|
|
||||||
|
<script src="{{ url_for('static', filename='app.js') }}"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "{{ app_name }}",
|
||||||
|
"short_name": "{{ app_name }}",
|
||||||
|
"start_url": "{{ url_for('index') }}",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#0a0d14",
|
||||||
|
"theme_color": "#0a0d14",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "{{ url_for('static', filename='icon-180.png') }}", "sizes": "180x180", "type": "image/png" },
|
||||||
|
{ "src": "{{ url_for('static', filename='icon-512.png') }}", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"""Stand-in Spotify Web API: just enough of /api/token, .../player/devices,
|
||||||
|
.../player and .../player/seek to test spotify.py against, the same way fakes.py stands in
|
||||||
|
for real HEOS hardware."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSpotify(threading.Thread):
|
||||||
|
"""Issues a fresh access token per refresh, tracks which one is
|
||||||
|
currently valid, and remembers every playback transfer it was asked
|
||||||
|
to make."""
|
||||||
|
|
||||||
|
def __init__(self, devices=None):
|
||||||
|
super().__init__(daemon=True)
|
||||||
|
self.devices_list = devices if devices is not None else [
|
||||||
|
{"id": "dev-1", "name": "Lego Room", "type": "Speaker"},
|
||||||
|
{"id": "dev-2", "name": "Home Cinema", "type": "AVR"},
|
||||||
|
]
|
||||||
|
self.valid_token = None
|
||||||
|
self.tokens_issued = 0
|
||||||
|
self.transfers = [] # every PUT /v1/me/player body
|
||||||
|
self.seeks = [] # every PUT /v1/me/player/seek's position_ms
|
||||||
|
self.reject_refresh = False # simulate a revoked refresh token
|
||||||
|
self.player = None # GET /v1/me/player's body; None is no session (204)
|
||||||
|
|
||||||
|
fake = self
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _send(self, status, payload=None):
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.end_headers()
|
||||||
|
if payload is not None:
|
||||||
|
self.wfile.write(json.dumps(payload).encode())
|
||||||
|
|
||||||
|
def _authorized(self):
|
||||||
|
header = self.headers.get("Authorization", "")
|
||||||
|
return header == f"Bearer {fake.valid_token}" and fake.valid_token is not None
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
if self.path == "/api/token":
|
||||||
|
if fake.reject_refresh:
|
||||||
|
self._send(400, {"error": "invalid_grant",
|
||||||
|
"error_description": "refresh token revoked"})
|
||||||
|
return
|
||||||
|
fake.tokens_issued += 1
|
||||||
|
fake.valid_token = f"token-{fake.tokens_issued}"
|
||||||
|
self._send(200, {"access_token": fake.valid_token, "expires_in": 3600})
|
||||||
|
return
|
||||||
|
self._send(404, {"error": {"message": "not found"}})
|
||||||
|
|
||||||
|
def do_PUT(self):
|
||||||
|
if self.path == "/v1/me/player":
|
||||||
|
if not self._authorized():
|
||||||
|
self._send(401, {"error": {"message": "The access token expired"}})
|
||||||
|
return
|
||||||
|
length = int(self.headers.get("Content-Length", 0))
|
||||||
|
fake.transfers.append(json.loads(self.rfile.read(length) or b"{}"))
|
||||||
|
self.send_response(204)
|
||||||
|
self.end_headers()
|
||||||
|
return
|
||||||
|
if self.path.startswith("/v1/me/player/seek?"):
|
||||||
|
if not self._authorized():
|
||||||
|
self._send(401, {"error": {"message": "The access token expired"}})
|
||||||
|
return
|
||||||
|
query = parse_qs(urlparse(self.path).query)
|
||||||
|
fake.seeks.append(int(query["position_ms"][0]))
|
||||||
|
# The real one answers some player commands with a
|
||||||
|
# non-JSON body rather than an empty 204.
|
||||||
|
body = b"a1b2c3d4"
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/plain")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
return
|
||||||
|
self._send(404, {"error": {"message": "not found"}})
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
if self.path == "/v1/me/player/devices":
|
||||||
|
if not self._authorized():
|
||||||
|
self._send(401, {"error": {"message": "The access token expired"}})
|
||||||
|
return
|
||||||
|
self._send(200, {"devices": fake.devices_list})
|
||||||
|
return
|
||||||
|
if self.path == "/v1/me/player":
|
||||||
|
if not self._authorized():
|
||||||
|
self._send(401, {"error": {"message": "The access token expired"}})
|
||||||
|
return
|
||||||
|
if fake.player is None:
|
||||||
|
self.send_response(204)
|
||||||
|
self.end_headers()
|
||||||
|
else:
|
||||||
|
self._send(200, fake.player)
|
||||||
|
return
|
||||||
|
self._send(404, {"error": {"message": "not found"}})
|
||||||
|
|
||||||
|
self.server = HTTPServer(("127.0.0.1", 0), Handler)
|
||||||
|
self.port = self.server.server_port
|
||||||
|
self.start()
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
self.server.serve_forever(poll_interval=0.05)
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.server.shutdown()
|
||||||
|
self.server.server_close()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def base_url(self):
|
||||||
|
return f"http://127.0.0.1:{self.port}"
|
||||||
+165
@@ -0,0 +1,165 @@
|
|||||||
|
"""Stand-in HEOS hardware: just enough of the CLI protocol to test against.
|
||||||
|
|
||||||
|
The grouping rules are the part worth pinning down -- what a set_group
|
||||||
|
call actually does to players already in a group is the kind of thing
|
||||||
|
you do not want to find out by experimenting on the speakers at eleven
|
||||||
|
at night.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
|
||||||
|
|
||||||
|
class FakeHeos(threading.Thread):
|
||||||
|
"""A HEOS CLI server on localhost, with three players.
|
||||||
|
|
||||||
|
"Denon Home 200 L" stands in for the living room's stereo pair --
|
||||||
|
HEOS pairs that kind of In-Room Group at the hardware level, so it
|
||||||
|
is one player, one pid, exactly like any other room.
|
||||||
|
"""
|
||||||
|
|
||||||
|
NAMES = {1: "Home Cinema", 2: "Lego Room", 3: "Denon Home 200 L"}
|
||||||
|
|
||||||
|
# What browse/browse?sid=<AVR pid> reports: HEOS's own list of the
|
||||||
|
# AVR's local inputs, already under whatever names you gave them in
|
||||||
|
# its setup menu -- HEOS carries the renamed labels itself.
|
||||||
|
AVR_INPUTS = [
|
||||||
|
{"name": "Z30 Pro", "mid": "inputs/mediaplayer"},
|
||||||
|
{"name": "Switch", "mid": "inputs/game"},
|
||||||
|
{"name": "LG G5", "mid": "inputs/tvaudio"},
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(daemon=True)
|
||||||
|
self.groups = {} # gid -> pids, leader first
|
||||||
|
self.volumes = {pid: 20 for pid in self.NAMES}
|
||||||
|
self.play_states = {pid: "play" for pid in self.NAMES}
|
||||||
|
self.group_volumes = {}
|
||||||
|
self.now_playing_mid = {1: "inputs/mediaplayer"} # pid -> what get_now_playing_media reports
|
||||||
|
self.now_playing_sid = {} # pid -> its source id; 4 is Spotify
|
||||||
|
self.now_playing_track = {} # pid -> song/artist/image_url fields
|
||||||
|
self.commands = [] # everything we were asked to do
|
||||||
|
self.server = socket.socket()
|
||||||
|
self.server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
self.server.bind(("127.0.0.1", 0))
|
||||||
|
self.server.listen(8)
|
||||||
|
self.port = self.server.getsockname()[1]
|
||||||
|
self.start()
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
conn, _ = self.server.accept()
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
threading.Thread(target=self._serve, args=(conn,), daemon=True).start()
|
||||||
|
|
||||||
|
def _serve(self, conn):
|
||||||
|
buffer = b""
|
||||||
|
with conn:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
chunk = conn.recv(4096)
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
if not chunk:
|
||||||
|
return
|
||||||
|
buffer += chunk
|
||||||
|
while b"\r\n" in buffer:
|
||||||
|
line, buffer = buffer.split(b"\r\n", 1)
|
||||||
|
reply = self.handle(line.decode().strip())
|
||||||
|
conn.sendall(json.dumps(reply).encode() + b"\r\n")
|
||||||
|
|
||||||
|
# -- command handling ------------------------------------------------
|
||||||
|
def handle(self, command: str) -> dict:
|
||||||
|
self.commands.append(command)
|
||||||
|
path, _, query = command[len("heos://"):].partition("?")
|
||||||
|
args = dict(part.split("=", 1) for part in query.split("&") if "=" in part)
|
||||||
|
|
||||||
|
if path == "player/get_players":
|
||||||
|
return self._ok(path, payload=[
|
||||||
|
{"name": name, "pid": pid, "model": "Fake"} for pid, name in self.NAMES.items()
|
||||||
|
])
|
||||||
|
|
||||||
|
if path == "group/get_groups":
|
||||||
|
payload = []
|
||||||
|
for gid, pids in self.groups.items():
|
||||||
|
payload.append({
|
||||||
|
"name": self.NAMES[gid], "gid": gid,
|
||||||
|
"players": [
|
||||||
|
{"name": self.NAMES[pid], "pid": pid,
|
||||||
|
"role": "leader" if pid == gid else "member"}
|
||||||
|
for pid in pids
|
||||||
|
],
|
||||||
|
})
|
||||||
|
return self._ok(path, payload=payload)
|
||||||
|
|
||||||
|
if path == "group/set_group":
|
||||||
|
pids = [int(p) for p in args["pid"].split(",")]
|
||||||
|
for gid in list(self.groups):
|
||||||
|
self.groups[gid] = [p for p in self.groups[gid] if p not in pids]
|
||||||
|
if len(self.groups[gid]) < 2:
|
||||||
|
del self.groups[gid] # HEOS dissolves a group of one
|
||||||
|
if len(pids) > 1:
|
||||||
|
self.groups[pids[0]] = pids
|
||||||
|
self.group_volumes.setdefault(pids[0], 25)
|
||||||
|
return self._ok(path, message=args["pid"])
|
||||||
|
|
||||||
|
if path.endswith("/get_volume"):
|
||||||
|
store, key = self._store(path, args)
|
||||||
|
return self._ok(path, message=f"{key[0]}={key[1]}&level={store[key[1]]}")
|
||||||
|
|
||||||
|
if path.endswith("/set_volume"):
|
||||||
|
store, key = self._store(path, args)
|
||||||
|
store[key[1]] = int(args["level"])
|
||||||
|
return self._ok(path, message=f"{key[0]}={key[1]}&level={args['level']}")
|
||||||
|
|
||||||
|
if path == "player/get_play_state":
|
||||||
|
pid = int(args["pid"])
|
||||||
|
return self._ok(path, message=f"pid={pid}&state={self.play_states[pid]}")
|
||||||
|
|
||||||
|
if path == "player/set_play_state":
|
||||||
|
pid = int(args["pid"])
|
||||||
|
self.play_states[pid] = args["state"]
|
||||||
|
return self._ok(path, message=f"pid={pid}&state={args['state']}")
|
||||||
|
|
||||||
|
if path == "player/get_now_playing_media":
|
||||||
|
pid = int(args["pid"])
|
||||||
|
mid = self.now_playing_mid.get(pid, "")
|
||||||
|
name = next((s["name"] for s in self.AVR_INPUTS if s["mid"] == mid), mid)
|
||||||
|
payload = {"mid": mid, "station": name} if mid else {}
|
||||||
|
if pid in self.now_playing_sid:
|
||||||
|
payload["sid"] = self.now_playing_sid[pid]
|
||||||
|
payload.update(self.now_playing_track.get(pid, {}))
|
||||||
|
return self._ok(path, payload=payload)
|
||||||
|
|
||||||
|
if path == "browse/browse":
|
||||||
|
sid = int(args["sid"])
|
||||||
|
payload = list(self.AVR_INPUTS) if sid == 1 else []
|
||||||
|
return self._ok(path, payload=payload)
|
||||||
|
|
||||||
|
if path == "browse/play_input":
|
||||||
|
pid = int(args["pid"])
|
||||||
|
self.now_playing_mid[pid] = args["input"]
|
||||||
|
return self._ok(path)
|
||||||
|
|
||||||
|
if path.endswith("/toggle_mute") or path == "system/heart_beat":
|
||||||
|
return self._ok(path)
|
||||||
|
|
||||||
|
return {"heos": {"command": path, "result": "fail", "message": "eid=2&text=Not+supported"}}
|
||||||
|
|
||||||
|
def _store(self, path, args):
|
||||||
|
if path.startswith("group/"):
|
||||||
|
gid = int(args["gid"])
|
||||||
|
if gid not in self.groups:
|
||||||
|
raise KeyError(gid)
|
||||||
|
return self.group_volumes, ("gid", gid)
|
||||||
|
return self.volumes, ("pid", int(args["pid"]))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _ok(path, message="", payload=None):
|
||||||
|
reply = {"heos": {"command": path, "result": "success", "message": message}}
|
||||||
|
if payload is not None:
|
||||||
|
reply["payload"] = payload
|
||||||
|
return reply
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
"""Run against the fake hardware in fakes.py:
|
||||||
|
|
||||||
|
python3 -m unittest discover -s tests -t .
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from controller import Controller, stepped_level # noqa: E402
|
||||||
|
from tests.fakes import FakeHeos # noqa: E402
|
||||||
|
|
||||||
|
AVR_PID = 1
|
||||||
|
HOME400_PID = 2
|
||||||
|
LIVING_ROOM_PID = 3 # the Home 200 pair, one pid -- HEOS pairs it at the hardware level
|
||||||
|
|
||||||
|
|
||||||
|
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={
|
||||||
|
"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"},
|
||||||
|
},
|
||||||
|
VOLUME_STEP=5,
|
||||||
|
)
|
||||||
|
return Controller(cfg), heos
|
||||||
|
|
||||||
|
|
||||||
|
class PanelTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.panel, self.heos = build()
|
||||||
|
|
||||||
|
def group_pids(self):
|
||||||
|
return {gid: set(pids) for gid, pids in self.heos.groups.items()}
|
||||||
|
|
||||||
|
# -- resolving ------------------------------------------------------
|
||||||
|
def test_living_room_resolves_to_its_one_pid(self):
|
||||||
|
self.panel.scan()
|
||||||
|
self.assertEqual(self.panel.member_pids("living_room"), [LIVING_ROOM_PID])
|
||||||
|
self.assertEqual(self.panel.member_pids("lego_room"), [HOME400_PID])
|
||||||
|
|
||||||
|
def test_avr_resolves_to_a_player_even_while_it_leads_a_group(self):
|
||||||
|
self.panel.join("lego_room")
|
||||||
|
# HEOS now reports a *group* named "Home Cinema" as well as the player.
|
||||||
|
self.assertEqual(self.panel.member_pids("avr"), [AVR_PID])
|
||||||
|
|
||||||
|
# -- grouping -------------------------------------------------------
|
||||||
|
def test_joining_adds_the_room(self):
|
||||||
|
self.panel.join("living_room")
|
||||||
|
self.assertEqual(self.group_pids(), {AVR_PID: {AVR_PID, LIVING_ROOM_PID}})
|
||||||
|
self.assertEqual(self.panel.joined_keys(), ["living_room"])
|
||||||
|
|
||||||
|
def test_joining_keeps_whoever_is_already_grouped(self):
|
||||||
|
self.panel.join("lego_room")
|
||||||
|
self.panel.join("living_room")
|
||||||
|
self.assertEqual(self.group_pids(), {AVR_PID: {AVR_PID, HOME400_PID, LIVING_ROOM_PID}})
|
||||||
|
self.assertEqual(self.panel.joined_keys(), ["lego_room", "living_room"])
|
||||||
|
|
||||||
|
def test_joining_replays_the_avr_input_over_heos(self):
|
||||||
|
"""A room that has just joined sometimes stays silent until the
|
||||||
|
AVR's input is reselected -- through HEOS's own browse/play_input,
|
||||||
|
the way the HEOS app does it, not the AVR's Telnet port -- so
|
||||||
|
join() pokes it with whatever is already playing."""
|
||||||
|
before = len(self.heos.commands)
|
||||||
|
self.panel.join("lego_room")
|
||||||
|
replays = [c for c in self.heos.commands[before:] if "browse/play_input" in c]
|
||||||
|
self.assertEqual(len(replays), 1)
|
||||||
|
self.assertIn(f"pid={AVR_PID}", replays[0])
|
||||||
|
self.assertIn("input=inputs/mediaplayer", replays[0])
|
||||||
|
|
||||||
|
def test_leaving_ungroups_the_room(self):
|
||||||
|
self.panel.join("living_room")
|
||||||
|
self.panel.leave("living_room")
|
||||||
|
self.assertEqual(self.group_pids(), {}) # AVR alone, no group left
|
||||||
|
self.assertEqual(self.panel.joined_keys(), [])
|
||||||
|
|
||||||
|
def test_leaving_one_room_does_not_disturb_the_other(self):
|
||||||
|
self.panel.join("lego_room")
|
||||||
|
self.panel.join("living_room")
|
||||||
|
before = [c for c in self.heos.commands if "set_group" in c]
|
||||||
|
self.panel.leave("lego_room")
|
||||||
|
after = [c for c in self.heos.commands if "set_group" in c]
|
||||||
|
self.assertEqual(self.group_pids(), {AVR_PID: {AVR_PID, LIVING_ROOM_PID}})
|
||||||
|
# Exactly one new set_group: the remaining room is never regrouped,
|
||||||
|
# which is what stops its music restarting.
|
||||||
|
self.assertEqual(len(after) - len(before), 1)
|
||||||
|
|
||||||
|
def test_separate_everything(self):
|
||||||
|
self.panel.set_membership(["lego_room", "living_room"])
|
||||||
|
self.panel.set_membership([])
|
||||||
|
self.assertEqual(self.group_pids(), {})
|
||||||
|
self.assertEqual(self.panel.joined_keys(), [])
|
||||||
|
|
||||||
|
def test_resolving_survives_a_restart_while_merged(self):
|
||||||
|
"""member_pids reads the live player list, so a fresh process needs
|
||||||
|
no memory of anything to find a room that is currently merged."""
|
||||||
|
self.panel.join("living_room")
|
||||||
|
reborn = Controller(self.panel.cfg)
|
||||||
|
reborn.scan()
|
||||||
|
self.assertEqual(reborn.member_pids("living_room"), [LIVING_ROOM_PID])
|
||||||
|
reborn.leave("living_room")
|
||||||
|
self.assertEqual(self.group_pids(), {})
|
||||||
|
|
||||||
|
# -- volume ---------------------------------------------------------
|
||||||
|
def test_living_room_volume_is_its_own_player_volume(self):
|
||||||
|
self.panel.scan()
|
||||||
|
self.assertEqual(self.panel.set_volume("living_room", 42), 42)
|
||||||
|
self.assertEqual(self.heos.volumes[LIVING_ROOM_PID], 42)
|
||||||
|
self.assertEqual(self.panel.volume("living_room"), 42)
|
||||||
|
|
||||||
|
def test_nudge_clamps_at_the_ends(self):
|
||||||
|
self.panel.set_volume("lego_room", 98)
|
||||||
|
self.assertEqual(self.panel.nudge_volume("lego_room", 5), 100)
|
||||||
|
self.panel.set_volume("lego_room", 1)
|
||||||
|
self.assertEqual(self.panel.nudge_volume("lego_room", -9), 0)
|
||||||
|
|
||||||
|
# -- volume in whole steps -------------------------------------------
|
||||||
|
def test_a_tap_lands_on_the_next_multiple(self):
|
||||||
|
self.panel.set_volume("lego_room", 23)
|
||||||
|
self.assertEqual(self.panel.step_volume("lego_room", 1), 25)
|
||||||
|
self.panel.set_volume("lego_room", 23)
|
||||||
|
self.assertEqual(self.panel.step_volume("lego_room", -1), 20)
|
||||||
|
|
||||||
|
def test_a_level_already_on_a_multiple_moves_a_whole_step(self):
|
||||||
|
self.panel.set_volume("lego_room", 25)
|
||||||
|
self.assertEqual(self.panel.step_volume("lego_room", 1), 30)
|
||||||
|
self.panel.set_volume("lego_room", 25)
|
||||||
|
self.assertEqual(self.panel.step_volume("lego_room", -1), 20)
|
||||||
|
|
||||||
|
def test_a_burst_of_taps_snaps_once_then_moves_whole_steps(self):
|
||||||
|
self.panel.set_volume("living_room", 23)
|
||||||
|
self.assertEqual(self.panel.step_volume("living_room", 3), 35)
|
||||||
|
|
||||||
|
# -- play / pause ------------------------------------------------------
|
||||||
|
def test_toggle_play_flips_what_the_speakers_report(self):
|
||||||
|
self.panel.scan()
|
||||||
|
self.assertEqual(self.panel.get_play_state("lego_room"), "play")
|
||||||
|
self.assertEqual(self.panel.toggle_play("lego_room"), "pause")
|
||||||
|
self.assertEqual(self.panel.get_play_state("lego_room"), "pause")
|
||||||
|
self.assertEqual(self.panel.toggle_play("lego_room"), "play")
|
||||||
|
|
||||||
|
def test_toggle_play_takes_an_explicit_state(self):
|
||||||
|
self.panel.scan()
|
||||||
|
self.assertEqual(self.panel.toggle_play("lego_room", "stop"), "stop")
|
||||||
|
self.assertEqual(self.heos.play_states[HOME400_PID], "stop")
|
||||||
|
|
||||||
|
def test_living_room_playback_goes_to_its_player(self):
|
||||||
|
self.panel.scan()
|
||||||
|
self.panel.toggle_play("living_room", "pause")
|
||||||
|
self.assertEqual(self.heos.play_states[LIVING_ROOM_PID], "pause")
|
||||||
|
|
||||||
|
def test_state_says_which_rooms_are_playing_spotify(self):
|
||||||
|
"""The cards only offer play/pause for a Spotify stream, so the
|
||||||
|
snapshot has to say which rooms have one."""
|
||||||
|
self.heos.now_playing_mid[HOME400_PID] = "spotify:track:4uLU6hMCjMI75M1A2tKUQC"
|
||||||
|
self.heos.now_playing_sid[HOME400_PID] = 4
|
||||||
|
state = self.panel.state()
|
||||||
|
self.assertEqual([r["spotify"] for r in state["rooms"]], [True, False])
|
||||||
|
|
||||||
|
def test_state_carries_the_song_a_room_is_playing(self):
|
||||||
|
self.heos.now_playing_mid[HOME400_PID] = "spotify:track:4uLU6hMCjMI75M1A2tKUQC"
|
||||||
|
self.heos.now_playing_track[HOME400_PID] = {
|
||||||
|
"song": "Harvest Moon", "artist": "Neil Young", "image_url": "https://i.scdn.co/image/abc",
|
||||||
|
}
|
||||||
|
state = self.panel.state()
|
||||||
|
self.assertEqual(
|
||||||
|
[r["now_playing"] for r in state["rooms"]],
|
||||||
|
[{"song": "Harvest Moon", "artist": "Neil Young", "image": "https://i.scdn.co/image/abc"}, None],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_a_song_without_artist_or_cover_still_shows(self):
|
||||||
|
self.heos.now_playing_mid[HOME400_PID] = "spotify:track:4uLU6hMCjMI75M1A2tKUQC"
|
||||||
|
self.heos.now_playing_track[HOME400_PID] = {"song": "Harvest Moon", "artist": "", "image_url": ""}
|
||||||
|
self.assertEqual(
|
||||||
|
self.panel.state()["rooms"][0]["now_playing"],
|
||||||
|
{"song": "Harvest Moon", "artist": None, "image": None},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_a_paused_room_keeps_its_song_and_a_stopped_one_drops_it(self):
|
||||||
|
self.heos.now_playing_mid[HOME400_PID] = "spotify:track:4uLU6hMCjMI75M1A2tKUQC"
|
||||||
|
self.heos.now_playing_track[HOME400_PID] = {"song": "Harvest Moon"}
|
||||||
|
self.heos.play_states[HOME400_PID] = "pause"
|
||||||
|
self.assertIsNotNone(self.panel.state()["rooms"][0]["now_playing"])
|
||||||
|
self.heos.play_states[HOME400_PID] = "stop"
|
||||||
|
self.assertIsNone(self.panel.state()["rooms"][0]["now_playing"])
|
||||||
|
|
||||||
|
def test_an_avr_input_is_not_a_song(self):
|
||||||
|
"""HEOS puts an input's own name where the song goes."""
|
||||||
|
self.heos.now_playing_mid[HOME400_PID] = "inputs/mediaplayer"
|
||||||
|
self.heos.now_playing_track[HOME400_PID] = {"song": "Z30 Pro"}
|
||||||
|
self.assertIsNone(self.panel.state()["rooms"][0]["now_playing"])
|
||||||
|
|
||||||
|
def test_an_avr_input_is_not_spotify(self):
|
||||||
|
self.heos.now_playing_sid[AVR_PID] = 1027
|
||||||
|
self.assertFalse(self.panel.on_spotify("avr"))
|
||||||
|
|
||||||
|
# -- the AVR, entirely over HEOS --------------------------------------
|
||||||
|
def test_avr_inputs_carry_your_renamed_labels(self):
|
||||||
|
"""HEOS reports the AVR's own renamed sources itself (browse/browse
|
||||||
|
on its pid) -- there is no separate Telnet lookup needed for them."""
|
||||||
|
self.assertTrue(self.panel.avr_connected())
|
||||||
|
self.assertEqual(
|
||||||
|
self.panel.avr_inputs(),
|
||||||
|
[{"code": "inputs/mediaplayer", "name": "Z30 Pro"},
|
||||||
|
{"code": "inputs/game", "name": "Switch"},
|
||||||
|
{"code": "inputs/tvaudio", "name": "LG G5"}],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.panel.avr_current_input(), {"code": "inputs/mediaplayer", "name": "Z30 Pro"}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_selecting_an_avr_input_goes_through_heos(self):
|
||||||
|
"""Selection has to be browse/play_input, not the AVR's own Telnet
|
||||||
|
port -- that is what actually pushes the stream to a joined group,
|
||||||
|
not just what the AVR itself is listening to."""
|
||||||
|
self.assertEqual(
|
||||||
|
self.panel.avr_select_input("inputs/game"),
|
||||||
|
{"code": "inputs/game", "name": "Switch"},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.panel.avr_current_input(), {"code": "inputs/game", "name": "Switch"}
|
||||||
|
)
|
||||||
|
self.assertTrue(any("browse/play_input" in c for c in self.heos.commands))
|
||||||
|
|
||||||
|
# -- the whole snapshot the UI renders -------------------------------
|
||||||
|
def test_state_snapshot(self):
|
||||||
|
self.panel.join("lego_room")
|
||||||
|
state = self.panel.state()
|
||||||
|
self.assertTrue(state["heos_ok"])
|
||||||
|
self.assertEqual([r["key"] for r in state["rooms"]], ["lego_room", "living_room"])
|
||||||
|
self.assertEqual([r["grouped"] for r in state["rooms"]], [True, False])
|
||||||
|
self.assertTrue(all(isinstance(r["volume"], int) for r in state["rooms"]))
|
||||||
|
self.assertEqual([r["play_state"] for r in state["rooms"]], ["play", "play"])
|
||||||
|
|
||||||
|
def test_state_reports_trouble_instead_of_blowing_up(self):
|
||||||
|
self.panel.heos.host = "127.0.0.1"
|
||||||
|
self.panel.heos.port = 1 # nothing is listening there
|
||||||
|
self.panel.heos._close()
|
||||||
|
state = self.panel.state()
|
||||||
|
self.assertFalse(state["heos_ok"])
|
||||||
|
self.assertTrue(state["errors"])
|
||||||
|
self.assertTrue(all(r["available"] is False for r in state["rooms"]))
|
||||||
|
|
||||||
|
|
||||||
|
class StepArithmeticTest(unittest.TestCase):
|
||||||
|
"""The rule on its own -- no speakers involved."""
|
||||||
|
|
||||||
|
def test_up_from_between_multiples(self):
|
||||||
|
self.assertEqual([stepped_level(23, n, 5) for n in (1, 2, 3)], [25, 30, 35])
|
||||||
|
|
||||||
|
def test_down_from_between_multiples(self):
|
||||||
|
self.assertEqual([stepped_level(23, -n, 5) for n in (1, 2, 3)], [20, 15, 10])
|
||||||
|
|
||||||
|
def test_from_a_multiple(self):
|
||||||
|
self.assertEqual(stepped_level(25, 1, 5), 30)
|
||||||
|
self.assertEqual(stepped_level(25, -1, 5), 20)
|
||||||
|
|
||||||
|
def test_clamped_to_the_ends(self):
|
||||||
|
self.assertEqual(stepped_level(98, 1, 5), 100)
|
||||||
|
self.assertEqual(stepped_level(2, -1, 5), 0)
|
||||||
|
self.assertEqual(stepped_level(0, -1, 5), 0)
|
||||||
|
|
||||||
|
def test_no_taps_changes_nothing(self):
|
||||||
|
self.assertEqual(stepped_level(23, 0, 5), 23)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Run against the fake Spotify Web API in fake_spotify.py:
|
||||||
|
|
||||||
|
python3 -m unittest discover -s tests -t .
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from spotify import SpotifyClient, SpotifyError # noqa: E402
|
||||||
|
from tests.fake_spotify import FakeSpotify # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class SpotifyTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.fake = FakeSpotify()
|
||||||
|
self.addCleanup(self.fake.stop)
|
||||||
|
self.client = SpotifyClient(
|
||||||
|
"client-id", "client-secret", "refresh-token",
|
||||||
|
accounts_url=self.fake.base_url, api_url=self.fake.base_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_devices_lists_what_spotify_reports(self):
|
||||||
|
names = [d["name"] for d in self.client.devices()]
|
||||||
|
self.assertEqual(names, ["Lego Room", "Home Cinema"])
|
||||||
|
self.assertEqual(self.fake.tokens_issued, 1) # one refresh for the whole call
|
||||||
|
|
||||||
|
def test_resume_transfers_playback_to_the_matched_device(self):
|
||||||
|
device = self.client.resume("Lego Room")
|
||||||
|
self.assertEqual(device["id"], "dev-1")
|
||||||
|
self.assertEqual(self.fake.transfers, [{"device_ids": ["dev-1"], "play": True}])
|
||||||
|
|
||||||
|
def test_resume_raises_when_no_device_has_that_name(self):
|
||||||
|
with self.assertRaises(SpotifyError):
|
||||||
|
self.client.resume("Kitchen")
|
||||||
|
self.assertEqual(self.fake.transfers, [])
|
||||||
|
|
||||||
|
def test_seek_asks_for_the_position(self):
|
||||||
|
self.client.seek(90000)
|
||||||
|
self.assertEqual(self.fake.seeks, [90000])
|
||||||
|
|
||||||
|
def test_playback_reports_the_device_and_whether_it_plays(self):
|
||||||
|
self.fake.player = {"device": {"id": "dev-1", "name": "Lego Room"}, "is_playing": True}
|
||||||
|
player = self.client.playback()
|
||||||
|
self.assertEqual(player["device"]["name"], "Lego Room")
|
||||||
|
self.assertTrue(player["is_playing"])
|
||||||
|
|
||||||
|
def test_playback_is_empty_without_a_session(self):
|
||||||
|
self.assertEqual(self.client.playback(), {})
|
||||||
|
|
||||||
|
def test_a_rejected_access_token_is_refreshed_and_retried_once(self):
|
||||||
|
self.client.devices() # get a real token first
|
||||||
|
self.assertEqual(self.fake.tokens_issued, 1)
|
||||||
|
self.client._access_token = "stale-but-not-yet-expired"
|
||||||
|
# _expires_at is untouched, so only the 401 -- not the pre-call
|
||||||
|
# expiry check -- can be what forces this to work.
|
||||||
|
names = [d["name"] for d in self.client.devices()]
|
||||||
|
self.assertEqual(names, ["Lego Room", "Home Cinema"])
|
||||||
|
self.assertEqual(self.fake.tokens_issued, 2)
|
||||||
|
|
||||||
|
def test_a_revoked_refresh_token_raises_a_clear_error(self):
|
||||||
|
self.fake.reject_refresh = True
|
||||||
|
with self.assertRaises(SpotifyError):
|
||||||
|
self.client.devices()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""The Zidoo client against a pretend Zidoo, answering the way a real one
|
||||||
|
does (see zidoo.py).
|
||||||
|
|
||||||
|
python3 -m unittest discover -s tests -t .
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from zidoo import ZidooClient, ZidooError # noqa: E402
|
||||||
|
|
||||||
|
PATH = "/storage/disk/Movies/A.Private.War.2018.1080p.mp4"
|
||||||
|
PNG = b"\x89PNG\r\n\x1a\n" + b"\0" * 16
|
||||||
|
|
||||||
|
|
||||||
|
class FakeZidoo(BaseHTTPRequestHandler):
|
||||||
|
video = None # getPlayStatus's "video", or None for nothing loaded
|
||||||
|
library = {} # file path -> getAggregationOfFile's answer
|
||||||
|
posters = {} # poster id -> image bytes
|
||||||
|
lookups = [] # paths getAggregationOfFile was asked about
|
||||||
|
seeks = [] # every seekTo's position
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
url = urlparse(self.path)
|
||||||
|
query = {key: values[0] for key, values in parse_qs(url.query).items()}
|
||||||
|
if url.path == "/ZidooVideoPlay/getPlayStatus":
|
||||||
|
if self.video is None:
|
||||||
|
return self._send(404, b"")
|
||||||
|
return self._json({"status": 200, "video": self.video})
|
||||||
|
if url.path == "/ZidooVideoPlay/seekTo":
|
||||||
|
if self.video is None:
|
||||||
|
return self._send(404, b"")
|
||||||
|
self.seeks.append(int(query["positon"])) # sic, as the real one spells it
|
||||||
|
return self._json({"status": 200})
|
||||||
|
if url.path == "/ZidooPoster/v2/getAggregationOfFile":
|
||||||
|
self.lookups.append(query["path"])
|
||||||
|
return self._json(self.library.get(query["path"], {"status": 804, "msg": "Error!!!"}))
|
||||||
|
if url.path == "/ZidooPoster/getFile/getPoster":
|
||||||
|
image = self.posters.get(int(query["id"]))
|
||||||
|
return self._send(200, image) if image else self._json({"status": 804, "msg": "Error!!!"})
|
||||||
|
self._send(404, b"")
|
||||||
|
|
||||||
|
def _json(self, payload):
|
||||||
|
self._send(200, json.dumps(payload).encode())
|
||||||
|
|
||||||
|
def _send(self, code, body):
|
||||||
|
self.send_response(code) # no Content-Type, like the real thing
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def log_message(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ZidooTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
FakeZidoo.video = {"status": 1, "title": "A Private War", "path": PATH,
|
||||||
|
"currentPosition": 1589745, "duration": 6635092}
|
||||||
|
FakeZidoo.library = {PATH: {"path": PATH, "type": "movie", "video": {"id": 2248, "parentId": 108},
|
||||||
|
"movie": {"id": 108, "name": "A Private War", "year": 2018}}}
|
||||||
|
FakeZidoo.posters = {108: PNG}
|
||||||
|
FakeZidoo.lookups = []
|
||||||
|
FakeZidoo.seeks = []
|
||||||
|
self.server = ThreadingHTTPServer(("127.0.0.1", 0), FakeZidoo)
|
||||||
|
threading.Thread(target=self.server.serve_forever, args=(0.05,), daemon=True).start()
|
||||||
|
self.zidoo = ZidooClient("127.0.0.1", self.server.server_address[1])
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.server.shutdown()
|
||||||
|
self.server.server_close()
|
||||||
|
|
||||||
|
def test_a_film_carries_its_year_progress_and_poster(self):
|
||||||
|
self.assertEqual(self.zidoo.now_playing(), {
|
||||||
|
"song": "A Private War", "artist": "2018", "image": None, "poster_id": 108,
|
||||||
|
"play_state": "play", "position_ms": 1589745, "duration_ms": 6635092,
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_a_paused_film_is_still_there_but_paused(self):
|
||||||
|
FakeZidoo.video["status"] = 0
|
||||||
|
self.assertEqual(self.zidoo.now_playing()["play_state"], "pause")
|
||||||
|
|
||||||
|
def test_the_poster_is_looked_up_once_per_file(self):
|
||||||
|
self.zidoo.now_playing()
|
||||||
|
self.zidoo.now_playing()
|
||||||
|
self.assertEqual(FakeZidoo.lookups, [PATH])
|
||||||
|
|
||||||
|
def test_a_file_outside_the_library_has_no_poster_or_year(self):
|
||||||
|
FakeZidoo.library = {}
|
||||||
|
track = self.zidoo.now_playing()
|
||||||
|
self.assertIsNone(track["poster_id"])
|
||||||
|
self.assertIsNone(track["artist"])
|
||||||
|
|
||||||
|
def test_nothing_loaded_is_nothing_playing(self):
|
||||||
|
FakeZidoo.video = None
|
||||||
|
self.assertIsNone(self.zidoo.now_playing())
|
||||||
|
|
||||||
|
def test_seek_moves_the_loaded_film(self):
|
||||||
|
self.zidoo.seek(3600000)
|
||||||
|
self.assertEqual(FakeZidoo.seeks, [3600000])
|
||||||
|
|
||||||
|
def test_seeking_with_nothing_loaded_is_an_error(self):
|
||||||
|
FakeZidoo.video = None
|
||||||
|
with self.assertRaises(ZidooError):
|
||||||
|
self.zidoo.seek(3600000)
|
||||||
|
|
||||||
|
def test_poster_is_sniffed_from_its_bytes(self):
|
||||||
|
self.assertEqual(self.zidoo.poster(108), (PNG, "image/png"))
|
||||||
|
|
||||||
|
def test_an_id_without_a_poster_is_none_not_its_json_error(self):
|
||||||
|
self.assertIsNone(self.zidoo.poster(2248))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Render static/logo.svg into the home-screen icons.
|
||||||
|
|
||||||
|
python3 tools/make_icons.py
|
||||||
|
|
||||||
|
Writes static/icon-180.png (what iOS uses on the home screen) and
|
||||||
|
static/icon-512.png (Android / the web manifest): the white mark on
|
||||||
|
the app's dark background color. iOS flattens a transparent PNG onto
|
||||||
|
white rather than black, which made the white-on-transparent mark
|
||||||
|
invisible on the home screen -- so the background needs to be baked
|
||||||
|
into the PNG itself.
|
||||||
|
|
||||||
|
The PNGs are committed, so this only needs running if the logo changes.
|
||||||
|
It rasterises with headless Chromium, which is a heavy thing to install
|
||||||
|
for one job -- if you have librsvg to hand, this does the same:
|
||||||
|
|
||||||
|
rsvg-convert -w 512 -h 512 --background-color '#0a0d14' static/logo.svg -o static/icon-512.png
|
||||||
|
|
||||||
|
Otherwise: pip install playwright && playwright install chromium
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
STATIC = Path(__file__).resolve().parent.parent / "static"
|
||||||
|
LOGO = STATIC / "logo.svg"
|
||||||
|
SIZES = (180, 512)
|
||||||
|
MARGIN = 0.14 # breathing room, so iOS's rounded mask never clips it
|
||||||
|
BACKGROUND = "#0a0d14" # matches theme_color/background_color in the manifest
|
||||||
|
|
||||||
|
PAGE = """<!doctype html>
|
||||||
|
<style>
|
||||||
|
html, body {{ margin: 0; background: {background}; }}
|
||||||
|
body {{ width: {size}px; height: {size}px; display: grid; place-items: center; }}
|
||||||
|
img {{ width: {inner}px; height: {inner}px; object-fit: contain; }}
|
||||||
|
</style>
|
||||||
|
<img src="logo.svg">
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
try:
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
except ImportError:
|
||||||
|
sys.exit("needs playwright: pip install playwright && playwright install chromium")
|
||||||
|
|
||||||
|
if not LOGO.exists():
|
||||||
|
sys.exit(f"no logo at {LOGO}")
|
||||||
|
|
||||||
|
scratch = STATIC / "_icon.html"
|
||||||
|
try:
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(args=["--no-sandbox"])
|
||||||
|
for size in SIZES:
|
||||||
|
inner = round(size * (1 - 2 * MARGIN))
|
||||||
|
scratch.write_text(PAGE.format(size=size, inner=inner, background=BACKGROUND))
|
||||||
|
page = browser.new_page(viewport={"width": size, "height": size})
|
||||||
|
page.goto(scratch.as_uri())
|
||||||
|
page.wait_for_timeout(120) # let the SVG paint
|
||||||
|
target = STATIC / f"icon-{size}.png"
|
||||||
|
page.screenshot(path=target)
|
||||||
|
page.close()
|
||||||
|
print(f"wrote {target} ({target.stat().st_size} bytes)")
|
||||||
|
browser.close()
|
||||||
|
finally:
|
||||||
|
scratch.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""One-time Spotify login per account, to get the refresh token config.py
|
||||||
|
needs for that account's Spotify button on the panel.
|
||||||
|
|
||||||
|
python3 tools/spotify_auth.py --client-id ... --client-secret ... --account 1
|
||||||
|
|
||||||
|
Run this somewhere you can actually reach a browser to approve the
|
||||||
|
login -- your laptop, or a WSL shell if Windows can reach it (WSL2
|
||||||
|
forwards localhost both ways, so a browser on the Windows side works
|
||||||
|
fine too). It listens on 127.0.0.1 for the one redirect Spotify sends
|
||||||
|
back, so nothing here ever sees your Spotify password, only the
|
||||||
|
short-lived code Spotify hands back afterwards.
|
||||||
|
|
||||||
|
In a plain shell with no desktop session wired up, this can't open a
|
||||||
|
browser for you automatically -- it tries, and that attempt can print
|
||||||
|
its own "Operation not supported" message when it fails. That's the
|
||||||
|
browser launcher failing, not this script; the URL it prints above that
|
||||||
|
still works, copied into any browser by hand.
|
||||||
|
|
||||||
|
Before running it:
|
||||||
|
|
||||||
|
1. Create an app at https://developer.spotify.com/dashboard (any name).
|
||||||
|
2. In its settings, add this exact Redirect URI:
|
||||||
|
http://127.0.0.1:8899/callback
|
||||||
|
Spotify allows plain http for a 127.0.0.1 redirect specifically --
|
||||||
|
nowhere else -- which is why this doesn't need HTTPS to work.
|
||||||
|
3. Copy its Client ID and Client Secret and pass them here.
|
||||||
|
|
||||||
|
It prints SPOTIFY_<ACCOUNT>_REFRESH_TOKEN for the account you logged in
|
||||||
|
as -- put that, plus the client id and secret, in .env (the README's
|
||||||
|
Spotify section has the details). Run it again with the other --account,
|
||||||
|
logged in as that account, for its own token. None of these belong in
|
||||||
|
config.py itself or in git.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
import webbrowser
|
||||||
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
|
|
||||||
|
REDIRECT_PORT = 8899
|
||||||
|
REDIRECT_URI = f"http://127.0.0.1:{REDIRECT_PORT}/callback"
|
||||||
|
SCOPES = "user-read-playback-state user-modify-playback-state"
|
||||||
|
|
||||||
|
|
||||||
|
def get_code(client_id: str, state: str) -> str:
|
||||||
|
"""Open Spotify's login page and block until its redirect lands."""
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, *args):
|
||||||
|
pass # the printed instructions are enough noise already
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
query = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
|
||||||
|
result["code"] = query.get("code", [None])[0]
|
||||||
|
result["state"] = query.get("state", [None])[0]
|
||||||
|
result["error"] = query.get("error", [None])[0]
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/html")
|
||||||
|
self.end_headers()
|
||||||
|
body = "You can close this tab and go back to the terminal." \
|
||||||
|
if result["code"] else f"Spotify said: {result['error']}"
|
||||||
|
self.wfile.write(body.encode())
|
||||||
|
|
||||||
|
server = HTTPServer(("127.0.0.1", REDIRECT_PORT), Handler)
|
||||||
|
authorize_url = "https://accounts.spotify.com/authorize?" + urllib.parse.urlencode({
|
||||||
|
"client_id": client_id,
|
||||||
|
"response_type": "code",
|
||||||
|
"redirect_uri": REDIRECT_URI,
|
||||||
|
"scope": SCOPES,
|
||||||
|
"state": state,
|
||||||
|
})
|
||||||
|
print(f"Open this URL and log in to Spotify:\n\n{authorize_url}\n")
|
||||||
|
try:
|
||||||
|
# Best-effort only: in a plain WSL shell (no desktop session wired
|
||||||
|
# up) this can fail with its own "Operation not supported" message
|
||||||
|
# printed straight to the terminal -- that's the browser launcher
|
||||||
|
# complaining, not this script; ignore it and open the URL above
|
||||||
|
# by hand (from Windows too -- WSL2 forwards localhost both ways,
|
||||||
|
# so the redirect below still reaches this script).
|
||||||
|
webbrowser.open(authorize_url)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
print("Waiting for Spotify to redirect back here once you approve it...")
|
||||||
|
server.handle_request() # one request is all this ever needs
|
||||||
|
server.server_close()
|
||||||
|
|
||||||
|
if result.get("error"):
|
||||||
|
sys.exit(f"Spotify refused: {result['error']}")
|
||||||
|
if result.get("state") != state:
|
||||||
|
sys.exit("state mismatch -- got a callback that wasn't for this run, aborting")
|
||||||
|
return result["code"]
|
||||||
|
|
||||||
|
|
||||||
|
def exchange(client_id: str, client_secret: str, code: str) -> dict:
|
||||||
|
credentials = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
|
||||||
|
body = urllib.parse.urlencode({
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": code,
|
||||||
|
"redirect_uri": REDIRECT_URI,
|
||||||
|
}).encode()
|
||||||
|
request = urllib.request.Request(
|
||||||
|
"https://accounts.spotify.com/api/token",
|
||||||
|
data=body,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Basic {credentials}",
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request) as response:
|
||||||
|
return json.loads(response.read())
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
sys.exit(f"Spotify rejected the code exchange: {exc.read().decode()}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--client-id", required=True)
|
||||||
|
parser.add_argument("--client-secret", required=True)
|
||||||
|
parser.add_argument("--account", required=True,
|
||||||
|
help="which SPOTIFY_ACCOUNTS slot this login is for, e.g. 1 or 2")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
state = secrets.token_urlsafe(16)
|
||||||
|
code = get_code(args.client_id, state)
|
||||||
|
tokens = exchange(args.client_id, args.client_secret, code)
|
||||||
|
|
||||||
|
print("\nPut these in .env -- the client id and secret are the same for every account:\n")
|
||||||
|
print(f"SPOTIFY_CLIENT_ID={args.client_id}")
|
||||||
|
print(f"SPOTIFY_CLIENT_SECRET={args.client_secret}")
|
||||||
|
print(f"SPOTIFY_ACCOUNT{args.account}_REFRESH_TOKEN={tokens['refresh_token']}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""Minimal client for the Zidoo media player's own HTTP API
|
||||||
|
|
||||||
|
The AVR only tells HEOS which of its inputs is selected, never what a
|
||||||
|
device plugged into one is actually showing, so a Zidoo's "now playing"
|
||||||
|
has to be asked for directly, over its own control API on port 9529.
|
||||||
|
|
||||||
|
Its video player only answers getPlayStatus while a video is actually
|
||||||
|
loaded -- with nothing playing, that route does not exist yet, which
|
||||||
|
looks the same here as the box being off or unreachable. Both are simply
|
||||||
|
"nothing to show" rather than an error: this is a nice-to-have on top of
|
||||||
|
an AVR input, not something the rest of the panel depends on.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
class ZidooError(RuntimeError):
|
||||||
|
"""The Zidoo did not do what it was asked -- off, unreachable, or with
|
||||||
|
nothing loaded to do it to."""
|
||||||
|
|
||||||
|
|
||||||
|
class ZidooClient:
|
||||||
|
def __init__(self, host, port=9529, timeout=1.5):
|
||||||
|
self.base_url = f"http://{host}:{port}"
|
||||||
|
self.timeout = timeout
|
||||||
|
self._film_for = (None, {}) # (file path, _film()'s answer) last looked up
|
||||||
|
|
||||||
|
def _get(self, route, **params):
|
||||||
|
"""The raw body of a GET, or None if the Zidoo did not answer."""
|
||||||
|
query = f"?{urllib.parse.urlencode(params)}" if params else ""
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(f"{self.base_url}/{route}{query}", timeout=self.timeout) as response:
|
||||||
|
return response.read()
|
||||||
|
except OSError: # URLError, and a timeout mid-read, which urlopen does not wrap
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get_json(self, route, **params):
|
||||||
|
body = self._get(route, **params)
|
||||||
|
try:
|
||||||
|
return json.loads(body) if body is not None else None
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def now_playing(self):
|
||||||
|
"""{"song", "artist", "image"} for whatever video is loaded, in the
|
||||||
|
same shape a room's now-playing card already expects -- or None.
|
||||||
|
A film has no artist, so that line under the title carries its year
|
||||||
|
instead. "image" is always None: the poster lives on the Zidoo, which
|
||||||
|
the phone cannot load over plain http, so "poster_id" names it
|
||||||
|
instead for app.py to serve. "position_ms" and "duration_ms" come
|
||||||
|
along whenever the length is known, as a room's do. "play_state" is
|
||||||
|
"play" while the film runs (the video's "status" is 1), "pause"
|
||||||
|
otherwise."""
|
||||||
|
payload = self._get_json("ZidooVideoPlay/getPlayStatus")
|
||||||
|
if not payload or payload.get("status") != 200:
|
||||||
|
return None
|
||||||
|
video = payload.get("video") or {}
|
||||||
|
title = video.get("title")
|
||||||
|
if not title:
|
||||||
|
return None
|
||||||
|
film = self._film(video.get("path"))
|
||||||
|
year = film.get("year")
|
||||||
|
track = {"song": title, "artist": str(year) if year else None, "image": None,
|
||||||
|
"poster_id": film.get("id"),
|
||||||
|
"play_state": "play" if video.get("status") == 1 else "pause"}
|
||||||
|
if video.get("duration"):
|
||||||
|
track["position_ms"] = video.get("currentPosition") or 0
|
||||||
|
track["duration_ms"] = video["duration"]
|
||||||
|
return track
|
||||||
|
|
||||||
|
def seek(self, position_ms):
|
||||||
|
"""Jump the loaded video to position_ms. Unlike now_playing(), this
|
||||||
|
is something someone asked for, so a Zidoo that does not answer is
|
||||||
|
an error rather than simply nothing to show."""
|
||||||
|
# "positon" [sic] is the Zidoo's own spelling.
|
||||||
|
payload = self._get_json("ZidooVideoPlay/seekTo", positon=int(position_ms))
|
||||||
|
if not payload or payload.get("status") != 200:
|
||||||
|
raise ZidooError("The Zidoo did not seek -- is a film still loaded on it?")
|
||||||
|
|
||||||
|
def _film(self, path):
|
||||||
|
"""The poster wall's entry for the film a file belongs to -- its
|
||||||
|
"id" (which is also its poster's) and "year" among the rest -- or {}
|
||||||
|
when the file is not in the library. Looked up once per file rather
|
||||||
|
than on every poll: the answer comes wrapped in the film's whole cast
|
||||||
|
and crew, and does not change halfway through it."""
|
||||||
|
if not path:
|
||||||
|
return {}
|
||||||
|
if self._film_for[0] != path:
|
||||||
|
payload = self._get_json("ZidooPoster/v2/getAggregationOfFile", path=path)
|
||||||
|
if payload is None:
|
||||||
|
return {} # unreachable for now -- ask again next poll
|
||||||
|
# "type" names the key holding the item itself: "movie" for a
|
||||||
|
# film. Anything else without one simply gets no poster or year.
|
||||||
|
self._film_for = (path, payload.get(payload.get("type")) or {})
|
||||||
|
return self._film_for[1]
|
||||||
|
|
||||||
|
def poster(self, poster_id, width=200, height=300):
|
||||||
|
"""(bytes, mimetype) for a film's poster, or None. The Zidoo sends
|
||||||
|
no Content-Type, and answers an id it has no poster for with a JSON
|
||||||
|
error instead, so the image is told apart by its first bytes."""
|
||||||
|
body = self._get("ZidooPoster/getFile/getPoster", id=poster_id, w=width, h=height)
|
||||||
|
if body is None:
|
||||||
|
return None
|
||||||
|
if body.startswith(b"\x89PNG"):
|
||||||
|
return body, "image/png"
|
||||||
|
if body.startswith(b"\xff\xd8"):
|
||||||
|
return body, "image/jpeg"
|
||||||
|
return None
|
||||||
Reference in New Issue
Block a user