Compare commits

...
22 Commits
Author SHA1 Message Date
franzz 053be20b9c Optimize api/state
Deploy HEOS panel / deploy (push) Successful in 26s
2026-09-17 16:15:59 +02:00
franzz 74c232e678 Remove favicon bg
Deploy HEOS panel / deploy (push) Successful in 26s
2026-09-17 15:17:42 +02:00
franzz 4f858be918 Fix icon background for iOS bookmarks
Deploy HEOS panel / deploy (push) Successful in 25s
2026-09-17 15:07:30 +02:00
franzz 9d64a8f68f Use kiwi heos logo everywhere
Deploy HEOS panel / deploy (push) Successful in 26s
2026-09-17 15:00:23 +02:00
franzz 49aeb6d5ee Update logo
Deploy HEOS panel / deploy (push) Successful in 25s
2026-09-17 00:20:00 +02:00
franzz a537d0dd18 Center logo
Deploy HEOS panel / deploy (push) Successful in 25s
2026-09-16 23:23:45 +02:00
franzz ec684b7803 Make progress bar cursor draggable
Deploy HEOS panel / deploy (push) Successful in 26s
2026-09-16 23:19:08 +02:00
franzz 51dff45b54 Fix AVR playing state
Deploy HEOS panel / deploy (push) Successful in 25s
2026-09-16 20:39:53 +02:00
franzz 532b48ad37 Fix grouped AVR / room card + add movie info
Deploy HEOS panel / deploy (push) Successful in 25s
2026-09-16 20:36:29 +02:00
franzz 69476134f7 Add icons
Deploy HEOS panel / deploy (push) Successful in 25s
2026-09-16 18:06:14 +02:00
franzz 3d4922fd19 Fix panel order
Deploy HEOS panel / deploy (push) Successful in 25s
2026-09-16 15:58:58 +02:00
franzz 4dd7e298a5 Add song progress bar
Deploy HEOS panel / deploy (push) Successful in 25s
2026-09-16 15:03:45 +02:00
franzz 58a2dedc03 Remove group management
Deploy HEOS panel / deploy (push) Successful in 25s
2026-09-16 13:43:31 +02:00
franzz 5b82a24ac4 fix music navs
Deploy HEOS panel / deploy (push) Successful in 25s
2026-09-15 23:19:11 +02:00
franzz 7c19ff9096 Add spotify integration
Deploy HEOS panel / deploy (push) Successful in 25s
2026-09-15 22:07:43 +02:00
franzz 6a0f1fa5e8 fix AVR input swap
Deploy HEOS panel / deploy (push) Successful in 25s
2026-09-15 17:20:24 +02:00
franzz dc7f09052d play/pause
Deploy HEOS panel / deploy (push) Successful in 24s
2026-09-15 01:03:25 +02:00
franzz d2d7677519 Changing port
Deploy HEOS panel / deploy (push) Successful in 24s
2026-09-15 00:46:41 +02:00
franzz ce8596945e Fix service restarter test
Deploy HEOS panel / deploy (push) Failing after 44s
2026-09-15 00:08:00 +02:00
franzz 9a5f2ac639 Swaping back to nodejs dependent checkout
Deploy HEOS panel / deploy (push) Failing after 36s
2026-09-14 23:58:53 +02:00
franzz a7a8ab6f4e Simplify deployment
Deploy HEOS panel / deploy (push) Failing after 0s
2026-09-14 23:48:51 +02:00
franzzandClaude Opus 5 4cff820070 Check out with git rather than actions/checkout
Deploy HEOS panel / deploy (push) Failing after 0s
actions/checkout is a JavaScript action, so a host-mode runner needs node
on its PATH to run it and fails with "Cannot find: node in PATH" without
one. Every step is plain shell now, which needs nothing of the runner
beyond git, python3, rsync and curl.

The clone is shallow and takes its URL from the live checkout's remote, so
there is no URL or token in the workflow. It works because the runner
already has to run as the user that owns that checkout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:35:29 +02:00
31 changed files with 2753 additions and 1038 deletions
+1
View File
@@ -0,0 +1 @@
.env
+4
View File
@@ -0,0 +1,4 @@
SPOTIFY_CLIENT_ID=
SPOTIFY_CLIENT_SECRET=
SPOTIFY_ACCOUNT1_REFRESH_TOKEN=
SPOTIFY_ACCOUNT2_REFRESH_TOKEN=
+41 -17
View File
@@ -3,12 +3,13 @@
# nobody offers sits in the queue rather than failing. # nobody offers sits in the queue rather than failing.
# #
# The runner has to be in host mode on the machine that serves the panel: # The runner has to be in host mode on the machine that serves the panel:
# it writes into DEPLOY_PATH and restarts the service. See # it writes into DEPLOY_PATH and restarts the service. It also needs node
# deploy/heos-panel.service for the unit and the one sudoers line the # 20+ on its PATH, since actions/checkout is a JavaScript action and a
# restart needs. # host-mode runner has nothing else to run one with.
# #
# DEPLOY_PATH is also where you edit: an rsync --delete lands on top of # DEPLOY_PATH does not have to be a git checkout -- an empty directory the
# whatever is sitting there uncommitted, so commit before you push. # 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 name: Deploy HEOS panel
@@ -25,7 +26,7 @@ jobs:
env: env:
DEPLOY_PATH: /var/www/html/heos DEPLOY_PATH: /var/www/html/heos
SERVICE: heos-panel SERVICE: heos-panel
PANEL_URL: http://127.0.0.1:5005/ # WEB_PORT in config.py PANEL_URL: http://127.0.0.1:5443/ # WEB_PORT in config.py
steps: steps:
- name: Checkout - name: Checkout
@@ -33,6 +34,7 @@ jobs:
- name: Check runner tools - name: Check runner tools
run: | run: |
echo "running as $(id -un) on $(hostname)"
command -v python3 command -v python3
command -v rsync command -v rsync
command -v curl command -v curl
@@ -42,12 +44,29 @@ jobs:
test -d "$DEPLOY_PATH" test -d "$DEPLOY_PATH"
test -w "$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 - name: Check the restart is allowed without a password
run: sudo -n systemctl is-active "$SERVICE" || true 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: the one under # A throwaway virtualenv in the workspace -- this is the npm ci of a
# $DEPLOY_PATH/.venv is what the running panel imports from, and a # Python project. The one under $DEPLOY_PATH/.venv is what the running
# test run has no business touching it. # panel imports from, and a test run has no business touching it.
- name: Install dependencies - name: Install dependencies
run: | run: |
python3 -m venv .venv-ci python3 -m venv .venv-ci
@@ -55,10 +74,15 @@ jobs:
.venv-ci/bin/pip install --quiet -r requirements.txt .venv-ci/bin/pip install --quiet -r requirements.txt
# Runs against the fake HEOS and AVR servers in tests/fakes.py, so it # Runs against the fake HEOS and AVR servers in tests/fakes.py, so it
# needs no speakers and touches nothing on the network. # 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 - name: Run tests
run: .venv-ci/bin/python -m unittest discover -s tests -t . --verbose 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 - name: Deploy to production
run: | run: |
rsync -azc --no-times --delete \ rsync -azc --no-times --delete \
@@ -66,20 +90,20 @@ jobs:
--exclude "/.gitea/" \ --exclude "/.gitea/" \
--exclude "/.venv/" \ --exclude "/.venv/" \
--exclude "/.venv-ci/" \ --exclude "/.venv-ci/" \
--exclude "/members.json" \ --exclude "/.env" \
--exclude "/config.json" \
--exclude "__pycache__/" \ --exclude "__pycache__/" \
./ "$DEPLOY_PATH/" ./ "$DEPLOY_PATH/"
# members.json is the stereo pair's learned membership and .venv is
# the runtime -- both are excluded above, so --delete leaves them be.
- name: Install runtime dependencies - name: Install runtime dependencies
run: | run: |
test -d "$DEPLOY_PATH/.venv" || python3 -m venv "$DEPLOY_PATH/.venv" test -d "$DEPLOY_PATH/.venv" || python3 -m venv "$DEPLOY_PATH/.venv"
"$DEPLOY_PATH/.venv/bin/pip" install --quiet -r "$DEPLOY_PATH/requirements.txt" "$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 - name: Restart
run: sudo systemctl restart "$SERVICE" run: sudo -n "$(command -v systemctl)" restart "$SERVICE"
- name: Wait for the panel to answer - name: Wait for the panel to answer
run: | run: |
@@ -93,5 +117,5 @@ jobs:
sleep 1 sleep 1
done done
echo "panel did not come back -- last of its log:" echo "panel did not come back -- last of its log:"
sudo systemctl status "$SERVICE" --no-pager --lines 30 || true systemctl status "$SERVICE" --no-pager --lines 30 || true
exit 1 exit 1
+2 -2
View File
@@ -144,5 +144,5 @@ __pycache__/
venv/ venv/
.venv-ci/ .venv-ci/
# Learned HEOS group membership, written at runtime # Your own rooms/speakers/ports -- copy config.json.example to config.json
members.json config.json
+94 -190
View File
@@ -1,91 +1,33 @@
# HEOS panel # Heos app
A phone-sized web remote for a Denon HEOS system, meant to be added to the _The default HEOS app is so bad I had to make one myself._
iOS home screen and used instead of the HEOS app. One Flask process serves
both the interface and the HTTP bridge behind it.
Everything it does fits on one screen: 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.
- **Volume** up/down for the Home 400 and the Living Room pair. A tap lands Everything fits on one screen:
on the next multiple of `VOLUME_STEP` — from 23 it goes to 25, not 28 —
so the levels stay round. Hold to keep moving. - **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.
- **Group** either room with the AVR — the AVR is always the host, so its - **Navigate** either room (play / pause / prev / next), while it is playing Spotify
sound takes over whatever joins it. A room that joins moves *into* the - **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
Home Cinema card, so one glance says what is playing together
- **Ungroup** either room again, or all of them at once - **Ungroup** either room again, or all of them at once
- **Change the AVR's input**, listed under the names you gave them, minus - **Change the AVR's input**, listed under the names you gave them, minus the sources you deleted in the AVR's setup menu
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 ## The kit it assumes
| Room | Device | How HEOS addresses it | | Room | Device | How HEOS addresses it |
| --- | --- | --- | | --- | --- | --- |
| Living Room | 2× Denon Home 200 as an In-Room Group | a **group** (`gid`) | | 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`) | | Lego Room | Denon Home 400 | a **player** (`pid`) |
| Home Cinema | Denon AVR-X3800H | a **player**, plus Telnet on port 23 | | Home Cinema | Denon AVR-X3800H | a **player** |
Any other mix works — it is all in `config.py`. See `config.json.example`.
## Install ## Install
```bash ### CI/CD
cd /var/www/html/heos
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
python3 app.py
```
Then open `http://<pi-ip>:5005/`. `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.
The virtual environment is not optional on a current Raspberry Pi OS:
`pip install` straight into the system Python is refused there with
`externally-managed-environment`. It also keeps Flask out of the way of
anything else running on the Pi.
Every later `python3 ...` command here assumes the environment is active
(`source .venv/bin/activate`); `deactivate` when you are done. The service
below calls the environment's Python directly, so it does not care.
## Configure
Open `http://<pi-ip>:5005/api/targets` and copy the exact `name` HEOS reports
for each device into `TARGETS` in `config.py`. The names come from whatever
you typed in the HEOS app, so they rarely match the model names.
```python
TARGETS = {
"avr": {"label": "Home Cinema", "heos_name": "Home Cinema"},
"home400": {"label": "Lego Room", "heos_name": "Lego Room"},
"living_room_group": {"label": "Living Room", "heos_name": "Denon Home 200 L"},
}
HOST_KEY = "avr" # always the group host
ROOM_KEYS = ["home400", "living_room_group"] # the cards, in order
```
`HEOS_HOST` only needs to point at **one** device: HEOS is distributed, so any
unit can see and control the whole network. `AVR_HOST` must be the AVR itself.
`VOLUME_STEP` is the grid the volume buttons snap to, not simply how much
they add: at 5, a tap moves 23 to 25 and 25 to 30.
The input picker already leaves out sources switched off in the AVR's own
setup menu (it asks the AVR with `SSSOD ?`). `AVR_INPUT_CODES` narrows it
further to the sources you actually use, and sets their order; leave it empty
to list everything the AVR still has switched on.
## 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.
## Run it as a service
`deploy/heos-panel.service` runs the panel out of its own virtualenv and
restarts it if it dies:
```bash ```bash
sudo cp deploy/heos-panel.service /etc/systemd/system/ sudo cp deploy/heos-panel.service /etc/systemd/system/
@@ -95,159 +37,121 @@ sudo systemctl enable --now heos-panel
Edit `User=` and the paths in it if you keep the panel somewhere else. Edit `User=` and the paths in it if you keep the panel somewhere else.
## Deploying from Gitea Make sure the CI/CD's \<user\> can restart the service:
`.gitea/workflows/deploy.yml` runs the tests on every push to `main`, then
rsyncs the tree into place, installs anything new from `requirements.txt`,
restarts the service and waits for the panel to answer again.
It needs a runner **in host mode on the machine that serves the panel**,
registered with the label `heos` (`runs-on:` must match, or the job queues
forever), running as the user that owns the directory. Restarting needs
one sudoers line:
```bash ```bash
echo 'franzz ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart heos-panel' \ echo "<user> ALL=(ALL) NOPASSWD: $(command -v systemctl) restart heos-panel" \
| sudo tee /etc/sudoers.d/heos-panel | sudo tee /etc/sudoers.d/heos-panel
sudo chmod 440 /etc/sudoers.d/heos-panel sudo chmod 440 /etc/sudoers.d/heos-panel
``` ```
The rsync excludes `.venv` and `members.json`, so the runtime and the Run the Gitea CI/CD.
learned stereo-pair membership survive a deploy. It does *not* exclude
`config.py`: your device names live in git, so commit changes to them
rather than editing the deployed copy. And since the deploy path is also
where you edit, `--delete` lands on top of anything uncommitted sitting
there.
## Behind a reverse proxy, at /heos ### Manually
`deploy/heos.conf` reverse-proxies `/heos` to the panel with Apache: 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 ```bash
sudo a2enmod proxy proxy_http headers sudo a2enmod proxy proxy_http headers
sudo cp deploy/heos.conf /etc/apache2/conf-available/heos.conf sudo cp deploy/heos.apache.conf /etc/apache2/conf-available/heos.conf
sudo a2enconf heos sudo a2enconf heos
sudo apachectl configtest && sudo systemctl reload apache2 sudo apachectl configtest && sudo systemctl reload apache2
``` ```
`deploy/heos.nginx.conf` is the same thing for nginx: copy it to #### Nginx
`/etc/nginx/snippets/heos.conf`, `include snippets/heos.conf;` inside the
`server` block, then `sudo nginx -t && sudo systemctl reload nginx`.
Both ship restricted to the local network — this controls the speakers, and `deploy/heos.nginx.conf` reverse-proxies `/heos` to the panel with Nginx:
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.
The app works at either address without being told which. The proxy sends ```bash
`X-Forwarded-Prefix: /heos`, and every URL the app generates — stylesheet, sudo cp deploy/heos.nginx.conf /etc/nginx/sites-available/heos
icons, the manifest's `start_url`, every `fetch` — picks up that prefix. sudo ln -s /etc/nginx/sites-available/heos /etc/nginx/sites-enabled/heos
Serve it straight from port 5005 and the same URLs come out as `/...`. sudo nginx -t && sudo systemctl reload nginx
That header is what the `headers` module is for; without it the page loads
and nothing on it works.
Two things worth knowing:
- The proxy block takes `/heos` away from the filesystem, so the source
under `/var/www/html/heos` stops being served as static files.
- The panel still answers directly on `<pi-ip>:5005`. Start it with
`--host 127.0.0.1` if you want Apache to be the only way in.
## How the grouping actually works
Worth knowing, because HEOS makes two things easy to get wrong.
**`set_group` replaces a group wholesale.** There is no "add this player".
Joining a second room therefore re-sends every member of the group, and the
host's `pid` has to come first — that is what makes the AVR the leader whose
content everyone plays.
**Your Home 200 pair is a group, not a speaker.** Merging it into the AVR
means sending *both* speakers' pids; sending only the leader would leave the
second Home 200 playing on its own. And once merged, the pair's own `gid`
stops existing, so:
- unmerging re-issues `set_group` with the pair's two pids, rebuilding it
- volume falls back to setting both players directly, since there is no
group volume to set any more
The panel learns the pair's members the first time it sees them un-merged and
remembers them in `members.json`, which is what lets it rebuild the pair after
a restart. If you would rather pin them down, list them in `config.py`:
```python
"living_room_group": {
"label": "Living Room",
"heos_name": "Denon Home 200 L",
"players": ["Denon Home 200 L", "Denon Home 200 R"], # leader first
},
``` ```
Leaving a room deliberately does *not* rewrite the AVR's group, so the other ## Configure
room's music does not restart.
## Two protocols, not one 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.
| | HEOS CLI (port 1255) | Denon Telnet (port 23) | `web_port` is this app listening port.
| --- | --- | --- |
| Speaks | JSON, `heos://player/...` | plain text, `SIGAME`, `SSFUN ?` |
| Used for | players, groups, volume | the AVR's **renamed** input list |
HEOS only knows generic input ids like `inputs/hdmi_in_1`; the names you gave Open `http://<host-ip>:<web_port>/api/targets`.
your sources live in the AVR's own protocol, which is why both are here.
The Telnet connection is held open, so input changes made with the physical `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).
remote show up in the panel too. Some Denon models only accept **one** Telnet
connection at a time — if another integration (Home Assistant, say) already `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.
holds it, the AVR card will read `offline` while the HEOS half keeps working.
`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 ## HTTP API
Used by the interface: Used by the interface:
| | | | HTTP Call | Description |
| --- | --- | | --- | --- |
| `GET /api/state` | everything the UI draws, in one call | | `GET /api/state` | everything the UI draws, in one call |
| `GET /api/targets` | every player and group HEOS can see | | `GET /api/targets` | every player and group HEOS can see |
| `POST /api/volume` | `{"target": "home400", "steps": 1}` — taps, snapped to `VOLUME_STEP`. Also takes `delta` (raw points) or `level` (absolute) | | `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": "home400"}` | | `POST /api/mute` | `{"target": "lego_room"}` |
| `POST /api/group` | `{"target": "home400", "joined": true}` | | `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 | | `POST /api/group/none` | every room back on its own |
| `GET /api/avr/inputs` | your renamed sources | | `GET /api/avr/inputs` | your renamed sources, over HEOS |
| `POST /api/avr/input` | `{"code": "GAME"}` | | `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 |
`POST /volume/up` and `/volume/down` take one snapped tap by default; pass ## Demo/Tests
`?step=3` and they move that many raw points instead, as they always did.
The original bridge's endpoints still answer, so existing Shortcuts and
scripts keep working: `/targets`, `/volume`, `/volume/{set,up,down,mute}`,
`/playback/{play,pause,stop,next,previous}`, `/group/{create,remove}`,
`/inputs`, `/input/{set,relay}`, `/avr/{raw,input,inputs}`, `/raw/<command>`.
Two of them are worth keeping for troubleshooting:
```
GET /raw/browse/browse?sid=1027 # any heos:// command, raw reply
GET /avr/raw?cmd=SSFUN ? # any Telnet command, every line back
```
## Working on it
```bash ```bash
python3 app.py --demo # fake speakers, real interface python3 app.py --demo # fake speakers, real interface
python3 -m unittest discover -s tests -t . # runs against a fake HEOS network 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 python3 tools/make_icons.py # re-render the icons from static/logo.svg
``` ```
## Layout
```
app.py Flask: the UI, the API, and the old bridge's routes
controller.py what a room is, what grouping means, volume
heos.py HEOS CLI client (persistent socket, reconnects itself)
avr.py Denon Telnet client + the renamed input list
config.py your devices and preferences
demo.py fake speakers for --demo
templates/ static/ the interface
tests/ fake HEOS + AVR servers, and tests against them
```
+205 -26
View File
@@ -2,7 +2,11 @@
"""HEOS panel: a phone-sized web remote plus the HTTP bridge it runs on. """HEOS panel: a phone-sized web remote plus the HTTP bridge it runs on.
pip3 install -r requirements.txt pip3 install -r requirements.txt
python3 app.py # http://<pi-ip>:5005/ 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 Everything the UI does goes through /api/*. The flatter, query-string
endpoints from the original heos_bridge.py (/volume/up?target=..., and endpoints from the original heos_bridge.py (/volume/up?target=..., and
@@ -10,25 +14,63 @@ friends) are still here so existing Shortcuts and scripts keep working.
""" """
import argparse import argparse
import os
from functools import wraps from functools import wraps
from flask import Flask, jsonify, render_template, request from flask import Flask, jsonify, render_template, request, url_for
from werkzeug.middleware.proxy_fix import ProxyFix from werkzeug.middleware.proxy_fix import ProxyFix
import config import config
from avr import AvrError
from controller import Controller, TargetError from controller import Controller, TargetError
from heos import HeosError from heos import HeosError
from spotify import SpotifyClient, SpotifyError
from zidoo import ZidooError
app = Flask(__name__) app = Flask(__name__)
# Served straight from port 5005 this changes nothing. Behind a reverse # 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 # 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 # X-Forwarded-Prefix that proxy sets, so every URL the app generates is
# /heos/... instead of /..., and the page works either way. # /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) app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)
controller: Controller = None 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): def handle_errors(view):
@@ -39,7 +81,7 @@ def handle_errors(view):
return view(*args, **kwargs) return view(*args, **kwargs)
except (TargetError, ValueError) as exc: except (TargetError, ValueError) as exc:
return jsonify({"error": str(exc)}), 400 return jsonify({"error": str(exc)}), 400
except (HeosError, AvrError) as exc: except (HeosError, SpotifyError, ZidooError) as exc:
return jsonify({"error": str(exc)}), 502 return jsonify({"error": str(exc)}), 502
return wrapped return wrapped
@@ -65,15 +107,76 @@ def _target_from(data: dict, field: str = "target") -> str:
return key 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 ----------------------------------------------------------- # --- The UI -----------------------------------------------------------
@app.route("/") @app.route("/")
def index(): def index():
return render_template( return render_template(
"index.html", "index.html",
app_name=config.APP_NAME, app_name=config.APP_NAME,
host=config.TARGETS[config.HOST_KEY], host=_room_meta(config.HOST_KEY, "avr"),
rooms=[{"key": key, **config.TARGETS[key]} for key in config.ROOM_KEYS], rooms=[
_room_meta(key, config.TARGETS[key].get("in_room_group", "none"))
for key in config.ROOM_KEYS
],
step=config.VOLUME_STEP, step=config.VOLUME_STEP,
spotify_accounts=[{"key": key, "label": config.SPOTIFY_ACCOUNTS[key]["label"]} for key in spotify],
) )
@@ -90,7 +193,24 @@ def manifest():
@app.get("/api/state") @app.get("/api/state")
@handle_errors @handle_errors
def api_state(): def api_state():
return jsonify(controller.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") @app.get("/api/targets")
@@ -136,6 +256,53 @@ def api_mute():
return jsonify({"target": key, "ok": True}) 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") @app.post("/api/group")
@handle_errors @handle_errors
def api_group(): def api_group():
@@ -161,8 +328,7 @@ def api_group_none():
@app.get("/api/avr/inputs") @app.get("/api/avr/inputs")
@handle_errors @handle_errors
def api_avr_inputs(): def api_avr_inputs():
refresh = request.args.get("refresh") in ("1", "true", "yes") return jsonify(controller.avr_inputs())
return jsonify(controller.avr.inputs(refresh=refresh))
@app.post("/api/avr/input") @app.post("/api/avr/input")
@@ -170,8 +336,29 @@ def api_avr_inputs():
def api_avr_set_input(): def api_avr_set_input():
code = _payload().get("code") code = _payload().get("code")
if not code: if not code:
raise ValueError("Provide 'code', e.g. GAME -- see GET /api/avr/inputs") raise ValueError("Provide 'code', e.g. inputs/aux_in_1 -- see GET /api/avr/inputs")
return jsonify(controller.avr.select_input(code)) 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 ----------------------------- # --- The original bridge's API, unchanged -----------------------------
@@ -298,34 +485,25 @@ def legacy_raw(subpath):
return jsonify(controller.heos.command(subpath, **request.args.to_dict())) return jsonify(controller.heos.command(subpath, **request.args.to_dict()))
@app.get("/avr/raw")
@handle_errors
def legacy_avr_raw():
cmd = request.args.get("cmd")
if not cmd:
raise ValueError("provide '?cmd=<raw telnet command>'")
return jsonify({"lines": controller.avr.telnet.request(cmd, timeout=3.0)})
@app.get("/avr/inputs") @app.get("/avr/inputs")
@handle_errors @handle_errors
def legacy_avr_inputs(): def legacy_avr_inputs():
return jsonify(controller.avr.inputs(refresh=True)) return jsonify(controller.avr_inputs())
@app.route("/avr/input", methods=["GET", "POST"]) @app.route("/avr/input", methods=["GET", "POST"])
@handle_errors @handle_errors
def legacy_avr_input(): def legacy_avr_input():
if request.method == "GET": if request.method == "GET":
return jsonify(controller.avr.current_input() or {"error": "AVR not reachable"}) return jsonify(controller.avr_current_input() or {"error": "AVR not reachable"})
code = request.args.get("input") code = request.args.get("input")
if not code: if not code:
raise ValueError("provide '?input=<code>', e.g. GAME, TV, CD, AUX1") raise ValueError("provide '?input=<code>', e.g. inputs/aux_in_1 -- see GET /avr/inputs")
return jsonify(controller.avr.select_input(code)) return jsonify(controller.avr_select_input(code))
def main(): def main():
global controller global controller, spotify
parser = argparse.ArgumentParser(description="HEOS panel") parser = argparse.ArgumentParser(description="HEOS panel")
parser.add_argument("--port", type=int, default=config.WEB_PORT) parser.add_argument("--port", type=int, default=config.WEB_PORT)
parser.add_argument("--host", default="0.0.0.0", parser.add_argument("--host", default="0.0.0.0",
@@ -340,6 +518,7 @@ def main():
controller = DemoController(config) controller = DemoController(config)
else: else:
controller = Controller(config) controller = Controller(config)
spotify = _build_spotify()
app.run(host=args.host, port=args.port, threaded=True) app.run(host=args.host, port=args.port, threaded=True)
-255
View File
@@ -1,255 +0,0 @@
"""Denon AVR control over the classic Telnet protocol (TCP port 23).
Nothing to do with HEOS. Commands are short plain-text strings ending in
a bare \\r: "SI?" asks which input is selected, "SIGAME" selects GAME,
"SSFUN ?" lists the sources *under the names you gave them* -- which is
the only reason we bother with this protocol at all, since HEOS only
ever reports generic identifiers like "inputs/hdmi_in_1".
The AVR also pushes a line at us whenever anything changes, including
changes made from the physical remote. So instead of polling, we hold
the connection open, read continuously, and keep the last value of each
status prefix. Asking for the current input is then free.
"""
import socket
import threading
import time
# Status prefixes worth remembering from the AVR's chatter.
_TRACKED = ("SI", "PW", "MV", "MU")
_LOG_LIMIT = 200
class AvrError(RuntimeError):
pass
class DenonTelnet:
"""Persistent listener + request/response on one Telnet connection."""
def __init__(self, host: str, port: int = 23, connect_timeout: float = 3.0):
self.host = host
self.port = port
self.connect_timeout = connect_timeout
self.status = {} # "SI" -> "MPLAY"
self.connected = False
self.last_error = None
self._sock = None
self._send_lock = threading.Lock()
self._cv = threading.Condition()
self._log = [] # [(seq, line)], newest last
self._seq = 0
threading.Thread(target=self._listen_forever, daemon=True).start()
# -- background reader ---------------------------------------------
def _listen_forever(self):
backoff = 1.0
while True:
try:
self._open()
backoff = 1.0
self._read_forever()
except OSError as exc:
self._drop(exc)
time.sleep(backoff)
backoff = min(30.0, backoff * 2)
def _open(self):
sock = socket.create_connection((self.host, self.port), timeout=self.connect_timeout)
sock.settimeout(60.0)
self._sock = sock
self.connected = True
self.last_error = None
# Prime the status cache so the first page load knows the input.
for probe in ("PW?", "SI?"):
self.send(probe)
def _read_forever(self):
buffer = b""
while True:
try:
chunk = self._sock.recv(1024)
except socket.timeout:
continue # the AVR is simply quiet; nothing has changed
if not chunk:
raise ConnectionError("AVR closed the connection")
buffer += chunk
while b"\r" in buffer:
raw, buffer = buffer.split(b"\r", 1)
self._ingest(raw.decode("utf-8", "replace").strip())
def _drop(self, exc):
self.connected = False
self.last_error = str(exc)
if self._sock is not None:
try:
self._sock.close()
except OSError:
pass
self._sock = None
def _ingest(self, line: str):
if not line:
return
with self._cv:
self._seq += 1
self._log.append((self._seq, line))
del self._log[:-_LOG_LIMIT]
for prefix in _TRACKED:
# SSFUN* also starts with 'SS', never with a tracked prefix,
# so a plain startswith is safe here.
if line.startswith(prefix) and len(line) > len(prefix):
self.status[prefix] = line[len(prefix):]
break
self._cv.notify_all()
# -- sending -------------------------------------------------------
def send(self, command: str):
sock = self._sock
if sock is None:
raise AvrError(f"AVR at {self.host} is not connected ({self.last_error or 'no connection'})")
with self._send_lock:
sock.sendall(command.encode("utf-8") + b"\r")
time.sleep(0.05) # the AVR wants a beat between commands
def request(self, command: str, prefix: str = None, until=None, timeout: float = 2.5) -> list:
"""Send a command and collect the reply lines it triggers.
Returns as soon as a matching line arrives (or, with `until`, as
soon as that terminator line does), so a query costs milliseconds
rather than a fixed timeout.
"""
with self._cv:
cursor = self._seq
self.send(command)
deadline = time.monotonic() + timeout
with self._cv:
while True:
lines = [
line for seq, line in self._log
if seq > cursor and (prefix is None or line.startswith(prefix))
]
if lines and (until is None or any(until(line) for line in lines)):
return lines
remaining = deadline - time.monotonic()
if remaining <= 0:
return lines
self._cv.wait(remaining)
def recent_lines(self) -> list:
with self._cv:
return [line for _, line in self._log]
def parse_ssfun(lines: list) -> list:
"""Parse `SSFUN ?` output -- 'SSFUNBD Blu-ray ' and friends --
into [{"code": "BD", "name": "Blu-ray"}, ...]."""
sources = []
for line in lines:
if not line.startswith("SSFUN"):
continue
rest = line[len("SSFUN"):]
if rest.strip() in ("END", ""):
continue
parts = rest.split(" ", 1)
if len(parts) != 2:
continue
code, name = parts[0].strip(), parts[1].strip()
if code and name:
sources.append({"code": code, "name": name})
return sources
def parse_sssod(lines: list) -> dict:
"""Parse `SSSOD ?` output -- 'SSSODTUNER DEL' and friends -- into
{"TUNER": False, "CD": True, ...}, i.e. which sources you have left
switched on in the AVR's own setup menu."""
usage = {}
for line in lines:
if not line.startswith("SSSOD"):
continue
rest = line[len("SSSOD"):].strip()
if rest in ("END", ""):
continue
code, _, value = rest.rpartition(" ")
code = code.strip()
if code:
usage[code] = value.strip().upper() != "DEL"
return usage
class AvrControl:
"""The input list and the current input, in the names you chose."""
def __init__(self, host: str, port: int = 23, allowed_codes=()):
self.telnet = DenonTelnet(host, port)
self.allowed_codes = list(allowed_codes or [])
self._inputs = None
self._usage = None
@property
def connected(self) -> bool:
return self.telnet.connected
def all_inputs(self, refresh: bool = False) -> list:
"""Every source the AVR knows, under your names, deleted ones
included. Cached: it only changes when you edit the setup menu."""
if self._inputs is None or refresh:
lines = self.telnet.request(
"SSFUN ?", prefix="SSFUN",
until=lambda line: line.strip() == "SSFUN END",
timeout=3.0,
)
sources = parse_ssfun(lines)
if sources:
self._inputs = sources
if self._usage is None or refresh:
lines = self.telnet.request(
"SSSOD ?", prefix="SSSOD",
until=lambda line: line.strip() == "SSSOD END",
timeout=3.0,
)
self._usage = parse_sssod(lines)
return self._inputs or []
def inputs(self, refresh: bool = False) -> list:
"""What the picker offers: the sources you can actually select.
Sources you deleted in the AVR's setup menu are left out -- they
are exactly the ones you never want to land on. Anything SSSOD
does not mention is kept, so a model that does not answer that
command shows its whole list rather than nothing at all.
"""
sources = [s for s in self.all_inputs(refresh) if self._usage.get(s["code"], True)]
if self.allowed_codes:
order = {code: i for i, code in enumerate(self.allowed_codes)}
sources = sorted(
(s for s in sources if s["code"] in order),
key=lambda s: order[s["code"]],
)
return sources
def current_input(self) -> dict:
"""{"code": "MPLAY", "name": "Apple TV"} -- the name comes from the
cached source list, the code from the AVR's own push messages."""
code = self.telnet.status.get("SI")
if code is None:
lines = self.telnet.request("SI?", prefix="SI")
code = lines[0][2:] if lines else None
if code is None:
return None
return {"code": code, "name": self.name_for(code)}
def name_for(self, code: str) -> str:
for source in self.all_inputs():
if source["code"] == code:
return source["name"]
return code
def select_input(self, code: str) -> dict:
self.telnet.request(f"SI{code}", prefix="SI", timeout=1.5)
self.telnet.status["SI"] = code # trust our own command immediately
return {"code": code, "name": self.name_for(code)}
+30
View File
@@ -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"
}
}
+101 -47
View File
@@ -5,65 +5,119 @@ first time you run this, so the names below match exactly what HEOS
reports for your own devices. reports for your own devices.
""" """
# --- Network ---------------------------------------------------------- import json
# Any ONE HEOS device's IP is enough: HEOS is a distributed system, so import os
# whichever unit you connect to can see and control every player and from pathlib import Path
# group on the network.
HEOS_HOST = "192.168.0.10"
HEOS_PORT = 1255
# The AVR's classic Denon Telnet control port. Completely separate from
# HEOS, and the only place your *renamed* input list actually lives --
# HEOS itself only knows a fixed set of generic input identifiers.
AVR_HOST = "192.168.0.10"
AVR_PORT = 23
# Port the panel itself listens on. def _load_dotenv(path: Path):
WEB_PORT = 5005 """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())
# --- Rooms ------------------------------------------------------------
# key -> how to find it on the network, and how to label it in the UI. _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 or group. # heos_name : the EXACT name HEOS reports for that player -- including
# players : only for a target that is a HEOS *group* (a stereo pair # an In-Room Group like a stereo pair, which HEOS pairs at
# or an In-Room Group). List its member players, leader # the hardware level into one player, one pid, always.
# first. Leave it out and the panel learns the members the # spotify_name : only needed if a room's Spotify Connect name differs
# first time it sees the group un-merged, then remembers # from heos_name -- GET /api/spotify/devices?account=account1 shows what
# them in members.json -- which is what lets it rebuild the # Spotify actually calls it. Defaults to heos_name.
# pair after you unmerge it from the AVR. # in_room_group : what heos_name actually names, when it is not a single
TARGETS = { # speaker -- one of IN_ROOM_GROUPS below. HEOS merges any
"avr": { # of these into one player, one pid, at the hardware
"label": "Home Cinema", # level, the same way it does a Stereo Pair -- so, same
"heos_name": "Home Cinema", # AVR-X3800H # as heos_name itself, this can only be set by hand, not
}, # read back from HEOS. Defaults to "none".
"home400": { IN_ROOM_GROUPS = ("none", "stereo-pair", "lcr-fronts", "surround-sound-system", "subwoofer")
"label": "Lego Room",
"heos_name": "Lego Room", # Denon Home 400 _config_path = Path(__file__).resolve().parent / "config.json"
}, try:
"living_room_group": { _cfg = json.loads(_config_path.read_text())
"label": "Living Room", except FileNotFoundError:
"heos_name": "Denon Home 200 L", # 2x Denon Home 200, In-Room Group raise SystemExit(
# "players": ["Denon Home 200 L", "Denon Home 200 R"], 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 # The AVR is always the group host: its content takes over every room
# that joins, which is the whole point of the merge buttons. # that joins, which is the whole point of the merge buttons.
HOST_KEY = "avr" HOST_KEY = _cfg["host_key"]
# The rooms that get a card with volume + a join/leave button, in order. # The rooms that get a card with volume + a join/leave button, in order.
ROOM_KEYS = ["home400", "living_room_group"] ROOM_KEYS = _cfg["room_keys"]
# --- Behaviour --------------------------------------------------------
# The grid the volume buttons snap to. A tap moves to the next multiple of # The grid the volume buttons snap to. A tap moves to the next multiple of
# this rather than adding it, so at 5 a level of 23 goes to 25, not 28. # this rather than adding it, so at 5 a level of 23 goes to 25, not 28.
VOLUME_STEP = 5 VOLUME_STEP = _cfg["volume_step"]
# Sources you deleted in the AVR's setup menu are hidden from the picker # Port the panel itself listens on.
# automatically. This narrows it further to the ones you actually use, by WEB_PORT = _cfg["web_port"]
# their SI code (see GET /api/avr/inputs), and sets their order in the
# list. Empty = every source the AVR still has switched on. # --- Network ----------------------------------------------------------
AVR_INPUT_CODES = [] # 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. # Shown as the app's name on the iOS home screen.
APP_NAME = "HEOS" 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")},
}
+270 -128
View File
@@ -1,26 +1,24 @@
"""The actual behaviour of the panel, on top of the two protocol clients. """The actual behaviour of the panel, on top of the HEOS CLI client.
The interesting part is grouping. A HEOS "In-Room Group" (your pair of The interesting part is grouping. Every room here -- including the
Home 200s) is addressed by a gid and behaves like one speaker -- until living room's L/R pair, which HEOS pairs at the hardware level into a
you merge it into the AVR's group, at which point that gid stops single pid -- is addressed by one player pid, merged or not. set_group
existing and its two players are just two members of the AVR's group. replaces a group wholesale rather than adding to it, so join() and
Two consequences drive most of the code below: leave() always have to name every room that should remain in the AVR's
group, not just the one being added or removed.
* Merging must send EVERY member pid of the pair, not just its leader, Everything, including the AVR's own inputs, goes over the one HEOS
or the second Home 200 gets left behind. connection now -- there used to be a second client here for the AVR's
* Unmerging must re-issue set_group with the pair's own pids to put Denon Telnet port, only for its renamed input list, but HEOS reports
the pair back together, so we have to remember what they were. 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 json
import threading import threading
import time import time
from pathlib import Path
from avr import AvrControl, AvrError
from heos import HeosClient, HeosError, parse_message from heos import HeosClient, HeosError, parse_message
from zidoo import ZidooClient
MEMBERS_FILE = Path(__file__).with_name("members.json")
def stepped_level(current: int, steps: int, size: int) -> int: def stepped_level(current: int, steps: int, size: int) -> int:
@@ -47,16 +45,24 @@ class TargetError(ValueError):
class Controller: 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): def __init__(self, cfg):
self.cfg = cfg self.cfg = cfg
self.heos = HeosClient(cfg.HEOS_HOST, cfg.HEOS_PORT) self.heos = HeosClient(cfg.HEOS_HOST, cfg.HEOS_PORT)
self.avr = AvrControl(cfg.AVR_HOST, cfg.AVR_PORT, cfg.AVR_INPUT_CODES) 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._lock = threading.RLock()
self._members_file = Path(getattr(cfg, "MEMBERS_FILE", MEMBERS_FILE))
self._learned = _load_learned(self._members_file)
self._players = [] self._players = []
self._groups = [] self._groups = []
self._scanned_at = 0.0 self._scanned_at = 0.0
self._inputs_cache = {} # key -> (inputs, cached_at)
threading.Thread(target=self._keep_warm, daemon=True).start() threading.Thread(target=self._keep_warm, daemon=True).start()
def _keep_warm(self): def _keep_warm(self):
@@ -71,13 +77,19 @@ class Controller:
# -- network picture ----------------------------------------------- # -- network picture -----------------------------------------------
def scan(self) -> dict: def scan(self) -> dict:
"""Re-read every player and group, and remember what the rooms """Re-read every player and group, in one round trip."""
are made of while we can see them."""
with self._lock: with self._lock:
self._players = self.heos.command("player/get_players").get("payload", []) players_reply, groups_reply = self.heos.command_batch([
self._groups = self.heos.command("group/get_groups").get("payload", []) ("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() self._scanned_at = time.monotonic()
self._learn_members()
return {"players": self._players, "groups": self._groups} return {"players": self._players, "groups": self._groups}
def _fresh(self, max_age: float = 2.0): def _fresh(self, max_age: float = 2.0):
@@ -90,41 +102,6 @@ class Controller:
return player.get("pid") return player.get("pid")
return None return None
def _group_named(self, name: str):
for group in self._groups:
if group.get("name") == name:
return group
return None
@staticmethod
def _ordered_pids(group: dict) -> list:
"""Member pids with the leader first -- HEOS makes the first pid
in a set_group call the leader, so the order is not cosmetic."""
players = group.get("players", [])
leaders = [p for p in players if p.get("role") == "leader"]
others = [p for p in players if p.get("role") != "leader"]
return [p.get("pid") for p in leaders + others]
def _learn_members(self):
"""Record what each room's group is made of whenever we catch it
standing on its own, so we can rebuild it after a merge."""
host_pid = self._host_pid()
changed = False
for key in self.cfg.ROOM_KEYS:
if "players" in self.cfg.TARGETS[key]:
continue # configured by hand, nothing to learn
group = self._group_named(self.cfg.TARGETS[key]["heos_name"])
if not group:
continue
pids = self._ordered_pids(group)
if host_pid in pids:
continue # currently merged with the AVR: not its own shape
if self._learned.get(key) != pids:
self._learned[key] = pids
changed = True
if changed:
_save_learned(self._members_file, self._learned)
# -- resolving rooms to pids --------------------------------------- # -- resolving rooms to pids ---------------------------------------
def _host_pid(self): def _host_pid(self):
"""The AVR is always a plain player. Resolving it by player name """The AVR is always a plain player. Resolving it by player name
@@ -137,63 +114,25 @@ class Controller:
return pid return pid
def member_pids(self, key: str) -> list: def member_pids(self, key: str) -> list:
"""Every player that makes up a room, leader first.""" """The pid of the one player that is this room, as a list."""
if key not in self.cfg.TARGETS: if key not in self.cfg.TARGETS:
raise TargetError(f"Unknown room '{key}'") raise TargetError(f"Unknown room '{key}'")
if key == self.cfg.HOST_KEY: if key == self.cfg.HOST_KEY:
return [self._host_pid()] return [self._host_pid()]
target = self.cfg.TARGETS[key] name = self.cfg.TARGETS[key]["heos_name"]
name = target["heos_name"]
if "players" in target:
pids = []
for player_name in target["players"]:
pid = self._player_pid(player_name)
if pid is None:
raise TargetError(f"No HEOS player named '{player_name}' was found")
pids.append(pid)
return pids
# A group under this name wins over a player under the same name:
# a stereo pair is usually named after its left-hand speaker.
host_pid = self._host_pid()
group = self._group_named(name)
if group:
pids = self._ordered_pids(group)
if host_pid not in pids:
return pids
known = self._learned.get(key)
if known:
live = {p.get("pid") for p in self._players}
if all(pid in live for pid in known):
return known
pid = self._player_pid(name) pid = self._player_pid(name)
if pid is not None: if pid is None:
return [pid]
raise TargetError( raise TargetError(
f"No HEOS player or group named '{name}' was found. " f"No HEOS player named '{name}' was found. "
f"Check GET /api/targets for the names HEOS actually reports." f"Check GET /api/targets for the names HEOS actually reports."
) )
return [pid]
# -- volume --------------------------------------------------------- # -- volume ---------------------------------------------------------
def _volume_handles(self, key: str) -> list: def _volume_handles(self, key: str) -> list:
"""Where volume for this room lives right now, as (scope, id). """Where volume for this room lives right now, as (scope, id)."""
return [("player", pid) for pid in self.member_pids(key)]
A room that is its own HEOS group has a single group volume. Once
it is merged into the AVR's group that gid is gone, and the only
knobs left are the member players' own volumes.
"""
pids = self.member_pids(key)
if len(pids) > 1:
wanted = set(pids)
for group in self._groups:
if {p.get("pid") for p in group.get("players", [])} == wanted:
return [("group", group["gid"])]
return [("player", pid) for pid in pids]
@staticmethod @staticmethod
def _id_param(scope: str) -> str: def _id_param(scope: str) -> str:
@@ -295,8 +234,7 @@ class Controller:
return pids return pids
def ungroup(self, key: str) -> list: def ungroup(self, key: str) -> list:
"""Stand a room back up on its own. For the Home 200 pair this """Stand a room back up on its own."""
re-forms the pair rather than leaving two lone speakers behind."""
with self._lock: with self._lock:
self._fresh() self._fresh()
pids = self.member_pids(key) pids = self.member_pids(key)
@@ -312,8 +250,26 @@ class Controller:
wanted = set(self.joined_keys()) | {key} wanted = set(self.joined_keys()) | {key}
self.group_targets(self.cfg.HOST_KEY, [k for k in self.cfg.ROOM_KEYS if k in wanted]) self.group_targets(self.cfg.HOST_KEY, [k for k in self.cfg.ROOM_KEYS if k in wanted])
self.scan() self.scan()
self._nudge_avr_input()
return self.joined_keys() 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: def leave(self, key: str) -> list:
"""Remove one room. Deliberately does not rewrite the AVR's group: """Remove one room. Deliberately does not rewrite the AVR's group:
whatever is still joined keeps playing without a hiccup.""" whatever is still joined keeps playing without a hiccup."""
@@ -346,6 +302,64 @@ class Controller:
self._fresh() self._fresh()
self.heos.command("player/set_play_state", pid=self.playback_pid(key), state=state) 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): def skip(self, key: str, direction: str):
with self._lock: with self._lock:
self._fresh() self._fresh()
@@ -353,11 +367,22 @@ class Controller:
self.heos.command(f"player/{command}", pid=self.playback_pid(key)) self.heos.command(f"player/{command}", pid=self.playback_pid(key))
def heos_inputs(self, key: str) -> list: def heos_inputs(self, key: str) -> list:
"""Physical inputs as HEOS sees them (generic ids, not your names).""" """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: with self._lock:
self._fresh() self._fresh()
reply = self.heos.command("browse/browse", sid=self.playback_pid(key)) reply = self.heos.command("browse/browse", sid=self.playback_pid(key))
return [{"name": i.get("name"), "input_id": i.get("mid")} for i in reply.get("payload", [])] 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): def play_heos_input(self, key: str, input_id: str, source_key: str = None):
with self._lock: with self._lock:
@@ -367,8 +392,69 @@ class Controller:
params["spid"] = self.playback_pid(source_key) params["spid"] = self.playback_pid(source_key)
self.heos.command("browse/play_input", **params) 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 ------------------------------------------ # -- one snapshot for the UI ------------------------------------------
def state(self) -> dict: 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 = { snapshot = {
"host": {"key": self.cfg.HOST_KEY, "label": self.cfg.TARGETS[self.cfg.HOST_KEY]["label"]}, "host": {"key": self.cfg.HOST_KEY, "label": self.cfg.TARGETS[self.cfg.HOST_KEY]["label"]},
"rooms": [], "rooms": [],
@@ -377,10 +463,44 @@ class Controller:
"errors": [], "errors": [],
} }
host_pid = None
avr_now_playing = None
with self._lock: with self._lock:
try: try:
self.scan() self.scan()
joined = set(self.joined_keys()) 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: for key in self.cfg.ROOM_KEYS:
room = { room = {
"key": key, "key": key,
@@ -388,11 +508,39 @@ class Controller:
"available": True, "available": True,
"grouped": key in joined, "grouped": key in joined,
"volume": None, "volume": None,
"play_state": None,
"spotify": False,
"now_playing": None,
"error": None, "error": None,
} }
try: try:
scope, obj_id = self._volume_handles(key)[0] if key in room_errors:
room["volume"] = self._read_volume(scope, obj_id) 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: except (TargetError, HeosError, KeyError) as exc:
room["available"] = False room["available"] = False
room["error"] = str(exc) room["error"] = str(exc)
@@ -402,31 +550,25 @@ class Controller:
snapshot["errors"].append(str(exc)) snapshot["errors"].append(str(exc))
snapshot["rooms"] = [ snapshot["rooms"] = [
{"key": key, "label": self.cfg.TARGETS[key]["label"], "available": False, {"key": key, "label": self.cfg.TARGETS[key]["label"], "available": False,
"grouped": False, "volume": None, "error": str(exc)} "grouped": False, "volume": None, "play_state": None, "spotify": False,
"now_playing": None, "error": str(exc)}
for key in self.cfg.ROOM_KEYS for key in self.cfg.ROOM_KEYS
] ]
try: 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"] = { snapshot["avr"] = {
"connected": self.avr.connected, "connected": host_pid is not None,
"inputs": self.avr.inputs(), "inputs": self.avr_inputs(),
"input": self.avr.current_input(), "input": current_input,
"now_playing": self.zidoo_now_playing(current_input),
} }
except (AvrError, OSError) as exc: except (HeosError, TargetError) as exc:
snapshot["errors"].append(str(exc)) snapshot["errors"].append(str(exc))
return snapshot return snapshot
def _load_learned(path: Path) -> dict:
try:
return json.loads(path.read_text())
except (OSError, ValueError):
return {}
def _save_learned(path: Path, data: dict):
try:
path.write_text(json.dumps(data, indent=2))
except OSError:
pass # a read-only checkout just means we re-learn next time
+57 -18
View File
@@ -10,26 +10,24 @@ from controller import stepped_level
class _FakeAvr: 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 = [ INPUTS = [
{"code": "MPLAY", "name": "Apple TV"}, {"code": "inputs/mediaplayer", "name": "Apple TV"},
{"code": "GAME", "name": "PlayStation"}, {"code": "inputs/game", "name": "PlayStation"},
{"code": "SAT/CBL", "name": "TV Box"}, {"code": "inputs/tvaudio", "name": "TV Box"},
{"code": "BD", "name": "Blu-ray"}, {"code": "inputs/bluray", "name": "Blu-ray"},
{"code": "TUNER", "name": "Radio"}, {"code": "inputs/tuner", "name": "Radio"},
{"code": "PHONO", "name": "Turntable"}, {"code": "inputs/phono", "name": "Turntable"},
] ]
def __init__(self, allowed_codes=()): def __init__(self):
self.allowed_codes = list(allowed_codes or [])
self.connected = True self.connected = True
self._code = "MPLAY" self._code = "inputs/mediaplayer"
def inputs(self, refresh=False): def inputs(self, refresh=False):
sources = list(self.INPUTS) return list(self.INPUTS)
if self.allowed_codes:
order = {code: i for i, code in enumerate(self.allowed_codes)}
sources = sorted((s for s in sources if s["code"] in order), key=lambda s: order[s["code"]])
return sources
def name_for(self, code): def name_for(self, code):
return next((s["name"] for s in self.INPUTS if s["code"] == code), code) return next((s["name"] for s in self.INPUTS if s["code"] == code), code)
@@ -46,10 +44,18 @@ class _FakeAvr:
class DemoController: class DemoController:
def __init__(self, cfg): def __init__(self, cfg):
self.cfg = cfg self.cfg = cfg
self.avr = _FakeAvr(cfg.AVR_INPUT_CODES) self.avr = _FakeAvr()
self.heos = None self.heos = None
self._volume = {key: 22 + 7 * i for i, key in enumerate(cfg.TARGETS)} self._volume = {key: 22 + 7 * i for i, key in enumerate(cfg.TARGETS)}
self._play = {key: "play" for key in cfg.TARGETS}
self._joined = set() 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 --------------------------------------------- # -- what the UI uses ---------------------------------------------
def state(self): def state(self):
@@ -57,10 +63,21 @@ class DemoController:
"host": {"key": self.cfg.HOST_KEY, "label": self.cfg.TARGETS[self.cfg.HOST_KEY]["label"]}, "host": {"key": self.cfg.HOST_KEY, "label": self.cfg.TARGETS[self.cfg.HOST_KEY]["label"]},
"rooms": [ "rooms": [
{"key": key, "label": self.cfg.TARGETS[key]["label"], "available": True, {"key": key, "label": self.cfg.TARGETS[key]["label"], "available": True,
"grouped": key in self._joined, "volume": self._volume[key], "error": None} "grouped": key in self._joined, "volume": self._volume[key],
"play_state": self._play[key], "spotify": self._spotify[key],
"spotify_account": self._account if self._spotify[key] else None,
"now_playing": self._track if self._spotify[key] and self._play[key] != "stop" else None,
"error": None}
for key in self.cfg.ROOM_KEYS for key in self.cfg.ROOM_KEYS
], ],
"avr": {"connected": True, "inputs": self.avr.inputs(), "input": self.avr.current_input()}, "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, "heos_ok": True,
"errors": [], "errors": [],
"demo": True, "demo": True,
@@ -82,6 +99,15 @@ class DemoController:
def toggle_mute(self, key): def toggle_mute(self, key):
return None 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): def join(self, key):
self._joined.add(key) self._joined.add(key)
return self.joined_keys() return self.joined_keys()
@@ -94,6 +120,9 @@ class DemoController:
self._joined = {k for k in self.cfg.ROOM_KEYS if k in joined} self._joined = {k for k in self.cfg.ROOM_KEYS if k in joined}
return self.joined_keys() return self.joined_keys()
def zidoo_seek(self, position_ms):
self._film_ms = min(596000, int(position_ms))
def joined_keys(self): def joined_keys(self):
return [k for k in self.cfg.ROOM_KEYS if k in self._joined] return [k for k in self.cfg.ROOM_KEYS if k in self._joined]
@@ -118,7 +147,17 @@ class DemoController:
return None return None
def heos_inputs(self, key): def heos_inputs(self, key):
return [{"name": s["name"], "input_id": f"inputs/{s['code'].lower()}"} for s in self.avr.inputs()] return [{"name": s["name"], "input_id": s["code"]} for s in self.avr.inputs()]
def play_heos_input(self, key, input_id, source_key=None): def play_heos_input(self, key, input_id, source_key=None):
return 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)
+13 -2
View File
@@ -22,8 +22,19 @@ Type=simple
User=franzz User=franzz
Group=www-data Group=www-data
WorkingDirectory=/var/www/html/heos WorkingDirectory=/var/www/html/heos
ExecStart=/var/www/html/heos/.venv/bin/python /var/www/html/heos/app.py # Optional (the leading "-" means systemd won't refuse to start without
# Add --host 127.0.0.1 above to allow only the reverse proxy in. # 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 Restart=always
RestartSec=3 RestartSec=3
+5 -5
View File
@@ -1,13 +1,13 @@
# HEOS panel behind Apache, at /heos # HEOS panel behind Apache, at /heos
# #
# sudo a2enmod proxy proxy_http headers # sudo a2enmod proxy proxy_http headers
# sudo cp /var/www/html/heos/deploy/heos.conf /etc/apache2/conf-available/heos.conf # sudo cp /var/www/html/heos/deploy/heos.apache.conf /etc/apache2/conf-available/heos.conf
# sudo a2enconf heos # sudo a2enconf heos
# sudo apachectl configtest && sudo systemctl reload apache2 # sudo apachectl configtest && sudo systemctl reload apache2
# #
# Apache reaches the panel on port 5005 (WEB_PORT in config.py). If you # 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; # 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>:5005. # 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 # This block also takes /heos away from the filesystem, so the source in
# /var/www/html/heos stops being reachable as static files. # /var/www/html/heos stops being reachable as static files.
@@ -26,6 +26,6 @@
# page loads and nothing on it works. # page loads and nothing on it works.
RequestHeader set X-Forwarded-Prefix /heos RequestHeader set X-Forwarded-Prefix /heos
ProxyPass http://127.0.0.1:5005 ProxyPass http://127.0.0.1:5443
ProxyPassReverse http://127.0.0.1:5005 ProxyPassReverse http://127.0.0.1:5443
</Location> </Location>
+64 -20
View File
@@ -1,32 +1,74 @@
# HEOS panel behind nginx, at /heos # The HEOS panel at https://domain.com/heos
# #
# sudo cp /var/www/html/heos/deploy/heos.nginx.conf /etc/nginx/snippets/heos.conf # sudo cp /var/www/html/heos/deploy/heos.nginx.conf /etc/nginx/sites-available/heos
# then inside the server { } block that serves the site: # sudo ln -s /etc/nginx/sites-available/heos /etc/nginx/sites-enabled/heos
# include snippets/heos.conf;
# sudo nginx -t && sudo systemctl reload nginx # sudo nginx -t && sudo systemctl reload nginx
# #
# nginx reaches the panel on port 5005 (WEB_PORT in config.py). If you want # server_name and the certificate paths have to agree: the paths are the
# it reachable ONLY through nginx, start it with --host 127.0.0.1; by # directory certbot made for that name.
# default it also answers directly on the LAN at <pi-ip>:5005. #
# 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.
# A bare /heos would miss the location below and fall through to the server {
# filesystem, so send it to the slashed form first. 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 { location = /heos {
return 301 /heos/; return 301 /heos/;
} }
location /heos/ { location /heos/ {
# The panel controls the speakers, and it usually hangs off a host # The panel controls the speakers, and this hostname may well
# with a public certificate. Keep it to the house unless you mean # resolve from outside. Keep it to the house unless you mean
# otherwise: drop these four lines to let it answer from anywhere. # otherwise: drop these four lines to let it answer from anywhere.
allow 192.168.0.0/24; allow 192.168.0.0/24;
allow 127.0.0.1; allow 127.0.0.1;
allow ::1; allow ::1;
deny all; deny all;
# The trailing slash on proxy_pass is what strips /heos/ back off # The trailing slash is what strips /heos/ back off before the
# before the request reaches the app. # request reaches the app.
proxy_pass http://127.0.0.1:5005/; proxy_pass http://127.0.0.1:5443/;
# Tells the app it is mounted on a sub-path, so every link, icon and # 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 # fetch it generates is /heos/... rather than /... Without this the
@@ -41,17 +83,19 @@ location /heos/ {
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Connection ""; proxy_set_header Connection "";
} }
}
# Giving it a host of its own instead? Then it is not on a sub-path, and # Want the panel at the root of this host instead of under /heos? Replace
# the X-Forwarded-Prefix line above is the one thing to leave out: # 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.
# #
# server {
# server_name heos.example.com;
# location / { # location / {
# proxy_pass http://127.0.0.1:5005; # proxy_pass http://127.0.0.1:5443;
# proxy_set_header Host $host; # proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr; # proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme; # proxy_set_header X-Forwarded-Proto $scheme;
# } # proxy_http_version 1.1;
# proxy_set_header Connection "";
# } # }
+104
View File
@@ -43,6 +43,11 @@ class HeosClient:
self._sock = None self._sock = None
self._buffer = b"" self._buffer = b""
self._lock = threading.Lock() 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 ----------------------------------------- # -- connection management -----------------------------------------
def _connect(self): def _connect(self):
@@ -51,6 +56,14 @@ class HeosClient:
sock.settimeout(self.timeout) sock.settimeout(self.timeout)
self._sock = sock self._sock = sock
self._buffer = b"" 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): def _close(self):
if self._sock is not None: if self._sock is not None:
@@ -106,6 +119,7 @@ class HeosClient:
heos = reply.get("heos", {}) heos = reply.get("heos", {})
if heos.get("command") != path: if heos.get("command") != path:
self._watch_progress(heos)
continue # an event or a late reply to something else continue # an event or a late reply to something else
if "under process" in heos.get("message", ""): if "under process" in heos.get("message", ""):
continue # placeholder ack; the real payload follows continue # placeholder ack; the real payload follows
@@ -113,6 +127,96 @@ class HeosClient:
raise HeosError(_failure_text(heos)) raise HeosError(_failure_text(heos))
return reply 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): def heart_beat(self):
"""Keep the socket warm so the first press after an idle spell is """Keep the socket warm so the first press after an idle spell is
as fast as the rest.""" as fast as the rest."""
+1
View File
@@ -1 +1,2 @@
flask>=3.0 flask>=3.0
gunicorn>=21
+155
View File
@@ -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
+449 -37
View File
@@ -3,31 +3,94 @@
network round trip before it looks like it did anything feels broken. */ network round trip before it looks like it did anything feels broken. */
const STEP = Number(document.documentElement.dataset.step) || 2; const STEP = Number(document.documentElement.dataset.step) || 2;
const HOST_LABEL = document.documentElement.dataset.host || 'the AVR';
// Where the app is mounted: "/" on its own port, "/heos/" behind a proxy. // Where the app is mounted: "/" on its own port, "/heos/" behind a proxy.
const BASE = document.documentElement.dataset.base || '/'; const BASE = document.documentElement.dataset.base || '/';
const POLL_MS = 5000; 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 el = (sel, root = document) => root.querySelector(sel);
const els = (sel, root = document) => Array.from(root.querySelectorAll(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 = { const ui = {
foot: el('[data-role="foot"]'), foot: el('[data-role="foot"]'),
toast: el('[data-role="toast"]'), toast: el('[data-role="toast"]'),
refresh: el('[data-role="refresh"]'),
splitAll: el('[data-role="split-all"]'),
avrStatus: el('[data-role="avr-status"]'), avrStatus: el('[data-role="avr-status"]'),
inputButton: el('[data-role="input-button"]'), inputButton: el('[data-role="input-button"]'),
inputName: el('[data-role="input-name"]'), inputName: el('[data-role="input-name"]'),
inputIcon: el('[data-role="input-icon"]'),
sheet: el('[data-role="sheet"]'), sheet: el('[data-role="sheet"]'),
options: el('[data-role="options"]'), options: el('[data-role="options"]'),
source: el('.card.source'),
joined: el('[data-role="joined"]'), joined: el('[data-role="joined"]'),
joinedRooms: el('[data-role="joined-rooms"]'), 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 = {}; const rooms = {};
let inputs = []; let inputs = [];
let currentInput = null; let currentInput = null;
let avrNowPlaying = null;
/* --- transport -------------------------------------------------------- */ /* --- transport -------------------------------------------------------- */
async function api(path, body) { async function api(path, body) {
@@ -42,8 +105,10 @@ async function api(path, body) {
} }
let toastTimer; let toastTimer;
function toast(message) { /* Red by default, since most toasts report trouble; 'ok' for a confirmation. */
function toast(message, kind = 'error') {
ui.toast.textContent = message; ui.toast.textContent = message;
ui.toast.classList.toggle('ok', kind === 'ok');
ui.toast.hidden = false; ui.toast.hidden = false;
clearTimeout(toastTimer); clearTimeout(toastTimer);
toastTimer = setTimeout(() => { ui.toast.hidden = true; }, 4000); toastTimer = setTimeout(() => { ui.toast.hidden = true; }, 4000);
@@ -57,16 +122,37 @@ els('.room').forEach((node) => {
node, node,
slot: el(`.room-slot[data-slot="${key}"]`), slot: el(`.room-slot[data-slot="${key}"]`),
level: el('[data-role="level"]', node), level: el('[data-role="level"]', node),
bar: el('[data-role="bar"]', node), meter: el('[data-role="meter"]', node),
actions: el('[data-role="actions"]', node),
toggle: el('[data-role="group"]', node), toggle: el('[data-role="group"]', node),
toggleLabel: el('[data-role="group-label"]', 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), steps: els('.step', node),
volume: null, 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, grouped: false,
available: false, available: false,
taps: 0, // button presses not yet sent taps: 0, // button presses not yet sent
wanted: null, // a level dragged to, not yet sent
dragging: false,
inflight: false, inflight: false,
busy: false, // a grouping change is in flight 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; rooms[key] = room;
@@ -74,43 +160,256 @@ els('.room').forEach((node) => {
const direction = Number(button.dataset.delta); const direction = Number(button.dataset.delta);
holdable(button, () => nudge(key, direction)); 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.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) { function paintRoom(room) {
const known = room.volume !== null && room.volume !== undefined; const known = room.volume !== null && room.volume !== undefined;
room.level.textContent = known ? room.volume : '—'; room.level.textContent = known ? room.volume : '—';
room.bar.style.width = `${known ? room.volume : 0}%`; // 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.node.classList.toggle('offline', !room.available);
room.steps.forEach((button) => { button.disabled = !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.disabled = !room.available;
room.toggle.classList.toggle('busy', room.busy); room.toggle.classList.toggle('busy', room.busy);
room.toggle.setAttribute('aria-pressed', String(room.grouped)); room.toggle.setAttribute('aria-pressed', String(room.grouped));
// Where the card sits already says whether it is grouped, so the button // Out of the group the button names where tapping it sends the room; in it,
// says what tapping it does instead. // where the card sits already says so, so the button says what it does.
room.toggleLabel.textContent = room.grouped ? 'Leave' : `Join ${HOST_LABEL}`; 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); 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 /* 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 means: one group, playing one thing. Leaving puts the card back in its
own slot, which is why the slots exist. */ own slot, which is why the slots exist. */
function placeRoom(room) { function placeRoom(room) {
const target = room.grouped ? ui.joinedRooms : room.slot; const target = room.grouped ? ui.joined : room.slot;
if (room.node.parentElement !== target) target.appendChild(room.node); 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() { function paintGrouping() {
const order = Object.keys(rooms); const order = Object.keys(rooms);
const inside = Array.from(ui.joinedRooms.children); const inside = Array.from(ui.joined.children);
// Keep them in the order the cards are declared, not the order they joined. // Keep them in the order the cards are declared, not the order they joined.
inside inside
.slice() .slice()
.sort((a, b) => order.indexOf(a.dataset.room) - order.indexOf(b.dataset.room)) .sort((a, b) => order.indexOf(a.dataset.room) - order.indexOf(b.dataset.room))
.forEach((node) => ui.joinedRooms.appendChild(node)); .forEach((node) => ui.joined.appendChild(node));
ui.joined.hidden = inside.length === 0; ui.joined.hidden = inside.length === 0;
ui.splitAll.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. */ /* Press and hold to keep moving, accelerating as you hold. */
@@ -158,13 +457,23 @@ function nudge(key, direction) {
thirty requests the speakers then have to chew through. */ thirty requests the speakers then have to chew through. */
async function flushVolume(key) { async function flushVolume(key) {
const room = rooms[key]; const room = rooms[key];
if (room.inflight || !room.taps) return; if (room.inflight) return;
const steps = room.taps; // 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; room.taps = 0;
} else {
return;
}
room.inflight = true; room.inflight = true;
try { try {
const data = await api('/api/volume', { target: key, steps }); const data = await api('/api/volume', body);
if (!room.taps) { if (!room.taps && room.wanted === null && !room.dragging) {
room.volume = data.level; room.volume = data.level;
paintRoom(room); paintRoom(room);
} }
@@ -173,10 +482,52 @@ async function flushVolume(key) {
refresh(); refresh();
} finally { } finally {
room.inflight = false; room.inflight = false;
if (room.taps) flushVolume(key); 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) { async function setGrouped(key, joined) {
const room = rooms[key]; const room = rooms[key];
if (room.busy || !room.available) return; if (room.busy || !room.available) return;
@@ -195,6 +546,40 @@ async function setGrouped(key, joined) {
} }
} }
/* 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) { function applyJoined(joined) {
Object.values(rooms).forEach((room) => { Object.values(rooms).forEach((room) => {
room.grouped = joined.includes(room.key); room.grouped = joined.includes(room.key);
@@ -203,17 +588,32 @@ function applyJoined(joined) {
paintGrouping(); paintGrouping();
} }
ui.splitAll.addEventListener('click', async () => { /* --- 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 { try {
applyJoined([]); const data = await api('/api/spotify/resume', { target, account });
const data = await api('/api/group/none', {}); // {} so it is a POST, as the route requires toast(`Resuming ${who}'s Spotify on ${data.device}`, 'ok');
applyJoined(data.joined || []); 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) { } catch (error) {
toast(error.message); toast(error.message);
} finally { } finally {
refresh(); button.disabled = false;
}
} }
});
/* --- the input picker -------------------------------------------------- */ /* --- the input picker -------------------------------------------------- */
ui.inputButton.addEventListener('click', openSheet); ui.inputButton.addEventListener('click', openSheet);
@@ -222,6 +622,10 @@ el('[data-role="sheet-close"]').addEventListener('click', closeSheet);
function sheetOpen() { return !ui.sheet.hidden; } function sheetOpen() { return !ui.sheet.hidden; }
function setInputIcon(code) {
paintIcon(ui.inputIcon, inputIcon(code));
}
function openSheet() { function openSheet() {
if (!inputs.length) { if (!inputs.length) {
toast('No inputs reported by the AVR yet'); toast('No inputs reported by the AVR yet');
@@ -232,9 +636,10 @@ function openSheet() {
const option = document.createElement('button'); const option = document.createElement('button');
option.className = 'option'; option.className = 'option';
option.setAttribute('aria-current', String(currentInput && currentInput.code === source.code)); option.setAttribute('aria-current', String(currentInput && currentInput.code === source.code));
option.innerHTML = '<span></span><span class="code"></span>'; 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>';
option.firstChild.textContent = source.name; paintIcon(option.querySelector('svg'), inputIcon(source.code));
option.lastChild.textContent = source.code; option.querySelector('.option-label span').textContent = source.name;
option.querySelector('.code').textContent = source.code;
option.addEventListener('click', () => chooseInput(source)); option.addEventListener('click', () => chooseInput(source));
ui.options.appendChild(option); ui.options.appendChild(option);
}); });
@@ -247,10 +652,12 @@ async function chooseInput(source) {
closeSheet(); closeSheet();
currentInput = source; currentInput = source;
ui.inputName.textContent = source.name; ui.inputName.textContent = source.name;
setInputIcon(source.code);
try { try {
const data = await api('/api/avr/input', { code: source.code }); const data = await api('/api/avr/input', { code: source.code });
currentInput = data; currentInput = data;
ui.inputName.textContent = data.name; ui.inputName.textContent = data.name;
setInputIcon(data.code);
} catch (error) { } catch (error) {
toast(error.message); toast(error.message);
refresh(); refresh();
@@ -265,17 +672,26 @@ function render(state) {
room.available = incoming.available; room.available = incoming.available;
room.grouped = incoming.grouped; room.grouped = incoming.grouped;
// Do not stomp on a volume the user is in the middle of changing. // Do not stomp on a volume the user is in the middle of changing.
if (!room.taps && !room.inflight) room.volume = incoming.volume; 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); paintRoom(room);
}); });
paintGrouping();
const avr = state.avr || {}; const avr = state.avr || {};
inputs = avr.inputs || []; inputs = avr.inputs || [];
currentInput = avr.input || null; currentInput = avr.input || null;
ui.inputName.textContent = currentInput ? currentInput.name : '—'; ui.inputName.textContent = currentInput ? currentInput.name : '—';
setInputIcon(currentInput ? currentInput.code : null);
ui.avrStatus.textContent = avr.connected ? 'ready' : 'offline'; ui.avrStatus.textContent = avr.connected ? 'ready' : 'offline';
ui.avrStatus.classList.toggle('on', Boolean(avr.connected)); 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 || []; const problems = state.errors || [];
ui.foot.textContent = problems.length ? problems[0] : (state.demo ? 'demo mode — no real speakers' : ''); ui.foot.textContent = problems.length ? problems[0] : (state.demo ? 'demo mode — no real speakers' : '');
@@ -297,15 +713,11 @@ async function refresh() {
} }
function busy() { function busy() {
return sheetOpen() || Object.values(rooms).some((r) => r.taps || r.inflight || r.busy); return sheetOpen()
|| seeking(avrTrack)
|| Object.values(rooms).some((r) => r.taps || r.inflight || r.dragging || r.busy || r.playBusy || seeking(r));
} }
ui.refresh.addEventListener('click', () => {
ui.refresh.classList.add('spin');
setTimeout(() => ui.refresh.classList.remove('spin'), 700);
refresh();
});
setInterval(() => { setInterval(() => {
if (document.visibilityState === 'visible' && !busy()) refresh(); if (document.visibilityState === 'visible' && !busy()) refresh();
}, POLL_MS); }, POLL_MS);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 16 KiB

+20 -1
View File
@@ -1,8 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox=".33 -.05 775.27 799.83" fill="#fff"> <svg xmlns="http://www.w3.org/2000/svg" viewBox=".33 -.05 775.27 799.83">
<title>HEOS</title> <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="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="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="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 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"/> <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> </svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

+210 -53
View File
@@ -6,13 +6,14 @@
--bg: #0a0d14; --bg: #0a0d14;
--card: #151a25; --card: #151a25;
--edge: #232b3b; --edge: #232b3b;
--raised: #1e2634; --raised: #29334a;
--ink: #eef2f9; --ink: #eef2f9;
--muted: #8d98ad; --muted: #8d98ad;
--accent: #5b8def; --accent: #5b8def;
--live: #3ddc97; --live: #3ddc97;
--warn: #ff7a6b; --warn: #ff7a6b;
--radius: 22px; --radius: 22px;
--gutter: 16px; /* from the screen's side to a card, and from one card to the next */
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
@@ -45,32 +46,29 @@ button {
touch-action: manipulation; touch-action: manipulation;
} }
svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; } /* Every icon is a Font Awesome glyph: a solid shape, filled, never stroked. */
svg { width: 22px; height: 22px; fill: currentColor; }
.app { .app {
max-width: 520px; max-width: 520px;
margin: 0 auto; margin: 0 auto;
padding: max(12px, env(safe-area-inset-top)) max(16px, env(safe-area-inset-right)) padding: max(12px, env(safe-area-inset-top)) max(var(--gutter), env(safe-area-inset-right))
max(24px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left)); max(24px, env(safe-area-inset-bottom)) max(var(--gutter), env(safe-area-inset-left));
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 14px; 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 ---------------------------------------------------------- */ /* --- header ---------------------------------------------------------- */
.top { display: flex; align-items: center; justify-content: space-between; padding: 6px 4px 0; } .top { order: -2; display: flex; align-items: center; justify-content: center; padding: 6px 4px 0; }
.top h1 { margin: 0; line-height: 0; } .top h1 { margin: 0; line-height: 0; }
.top .logo { height: 34px; width: auto; display: block; } .top .logo { height: 34px; width: auto; display: block; }
.icon-button {
width: 44px; height: 44px; border-radius: 50%;
display: grid; place-items: center;
color: var(--muted); background: var(--card);
}
.icon-button:active { background: var(--raised); color: var(--ink); }
.icon-button.spin svg { animation: spin .7s linear; }
@keyframes spin { to { transform: rotate(360deg); } }
/* --- cards ----------------------------------------------------------- */ /* --- cards ----------------------------------------------------------- */
.card { .card {
background: var(--card); background: var(--card);
@@ -82,17 +80,24 @@ svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width:
gap: 14px; gap: 14px;
} }
.card.offline { opacity: .5; } .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 { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.card-head h2 { margin: 0; font-size: 17px; font-weight: 600; } .card-head h2 {
display: flex; align-items: center; gap: 10px;
.level { color: var(--muted); font-size: 13px; } margin: 0; min-width: 0;
.level b { font-size: 17px; font-weight: 600;
color: var(--ink);
font-size: 26px;
font-weight: 640;
font-variant-numeric: tabular-nums;
} }
.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 { .pill {
font-size: 11px; text-transform: uppercase; letter-spacing: .08em; font-size: 11px; text-transform: uppercase; letter-spacing: .08em;
@@ -101,6 +106,80 @@ svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width:
} }
.pill.on { color: var(--live); } .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 ---------------------------------------------------------- */
.volume { display: flex; align-items: center; gap: 14px; } .volume { display: flex; align-items: center; gap: 14px; }
@@ -109,34 +188,106 @@ svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width:
width: 78px; height: 62px; width: 78px; height: 62px;
border-radius: 18px; border-radius: 18px;
background: var(--raised); background: var(--raised);
font-size: 30px; font-weight: 500; line-height: 1;
display: grid; place-items: center; display: grid; place-items: center;
} }
.step svg { width: 20px; height: 20px; }
.step:active { background: var(--accent); transform: scale(.96); } .step:active { background: var(--accent); transform: scale(.96); }
.step:disabled { opacity: .4; } .step:disabled { opacity: .4; }
.meter { flex: 1; height: 8px; border-radius: 999px; background: #0c111b; overflow: hidden; } .meter { position: relative; flex: 1; height: 8px; border-radius: 999px; background: #0c111b; }
.meter i { display: block; height: 100%; width: 0; border-radius: 999px; background: var(--accent); transition: width .12s ease-out; } .meter i {
display: block; height: 100%; width: calc(var(--level, 0) * 1%);
/* --- join / leave the AVR -------------------------------------------- */ border-radius: 999px; background: var(--accent);
.toggle { transition: width .12s ease-out;
height: 52px; border-radius: 16px;
background: var(--raised);
display: flex; align-items: center; justify-content: center; gap: 10px;
font-size: 15px; font-weight: 550;
color: var(--muted);
} }
.toggle .dot { width: 9px; height: 9px; border-radius: 50%; background: currentColor; opacity: .6; }
/* 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[aria-pressed="true"] { background: var(--accent); color: #fff; }
.toggle[aria-pressed="true"] .dot { background: #fff; opacity: 1; }
.toggle:active { transform: scale(.985); } .toggle:active { transform: scale(.985); }
.toggle:disabled { opacity: .5; } .toggle:disabled { opacity: .5; }
.toggle.busy { opacity: .6; } .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 card ------------------------------------------------------ */
.source-button { .source-button {
display: flex; align-items: center; gap: 12px; display: flex; align-items: center; gap: 12px;
width: 100%; min-height: 64px; width: 100%; min-height: 50px;
padding: 10px 14px; padding: 10px 14px;
border-radius: 16px; border-radius: 16px;
background: var(--raised); background: var(--raised);
@@ -144,28 +295,29 @@ svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width:
} }
.source-button:active { transform: scale(.985); } .source-button:active { transform: scale(.985); }
.source-button .eyebrow { font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); } .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 .value { flex: 1; font-size: 19px; font-weight: 600; }
.source-button .chevron { color: var(--muted); } .source-button .chevron { color: var(--muted); }
/* --- rooms merged into the host's card --------------------------------- */ /* --- rooms merged into the host's card --------------------------------- */
.joined { display: flex; flex-direction: column; gap: 12px; } .joined { display: flex; flex-direction: column; gap: 14px; }
.joined-title { margin: 0 2px; font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); }
.joined-rooms { display: flex; flex-direction: column; gap: 14px; }
/* The card stops being a card in here: one border around the group, not /* In here a room is an outline rather than a card: its border is what says
one around every room in it. */ it is playing with the AVR, and the fill -- a tint too, when it plays --
.joined .card.room { background: none; border: 0; border-radius: 0; padding: 0; gap: 12px; } belongs to the group's card instead. Same specificity as
.joined .card.room + .card.room { border-top: 1px solid var(--edge); padding-top: 14px; } .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 .card-head h2 { font-size: 15px; font-weight: 550; color: var(--muted); }
.joined .level b { font-size: 22px; } .joined .cover { width: 52px; height: 52px; border-radius: 10px; }
.joined .song { font-size: 18px; }
.joined .step { height: 54px; } .joined .step { height: 54px; }
.joined .toggle, /* In here the Spotify buttons hide and the toggle says "Detach", so it takes
.joined .toggle[aria-pressed="true"] { their place and their look: the whole row, a plain background rather than
height: 40px; font-size: 13px; font-weight: 500; the pressed accent, and an exit icon where the host's would only muddle. */
background: none; border: 1px solid var(--edge); color: var(--muted); .joined .toggle { flex: 1; min-width: 0; }
} .joined .toggle[aria-pressed="true"] { background: var(--raised); color: inherit; }
.joined .toggle .dot { display: none; } .joined .toggle .cinema { display: none; }
.joined .toggle:active { background: var(--raised); color: var(--ink); } .joined .toggle .leave { display: block; }
/* --- misc ------------------------------------------------------------- */ /* --- misc ------------------------------------------------------------- */
.wide { width: 100%; height: 50px; border-radius: 16px; font-size: 15px; } .wide { width: 100%; height: 50px; border-radius: 16px; font-size: 15px; }
@@ -203,7 +355,11 @@ svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width:
} }
.option:active { transform: scale(.985); } .option:active { transform: scale(.985); }
.option[aria-current="true"] { background: var(--accent); color: #fff; } .option[aria-current="true"] { background: var(--accent); color: #fff; }
.option .code { font-size: 12px; color: var(--muted); } .option-label { display: flex; align-items: center; gap: 10px; min-width: 0; overflow: hidden; }
.option-label svg { flex: 0 0 auto; height: 18px; width: 18px; fill: currentColor; color: var(--muted); }
.option-label span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.option[aria-current="true"] .option-label svg { color: rgba(255, 255, 255, .8); }
.option .code { font-size: 12px; color: var(--muted); flex: 0 0 auto; }
.option[aria-current="true"] .code { color: rgba(255, 255, 255, .8); } .option[aria-current="true"] .code { color: rgba(255, 255, 255, .8); }
/* --- toast ------------------------------------------------------------ */ /* --- toast ------------------------------------------------------------ */
@@ -215,6 +371,7 @@ svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width:
background: #2b1f24; border: 1px solid #52303a; color: #ffd9d4; background: #2b1f24; border: 1px solid #52303a; color: #ffd9d4;
font-size: 14px; box-shadow: 0 10px 30px rgba(0, 0, 0, .45); 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) { @media (prefers-reduced-motion: reduce) {
* { animation: none !important; transition: none !important; } * { animation: none !important; transition: none !important; }
+101 -30
View File
@@ -14,59 +14,130 @@
<link rel="manifest" href="{{ url_for('manifest') }}"> <link rel="manifest" href="{{ url_for('manifest') }}">
<link rel="apple-touch-icon" href="{{ url_for('static', filename='icon-180.png') }}"> <link rel="apple-touch-icon" href="{{ url_for('static', filename='icon-180.png') }}">
<link rel="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') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='panel.css') }}">
</head> </head>
<body> <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"> <div class="app">
<header class="top"> <header class="top">
<h1><img class="logo" src="{{ url_for('static', filename='logo.svg') }}" alt="{{ app_name }}"></h1> <h1><img class="logo" src="{{ url_for('static', filename='logo.svg') }}" alt="{{ app_name }}"></h1>
<button class="icon-button" data-role="refresh" aria-label="Refresh">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 12a8 8 0 1 1-2.34-5.66M20 4v5h-5"/></svg>
</button>
</header> </header>
<section class="card source">
<div class="card-head">
<h2>{{ host.label }}</h2>
<span class="pill" data-role="avr-status">offline</span>
</div>
<button class="source-button" data-role="input-button">
<span class="eyebrow">Input</span>
<span class="value" data-role="input-name"></span>
<svg class="chevron" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>
</button>
<div class="joined" data-role="joined" hidden>
<p class="joined-title">Playing together</p>
<div class="joined-rooms" data-role="joined-rooms"></div>
</div>
</section>
{% for room in rooms %} {% for room in rooms %}
<div class="room-slot" data-slot="{{ room.key }}"> <div class="room-slot" data-slot="{{ room.key }}">
<section class="card room" data-room="{{ room.key }}"> <section class="card room" data-room="{{ room.key }}">
<div class="card-head"> <div class="card-head">
<h2>{{ room.label }}</h2> <h2><svg class="room-icon" data-icon="{{ room.kind }}" viewBox="0 0 512 512" aria-hidden="true"><path d=""/></svg><span class="room-label">{{ room.label }}</span></h2>
<span class="level"><b data-role="level"></b></span> </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>
<div class="volume"> <div class="volume">
<button class="step" data-delta="-1" aria-label="{{ room.label }}: volume down">&minus;</button> <button class="step" data-delta="-1" aria-label="{{ room.label }}: volume down">
<div class="meter"><i data-role="bar"></i></div> <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 class="step" data-delta="1" aria-label="{{ room.label }}: volume up">+</button> </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>
<button class="toggle" data-role="group" aria-pressed="false"> <div class="actions" data-role="actions" hidden>
<span class="dot" aria-hidden="true"></span> <button class="transport" data-role="prev" aria-label="{{ room.label }}: previous track">
<span data-role="group-label">Separate</span> <!-- 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>
<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> </section>
</div> </div>
{% endfor %} {% endfor %}
<button class="wide ghost" data-role="split-all" hidden>Separate everything</button> <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> <p class="foot" data-role="foot"></p>
</div> </div>
+117
View File
@@ -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}"
+55 -65
View File
@@ -1,8 +1,9 @@
"""Stand-in Denon hardware: just enough HEOS and Telnet to test against. """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 The grouping rules are the part worth pinning down -- what a set_group
call does to a stereo pair is the kind of thing you do not want to find call actually does to players already in a group is the kind of thing
out by experimenting on the speakers at eleven at night. you do not want to find out by experimenting on the speakers at eleven
at night.
""" """
import json import json
@@ -11,15 +12,33 @@ import threading
class FakeHeos(threading.Thread): class FakeHeos(threading.Thread):
"""A HEOS CLI server on localhost, with four players and a pair.""" """A HEOS CLI server on localhost, with three players.
NAMES = {1: "Home Cinema", 2: "Lego Room", 3: "Denon Home 200 L", 4: "Denon Home 200 R"} "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): def __init__(self):
super().__init__(daemon=True) super().__init__(daemon=True)
self.groups = {3: [3, 4]} # gid -> pids, leader first self.groups = {} # gid -> pids, leader first
self.volumes = {pid: 20 for pid in self.NAMES} self.volumes = {pid: 20 for pid in self.NAMES}
self.group_volumes = {3: 25} 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.commands = [] # everything we were asked to do
self.server = socket.socket() self.server = socket.socket()
self.server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) self.server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
@@ -96,6 +115,35 @@ class FakeHeos(threading.Thread):
store[key[1]] = int(args["level"]) store[key[1]] = int(args["level"])
return self._ok(path, message=f"{key[0]}={key[1]}&level={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": if path.endswith("/toggle_mute") or path == "system/heart_beat":
return self._ok(path) return self._ok(path)
@@ -115,61 +163,3 @@ class FakeHeos(threading.Thread):
if payload is not None: if payload is not None:
reply["payload"] = payload reply["payload"] = payload
return reply return reply
class FakeAvr(threading.Thread):
"""A Denon Telnet server that knows SI and SSFUN."""
SOURCES = [("MPLAY", "Apple TV"), ("GAME", "PlayStation"),
("SAT/CBL", "TV Box"), ("DVD", "Old DVD")]
DELETED = {"DVD"} # switched off in the AVR's setup menu
def __init__(self):
super().__init__(daemon=True)
self.input = "MPLAY"
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(4)
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(1024)
except OSError:
return
if not chunk:
return
buffer += chunk
while b"\r" in buffer:
line, buffer = buffer.split(b"\r", 1)
for reply in self.handle(line.decode().strip()):
conn.sendall(reply.encode() + b"\r")
def handle(self, command: str) -> list:
if command == "SSFUN ?":
# The real AVR pads the names out with spaces.
return [f"SSFUN{code} {name} " for code, name in self.SOURCES] + ["SSFUN END"]
if command == "SSSOD ?":
return [f"SSSOD{code} {'DEL' if code in self.DELETED else 'USE'}"
for code, _ in self.SOURCES] + ["SSSOD END"]
if command == "SI?":
return [f"SI{self.input}"]
if command.startswith("SI"):
self.input = command[2:]
return [f"SI{self.input}"]
if command == "PW?":
return ["PWON"]
return []
+156 -93
View File
@@ -4,8 +4,6 @@
""" """
import sys import sys
import tempfile
import time
import unittest import unittest
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
@@ -13,169 +11,234 @@ from types import SimpleNamespace
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from controller import Controller, stepped_level # noqa: E402 from controller import Controller, stepped_level # noqa: E402
from tests.fakes import FakeAvr, FakeHeos # noqa: E402 from tests.fakes import FakeHeos # noqa: E402
PAIR = {3, 4} # the two Home 200s
AVR_PID = 1 AVR_PID = 1
HOME400_PID = 2 HOME400_PID = 2
LIVING_ROOM_PID = 3 # the Home 200 pair, one pid -- HEOS pairs it at the hardware level
def build(tmpdir): def build():
heos, avr = FakeHeos(), FakeAvr() heos = FakeHeos()
cfg = SimpleNamespace( cfg = SimpleNamespace(
HEOS_HOST="127.0.0.1", HEOS_PORT=heos.port, HEOS_HOST="127.0.0.1", HEOS_PORT=heos.port,
AVR_HOST="127.0.0.1", AVR_PORT=avr.port,
HOST_KEY="avr", HOST_KEY="avr",
ROOM_KEYS=["home400", "living_room_group"], ROOM_KEYS=["lego_room", "living_room"],
TARGETS={ TARGETS={
"avr": {"label": "Home Cinema", "heos_name": "Home Cinema"}, "avr": {"label": "Home Cinema", "heos_name": "Home Cinema"},
"home400": {"label": "Lego Room", "heos_name": "Lego Room"}, "lego_room": {"label": "Lego Room", "heos_name": "Lego Room"},
"living_room_group": {"label": "Living Room", "heos_name": "Denon Home 200 L"}, "living_room": {"label": "Living Room", "heos_name": "Denon Home 200 L"},
}, },
AVR_INPUT_CODES=[],
VOLUME_STEP=5, VOLUME_STEP=5,
MEMBERS_FILE=str(Path(tmpdir) / "members.json"),
) )
return Controller(cfg), heos, avr return Controller(cfg), heos
class PanelTest(unittest.TestCase): class PanelTest(unittest.TestCase):
def setUp(self): def setUp(self):
self.tmp = tempfile.TemporaryDirectory() self.panel, self.heos = build()
self.addCleanup(self.tmp.cleanup)
self.panel, self.heos, self.avr = build(self.tmp.name)
def group_pids(self): def group_pids(self):
return {gid: set(pids) for gid, pids in self.heos.groups.items()} return {gid: set(pids) for gid, pids in self.heos.groups.items()}
# -- resolving ------------------------------------------------------ # -- resolving ------------------------------------------------------
def test_pair_resolves_to_both_speakers(self): def test_living_room_resolves_to_its_one_pid(self):
"""The bug this replaces: grouping used only the pair's leader,
which left the second Home 200 behind."""
self.panel.scan() self.panel.scan()
self.assertEqual(set(self.panel.member_pids("living_room_group")), PAIR) self.assertEqual(self.panel.member_pids("living_room"), [LIVING_ROOM_PID])
self.assertEqual(self.panel.member_pids("home400"), [HOME400_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): def test_avr_resolves_to_a_player_even_while_it_leads_a_group(self):
self.panel.join("home400") self.panel.join("lego_room")
# HEOS now reports a *group* named "Home Cinema" as well as the player. # HEOS now reports a *group* named "Home Cinema" as well as the player.
self.assertEqual(self.panel.member_pids("avr"), [AVR_PID]) self.assertEqual(self.panel.member_pids("avr"), [AVR_PID])
# -- grouping ------------------------------------------------------- # -- grouping -------------------------------------------------------
def test_joining_takes_the_whole_pair(self): def test_joining_adds_the_room(self):
self.panel.join("living_room_group") self.panel.join("living_room")
self.assertEqual(self.group_pids(), {AVR_PID: {AVR_PID} | PAIR}) self.assertEqual(self.group_pids(), {AVR_PID: {AVR_PID, LIVING_ROOM_PID}})
self.assertEqual(self.panel.joined_keys(), ["living_room_group"]) self.assertEqual(self.panel.joined_keys(), ["living_room"])
def test_joining_keeps_whoever_is_already_grouped(self): def test_joining_keeps_whoever_is_already_grouped(self):
self.panel.join("home400") self.panel.join("lego_room")
self.panel.join("living_room_group") self.panel.join("living_room")
self.assertEqual(self.group_pids(), {AVR_PID: {AVR_PID, HOME400_PID} | PAIR}) self.assertEqual(self.group_pids(), {AVR_PID: {AVR_PID, HOME400_PID, LIVING_ROOM_PID}})
self.assertEqual(self.panel.joined_keys(), ["home400", "living_room_group"]) self.assertEqual(self.panel.joined_keys(), ["lego_room", "living_room"])
def test_leaving_rebuilds_the_stereo_pair(self): def test_joining_replays_the_avr_input_over_heos(self):
self.panel.join("living_room_group") """A room that has just joined sometimes stays silent until the
self.panel.leave("living_room_group") AVR's input is reselected -- through HEOS's own browse/play_input,
self.assertEqual(self.group_pids(), {3: PAIR}) # pair back, AVR alone 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(), []) self.assertEqual(self.panel.joined_keys(), [])
def test_leaving_one_room_does_not_disturb_the_other(self): def test_leaving_one_room_does_not_disturb_the_other(self):
self.panel.join("home400") self.panel.join("lego_room")
self.panel.join("living_room_group") self.panel.join("living_room")
before = [c for c in self.heos.commands if "set_group" in c] before = [c for c in self.heos.commands if "set_group" in c]
self.panel.leave("home400") self.panel.leave("lego_room")
after = [c for c in self.heos.commands if "set_group" in c] after = [c for c in self.heos.commands if "set_group" in c]
self.assertEqual(self.group_pids(), {AVR_PID: {AVR_PID} | PAIR}) self.assertEqual(self.group_pids(), {AVR_PID: {AVR_PID, LIVING_ROOM_PID}})
# Exactly one new set_group: the remaining room is never regrouped, # Exactly one new set_group: the remaining room is never regrouped,
# which is what stops its music restarting. # which is what stops its music restarting.
self.assertEqual(len(after) - len(before), 1) self.assertEqual(len(after) - len(before), 1)
def test_separate_everything(self): def test_separate_everything(self):
self.panel.set_membership(["home400", "living_room_group"]) self.panel.set_membership(["lego_room", "living_room"])
self.panel.set_membership([]) self.panel.set_membership([])
self.assertEqual(self.group_pids(), {3: PAIR}) self.assertEqual(self.group_pids(), {})
self.assertEqual(self.panel.joined_keys(), []) self.assertEqual(self.panel.joined_keys(), [])
def test_membership_survives_a_restart_while_merged(self): def test_resolving_survives_a_restart_while_merged(self):
"""Once merged, the pair's own group is gone from HEOS, so a fresh """member_pids reads the live player list, so a fresh process needs
process has to fall back on what it learned earlier.""" no memory of anything to find a room that is currently merged."""
self.panel.join("living_room_group") self.panel.join("living_room")
reborn = Controller(self.panel.cfg) reborn = Controller(self.panel.cfg)
reborn.scan() reborn.scan()
self.assertEqual(set(reborn.member_pids("living_room_group")), PAIR) self.assertEqual(reborn.member_pids("living_room"), [LIVING_ROOM_PID])
reborn.leave("living_room_group") reborn.leave("living_room")
self.assertEqual(self.group_pids(), {3: PAIR}) self.assertEqual(self.group_pids(), {})
# -- volume --------------------------------------------------------- # -- volume ---------------------------------------------------------
def test_pair_uses_group_volume_when_it_stands_alone(self): def test_living_room_volume_is_its_own_player_volume(self):
self.panel.scan() self.panel.scan()
self.assertEqual(self.panel.set_volume("living_room_group", 42), 42) self.assertEqual(self.panel.set_volume("living_room", 42), 42)
self.assertEqual(self.heos.group_volumes[3], 42) self.assertEqual(self.heos.volumes[LIVING_ROOM_PID], 42)
self.assertEqual(self.panel.volume("living_room"), 42)
def test_pair_uses_player_volume_once_merged(self):
"""Its gid stops existing the moment it joins the AVR, so the old
bridge's get_volume?gid= call would simply fail here."""
self.panel.join("living_room_group")
self.assertEqual(self.panel.set_volume("living_room_group", 31), 31)
self.assertEqual(self.heos.volumes[3], 31)
self.assertEqual(self.heos.volumes[4], 31)
self.assertEqual(self.panel.volume("living_room_group"), 31)
def test_nudge_clamps_at_the_ends(self): def test_nudge_clamps_at_the_ends(self):
self.panel.set_volume("home400", 98) self.panel.set_volume("lego_room", 98)
self.assertEqual(self.panel.nudge_volume("home400", 5), 100) self.assertEqual(self.panel.nudge_volume("lego_room", 5), 100)
self.panel.set_volume("home400", 1) self.panel.set_volume("lego_room", 1)
self.assertEqual(self.panel.nudge_volume("home400", -9), 0) self.assertEqual(self.panel.nudge_volume("lego_room", -9), 0)
# -- volume in whole steps ------------------------------------------- # -- volume in whole steps -------------------------------------------
def test_a_tap_lands_on_the_next_multiple(self): def test_a_tap_lands_on_the_next_multiple(self):
self.panel.set_volume("home400", 23) self.panel.set_volume("lego_room", 23)
self.assertEqual(self.panel.step_volume("home400", 1), 25) self.assertEqual(self.panel.step_volume("lego_room", 1), 25)
self.panel.set_volume("home400", 23) self.panel.set_volume("lego_room", 23)
self.assertEqual(self.panel.step_volume("home400", -1), 20) self.assertEqual(self.panel.step_volume("lego_room", -1), 20)
def test_a_level_already_on_a_multiple_moves_a_whole_step(self): def test_a_level_already_on_a_multiple_moves_a_whole_step(self):
self.panel.set_volume("home400", 25) self.panel.set_volume("lego_room", 25)
self.assertEqual(self.panel.step_volume("home400", 1), 30) self.assertEqual(self.panel.step_volume("lego_room", 1), 30)
self.panel.set_volume("home400", 25) self.panel.set_volume("lego_room", 25)
self.assertEqual(self.panel.step_volume("home400", -1), 20) self.assertEqual(self.panel.step_volume("lego_room", -1), 20)
def test_a_burst_of_taps_snaps_once_then_moves_whole_steps(self): def test_a_burst_of_taps_snaps_once_then_moves_whole_steps(self):
self.panel.set_volume("living_room_group", 23) self.panel.set_volume("living_room", 23)
self.assertEqual(self.panel.step_volume("living_room_group", 3), 35) self.assertEqual(self.panel.step_volume("living_room", 3), 35)
# -- the AVR --------------------------------------------------------- # -- play / pause ------------------------------------------------------
def test_renamed_inputs_and_selection(self): def test_toggle_play_flips_what_the_speakers_report(self):
deadline = time.time() + 5 self.panel.scan()
while not self.panel.avr.connected and time.time() < deadline: self.assertEqual(self.panel.get_play_state("lego_room"), "play")
time.sleep(0.05) self.assertEqual(self.panel.toggle_play("lego_room"), "pause")
self.assertTrue(self.panel.avr.connected) 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( self.assertEqual(
self.panel.avr.inputs(), [r["now_playing"] for r in state["rooms"]],
[{"code": "MPLAY", "name": "Apple TV"}, [{"song": "Harvest Moon", "artist": "Neil Young", "image": "https://i.scdn.co/image/abc"}, None],
{"code": "GAME", "name": "PlayStation"},
{"code": "SAT/CBL", "name": "TV Box"}],
) )
self.assertEqual(self.panel.avr.current_input(), {"code": "MPLAY", "name": "Apple TV"})
# "Old DVD" is deleted in the AVR's setup menu, so the picker skips def test_a_song_without_artist_or_cover_still_shows(self):
# it -- but it keeps its name, in case the AVR is sitting on it. self.heos.now_playing_mid[HOME400_PID] = "spotify:track:4uLU6hMCjMI75M1A2tKUQC"
self.assertNotIn("DVD", [s["code"] for s in self.panel.avr.inputs()]) self.heos.now_playing_track[HOME400_PID] = {"song": "Harvest Moon", "artist": "", "image_url": ""}
self.assertIn({"code": "DVD", "name": "Old DVD"}, self.panel.avr.all_inputs()) self.assertEqual(
self.assertEqual(self.panel.avr.name_for("DVD"), "Old DVD") self.panel.state()["rooms"][0]["now_playing"],
{"song": "Harvest Moon", "artist": None, "image": None},
)
self.assertEqual(self.panel.avr.select_input("GAME"), {"code": "GAME", "name": "PlayStation"}) def test_a_paused_room_keeps_its_song_and_a_stopped_one_drops_it(self):
self.assertEqual(self.avr.input, "GAME") 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 ------------------------------- # -- the whole snapshot the UI renders -------------------------------
def test_state_snapshot(self): def test_state_snapshot(self):
self.panel.join("home400") self.panel.join("lego_room")
state = self.panel.state() state = self.panel.state()
self.assertTrue(state["heos_ok"]) self.assertTrue(state["heos_ok"])
self.assertEqual([r["key"] for r in state["rooms"]], ["home400", "living_room_group"]) 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.assertEqual([r["grouped"] for r in state["rooms"]], [True, False])
self.assertTrue(all(isinstance(r["volume"], int) for r in state["rooms"])) 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): def test_state_reports_trouble_instead_of_blowing_up(self):
self.panel.heos.host = "127.0.0.1" self.panel.heos.host = "127.0.0.1"
+70
View File
@@ -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()
+122
View File
@@ -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()
+10 -6
View File
@@ -4,14 +4,17 @@
python3 tools/make_icons.py python3 tools/make_icons.py
Writes static/icon-180.png (what iOS uses on the home screen) and 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 a static/icon-512.png (Android / the web manifest): the white mark on
transparent background, which iOS lays over black on the home screen. 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. 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 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: for one job -- if you have librsvg to hand, this does the same:
rsvg-convert -w 512 -h 512 static/logo.svg -o static/icon-512.png rsvg-convert -w 512 -h 512 --background-color '#0a0d14' static/logo.svg -o static/icon-512.png
Otherwise: pip install playwright && playwright install chromium Otherwise: pip install playwright && playwright install chromium
""" """
@@ -23,10 +26,11 @@ STATIC = Path(__file__).resolve().parent.parent / "static"
LOGO = STATIC / "logo.svg" LOGO = STATIC / "logo.svg"
SIZES = (180, 512) SIZES = (180, 512)
MARGIN = 0.14 # breathing room, so iOS's rounded mask never clips it 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> PAGE = """<!doctype html>
<style> <style>
html, body {{ margin: 0; background: transparent; }} html, body {{ margin: 0; background: {background}; }}
body {{ width: {size}px; height: {size}px; display: grid; place-items: center; }} body {{ width: {size}px; height: {size}px; display: grid; place-items: center; }}
img {{ width: {inner}px; height: {inner}px; object-fit: contain; }} img {{ width: {inner}px; height: {inner}px; object-fit: contain; }}
</style> </style>
@@ -49,12 +53,12 @@ def main():
browser = p.chromium.launch(args=["--no-sandbox"]) browser = p.chromium.launch(args=["--no-sandbox"])
for size in SIZES: for size in SIZES:
inner = round(size * (1 - 2 * MARGIN)) inner = round(size * (1 - 2 * MARGIN))
scratch.write_text(PAGE.format(size=size, inner=inner)) scratch.write_text(PAGE.format(size=size, inner=inner, background=BACKGROUND))
page = browser.new_page(viewport={"width": size, "height": size}) page = browser.new_page(viewport={"width": size, "height": size})
page.goto(scratch.as_uri()) page.goto(scratch.as_uri())
page.wait_for_timeout(120) # let the SVG paint page.wait_for_timeout(120) # let the SVG paint
target = STATIC / f"icon-{size}.png" target = STATIC / f"icon-{size}.png"
page.screenshot(path=target, omit_background=True) page.screenshot(path=target)
page.close() page.close()
print(f"wrote {target} ({target.stat().st_size} bytes)") print(f"wrote {target} ({target.stat().st_size} bytes)")
browser.close() browser.close()
+143
View File
@@ -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()
+110
View File
@@ -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