diff --git a/.gitignore b/.gitignore
index 3aaa513..0597bad 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,6 @@ config.json
# Stamped by the Gitea deploy at checkout time -- see config.py's APP_VERSION
VERSION
+
+# VS Code
+.vscode/settings.json
diff --git a/README.md b/README.md
index 140c637..7f0960f 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,7 @@ Everything fits on one screen:
- **Volume** up/down for each room and for the AVR. A tap lands on the next multiple of `VOLUME_STEP` — from 23 it goes to 25, not 28 — so the levels stay round. Hold to keep moving, or drag the level along its bar to set it outright.
- **Navigate** either room (play / pause / prev / next), while it is playing Spotify
+- **See what's up next** in a room's Spotify queue: the next 10 tracks, folded away at the foot of its card until you open it
- **Group** either room with the AVR. the AVR is always the host, so its sound takes over whatever joins it. A room that joins moves _into_ the Home Cinema card, so one glance says what is playing together
- **Ungroup** either room again, or all of them at once
- **Change the AVR's input**, listed under the names you gave them, minus the sources you deleted in the AVR's setup menu
@@ -155,8 +156,28 @@ Used by the interface:
## Demo/Tests
+Locally, install the dev requirements rather than just the production ones: they add the tools below on top.
+
```bash
+pip install -r requirements-dev.txt
+playwright install chromium # once, for make_icons.py
python3 app.py --demo # fake speakers, real interface
python3 -m unittest discover -s tests -t . # runs against a fake HEOS network
python3 tools/make_icons.py # re-render the icons from static/logo.svg
```
+
+## Dependencies
+
+- `requirements.txt`: what the server runs. Only Flask and gunicorn are pinned, to the exact versions tested here, so the Gitea job tests and deploys those same two. Their own dependencies (Werkzeug, Jinja2…) are left free: pip picks whatever fits the pinned versions at install time.
+- `requirements-dev.txt`: everything in `requirements.txt` (through its `-r` line), plus tools only needed on your machine: `playwright` for `tools/make_icons.py`, and `pur` for upgrading. The server never installs it.
+
+To upgrade every pin in both files to its newest release:
+
+```bash
+source .venv/bin/activate
+pur -r requirements-dev.txt # rewrites the pins, following -r into requirements.txt
+pip install -r requirements-dev.txt
+python3 -m unittest discover -s tests -t .
+```
+
+`pur -r requirements-dev.txt --dry-run` shows what would change without writing anything, and `--minor '*'` stays within each package's current major version.
diff --git a/app.py b/app.py
index 3630dc1..55d2665 100644
--- a/app.py
+++ b/app.py
@@ -314,6 +314,25 @@ def _mark_spotify_accounts(rooms: list):
room["spotify_account"] = playing_on.get(_spotify_name(room["key"]))
+# How much of a room's Spotify queue its card unfolds to.
+QUEUE_LENGTH = 10
+def _queue_item(item: dict) -> dict:
+ """One queue entry in the same shape as a card's now-playing: a track
+ names its artists and its album's cover, a podcast episode its show."""
+ show = item.get("show") or {}
+ artist = ", ".join(a["name"] for a in item.get("artists") or [] if a.get("name")) or show.get("name")
+ images = (item.get("album") or {}).get("images") or item.get("images") or show.get("images") or []
+ # Spotify lists them widest first: the smallest still sharp on a retina phone, rather than a 640px cover squeezed into a thumbnail
+ sharp = [i for i in images if (i.get("width") or 0) >= 120]
+ image = (sharp[-1] if sharp else images[0] if images else {}).get("url")
+ return {
+ "song": item.get("name") or "",
+ "artist": artist or "",
+ "image": image,
+ "duration_ms": item.get("duration_ms")
+ }
+
+
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
@@ -522,6 +541,23 @@ def api_spotify_devices():
return jsonify(_spotify_from(request.args).devices())
+@app.get("/api/spotify/queue")
+@handle_errors
+def api_spotify_queue():
+ """What comes next in a room's Spotify stream (?target=lego_room), asked
+ of whichever of your accounts is playing there. Only fetched when a card
+ unfolds its queue, not on every poll."""
+ key = _target_from(request.args)
+ # Demo rooms are not on anyone's real Spotify, so they bring their own queue.
+ if _is_demo():
+ return jsonify({"target": key, "queue": controller.spotify_queue(key)[:QUEUE_LENGTH]})
+ account = _spotify_playing_on().get(_spotify_name(key))
+ if account is None:
+ raise ValueError("Only a Spotify stream one of your accounts is playing has a queue to show")
+ items = spotify[account].queue()[:QUEUE_LENGTH]
+ return jsonify({"target": key, "account": account, "queue": [_queue_item(i) for i in items]})
+
+
@app.post("/api/spotify/resume")
@handle_errors
def api_spotify_resume():
diff --git a/demo.py b/demo.py
index 49b74d5..8982e39 100644
--- a/demo.py
+++ b/demo.py
@@ -140,6 +140,16 @@ class DemoController:
def zidoo_seek(self, position_ms):
self._film_ms = min(596000, int(position_ms))
+ def spotify_queue(self, key):
+ """What the demo's Spotify room plays next -- app.py's /api/spotify/queue."""
+ if not self._spotify.get(key):
+ raise ValueError("Only a Spotify stream one of your accounts is playing has a queue to show")
+ songs = ["Unknown Legend", "Old King", "Natural Beauty", "From Hank to Hendrix",
+ "You and Me", "War of Man", "One of These Days", "Such a Woman",
+ "Dreamin' Man", "Little Bit of Love", "Heart of Gold", "Old Man"]
+ return [{"song": song, "artist": "Neil Young", "image": None, "duration_ms": 240000}
+ for song in songs]
+
def joined_keys(self):
return [k for k in self.cfg.GUEST_ROOMS if k in self._joined]
diff --git a/requirements-dev.txt b/requirements-dev.txt
new file mode 100644
index 0000000..4afbb92
--- /dev/null
+++ b/requirements-dev.txt
@@ -0,0 +1,3 @@
+-r requirements.txt
+playwright==1.64.0
+pur==7.4.0
diff --git a/requirements.txt b/requirements.txt
index a7a45bc..267f336 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,2 +1,2 @@
-flask>=3.0
-gunicorn>=21
+flask==3.1.3
+gunicorn==26.2.0
diff --git a/spotify.py b/spotify.py
index e96a885..823b550 100644
--- a/spotify.py
+++ b/spotify.py
@@ -186,6 +186,12 @@ class SpotifyClient:
playback() first rather than pausing on spec."""
self._call("PUT", "/v1/me/player/pause")
+ def queue(self) -> list:
+ """What this account plays next, in order: whatever was queued by
+ hand first, then the rest of the album or playlist. Spotify sends
+ one page of it, about twenty items, and has no way to ask for fewer."""
+ return self._call("GET", "/v1/me/player/queue").get("queue") or []
+
def seek(self, position_ms: int):
"""Jump to position_ms in whatever the account is playing, on
whichever device is playing it."""
diff --git a/static/app.js b/static/app.js
index 64565fc..297f302 100644
--- a/static/app.js
+++ b/static/app.js
@@ -127,6 +127,10 @@ els('.card[data-room]').forEach((node) => {
artist: el('[data-role="artist"]', node),
progress: el('[data-role="progress"]', node),
steps: els('.step', node),
+ queue: el('[data-role="queue"]', node),
+ queueList: el('[data-role="queue-list"]', node),
+ queueFor: null, // the song the queue on show was fetched behind
+ queueLoading: false,
volume: null,
playState: null,
track: null, // {song, artist, image} while something is loaded
@@ -162,6 +166,9 @@ els('.card[data-room]').forEach((node) => {
// the next poll does not try it again until the track changes.
room.cover.addEventListener('error', () => { room.cover.hidden = true; });
+ // Asked for afresh on every unfold: songs queued from the Spotify app in
+ // the meantime would not otherwise turn up until the track changed.
+ if (room.queue) room.queue.addEventListener('toggle', () => { if (room.queue.open) loadQueue(room); });
if (room.toggle) room.toggle.addEventListener('click', () => setGrouped(key, !room.grouped));
if (room.prev) room.prev.addEventListener('click', () => skipTrack(key, 'previous'));
room.play.addEventListener('click', () => togglePlay(key));
@@ -226,9 +233,93 @@ function paintRoom(room) {
paintVolume(room);
paintTransport(room);
paintSources(room);
+ paintQueue(room);
placeRoom(room);
}
+/* --- Spotify queue: what comes next, folded at the foot of a room's card.
+ It costs a Spotify call, so it is only fetched while unfolded -- on the
+ unfold, and again whenever the song changes under it. ------------------ */
+function queueKey(room) {
+ return room.track ? `${room.spotifyAccount}|${room.track.song}|${room.track.artist}` : room.spotifyAccount;
+}
+
+function paintQueue(room) {
+ if (!room.queue) return;
+ // Shown for the same stream the transport buttons are: one of your accounts'.
+ const shown = room.available && room.onSpotify && Boolean(room.spotifyAccount) && !room.grouped;
+ room.queue.hidden = !shown;
+ if (!shown) {
+ // Folded again for next time, rather than reopening on a stale list.
+ room.queue.open = false;
+ room.queueFor = null;
+ return;
+ }
+ if (room.queue.open && room.queueFor !== queueKey(room)) loadQueue(room);
+}
+
+async function loadQueue(room) {
+ if (room.queueLoading) return;
+ room.queueLoading = true;
+ const wanted = queueKey(room);
+ room.queueFor = wanted;
+ // Only an empty box says it is loading; a list already there stays put
+ // until the new one replaces it.
+ if (!room.queueList.children.length) renderQueueLoading(room);
+ try {
+ const data = await api(`/api/spotify/queue?target=${encodeURIComponent(room.key)}`);
+ renderQueue(room, data.queue || []);
+ } catch (error) {
+ renderQueueNote(room, error.message);
+ room.queueFor = null; // so the next poll tries again
+ } finally {
+ room.queueLoading = false;
+ }
+ // The song moved on while this was in flight.
+ if (room.queueFor !== null && room.queueFor !== queueKey(room)) paintQueue(room);
+}
+
+function renderQueueNote(room, text) {
+ const note = document.createElement('li');
+ note.className = 'queue-note';
+ note.textContent = text;
+ room.queueList.replaceChildren(note);
+}
+
+/* Font Awesome's ellipsis, one path per dot so panel.css can blink them in turn. */
+function renderQueueLoading(room) {
+ const note = document.createElement('li');
+ note.className = 'queue-note';
+ note.setAttribute('aria-label', 'Loading');
+ note.innerHTML = '';
+ room.queueList.replaceChildren(note);
+}
+
+function renderQueue(room, items) {
+ if (!items.length) {
+ renderQueueNote(room, 'Nothing queued');
+ return;
+ }
+ room.queueList.replaceChildren(...items.map((item) => {
+ const row = document.createElement('li');
+ row.className = 'queue-item';
+ row.innerHTML = '