1 parent
6c76121863
commit
6bee9bd9a3
13 files changed
+230
-5
No files matched your search
@@ -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
|
||||
@@ -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.
|
||||
@@ -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():
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-r requirements.txt
|
||||
playwright==1.64.0
|
||||
pur==7.4.0
|
||||
+2
-2
@@ -1,2 +1,2 @@
|
||||
flask>=3.0
|
||||
gunicorn>=21
|
||||
flask==3.1.3
|
||||
gunicorn==26.2.0
|
||||
@@ -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."""
|
||||
|
||||
@@ -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 = '<svg class="queue-loading" viewBox="0 0 448 512" aria-hidden="true">'
|
||||
+ '<path d="M0 256a56 56 0 1 1 112 0 56 56 0 1 1 -112 0z"/>'
|
||||
+ '<path d="M168 256a56 56 0 1 1 112 0 56 56 0 1 1 -112 0z"/>'
|
||||
+ '<path d="M336 256a56 56 0 1 1 112 0 56 56 0 1 1 -112 0z"/></svg>';
|
||||
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 = '<img class="cover" alt="" loading="lazy"><div class="track"><span class="song"></span><span class="artist"></span></div><span class="queue-time"></span>';
|
||||
const cover = el('.cover', row);
|
||||
if (item.image) {
|
||||
cover.addEventListener('error', () => { cover.style.visibility = 'hidden'; });
|
||||
cover.src = item.image;
|
||||
}
|
||||
el('.song', row).textContent = item.song;
|
||||
el('.artist', row).textContent = item.artist || '';
|
||||
el('.artist', row).hidden = !item.artist;
|
||||
el('.queue-time', row).textContent = item.duration_ms ? clock(Math.floor(item.duration_ms / 1000)) : '';
|
||||
return row;
|
||||
}));
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
|
||||
@@ -133,6 +133,7 @@ svg { width: 22px; height: 22px; fill: currentColor; }
|
||||
.ghost { background: transparent; border: 1px solid var(--outline); color: var(--muted); box-shadow: none; }
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@keyframes blink { 0%, 80%, 100% { opacity: .2; } 40% { opacity: 1; } }
|
||||
@keyframes rise { from { transform: translateY(100%); opacity: .4; } }
|
||||
|
||||
/* Header */
|
||||
@@ -302,6 +303,38 @@ header .logo { height: 44px; width: auto; display: block; margin: var(--space-xs
|
||||
|
||||
.toggle .leave { display: none; }
|
||||
|
||||
/* Card - Spotify Queue */
|
||||
|
||||
.queue { background: var(--shade); border-radius: var(--radius-lg); }
|
||||
.queue summary {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: var(--space-md);
|
||||
min-height: 44px; padding: 0 var(--space-md);
|
||||
font-size: var(--text-sm); font-weight: 600; color: var(--muted);
|
||||
list-style: none; cursor: pointer;
|
||||
}
|
||||
.queue summary::-webkit-details-marker { display: none; }
|
||||
.queue-label { display: flex; align-items: center; gap: var(--space-sm); }
|
||||
.queue-label svg { width: 16px; height: 16px; }
|
||||
.queue summary .chevron { width: 16px; height: 16px; transition: transform .2s ease-out; }
|
||||
.queue[open] summary .chevron { transform: rotate(90deg); }
|
||||
|
||||
.queue-list {
|
||||
list-style: none; margin: 0;
|
||||
padding: 0 var(--space-md) var(--space-md);
|
||||
display: flex; flex-direction: column; gap: var(--space-sm);
|
||||
}
|
||||
.queue-item { display: flex; align-items: center; gap: var(--space-md); min-width: 0; }
|
||||
.queue-item .cover { width: 40px; height: 40px; border-radius: 8px; }
|
||||
.queue-item .track { flex: 1; }
|
||||
.queue-item .song { font-size: var(--text-sm); }
|
||||
.queue-item .artist { font-size: var(--text-xs); }
|
||||
.queue-time { flex: 0 0 auto; font-size: var(--text-xs); color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||
.queue-note { font-size: var(--text-sm); color: var(--muted); }
|
||||
.queue-loading { display: block; width: 28px; height: 28px; margin: 0 auto; }
|
||||
.queue-loading path { animation: blink 1.2s ease-in-out infinite; }
|
||||
.queue-loading path:nth-child(2) { animation-delay: .2s; }
|
||||
.queue-loading path:nth-child(3) { animation-delay: .4s; }
|
||||
|
||||
/* Card - AVR Input */
|
||||
|
||||
.input-select {
|
||||
|
||||
@@ -121,6 +121,17 @@
|
||||
<div class="joined" data-role="joined" hidden></div>
|
||||
{% else %}
|
||||
{{ control_row(room.key, accounts) }}
|
||||
{# Folded until asked for, and only filled in then: see paintQueue. #}
|
||||
<details class="queue" data-role="queue" hidden>
|
||||
<summary>
|
||||
<span class="queue-label">
|
||||
<svg viewBox="0 0 512 512" aria-hidden="true"><path d="M48 144a48 48 0 1 0 0-96 48 48 0 1 0 0 96zM192 64c-17.7 0-32 14.3-32 32s14.3 32 32 32l288 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L192 64zm0 160c-17.7 0-32 14.3-32 32s14.3 32 32 32l288 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-288 0zm0 160c-17.7 0-32 14.3-32 32s14.3 32 32 32l288 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-288 0zM48 464a48 48 0 1 0 0-96 48 48 0 1 0 0 96zM96 256a48 48 0 1 0 -96 0 48 48 0 1 0 96 0z"/></svg>
|
||||
<span>Up next</span>
|
||||
</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>
|
||||
</summary>
|
||||
<ol class="queue-list" data-role="queue-list"></ol>
|
||||
</details>
|
||||
{% endif %}
|
||||
</section>
|
||||
{%- endmacro %}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Stand-in Spotify Web API: just enough of /api/token, .../player/devices,
|
||||
.../player, .../player/pause and .../player/seek to test spotify.py against, the same way
|
||||
.../player, .../player/queue, .../player/pause and .../player/seek to test spotify.py against, the same way
|
||||
fakes.py stands in for real HEOS hardware."""
|
||||
|
||||
import json
|
||||
@@ -27,6 +27,7 @@ class FakeSpotify(threading.Thread):
|
||||
self.reject_refresh = False # simulate a revoked refresh token
|
||||
self.scope = "user-read-playback-state user-modify-playback-state"
|
||||
self.player = None # GET /v1/me/player's body; None is no session (204)
|
||||
self.queue = [] # GET /v1/me/player/queue's "queue"
|
||||
self.forbid = None # a plain-text 403 body every API call answers with
|
||||
|
||||
fake = self
|
||||
@@ -130,6 +131,12 @@ class FakeSpotify(threading.Thread):
|
||||
else:
|
||||
self._send(200, fake.player)
|
||||
return
|
||||
if self.path == "/v1/me/player/queue":
|
||||
if not self._authorized():
|
||||
self._send(401, {"error": {"message": "The access token expired"}})
|
||||
return
|
||||
self._send(200, {"currently_playing": None, "queue": fake.queue})
|
||||
return
|
||||
self._send(404, {"error": {"message": "not found"}})
|
||||
|
||||
self.server = HTTPServer(("127.0.0.1", 0), Handler)
|
||||
|
||||
@@ -64,6 +64,10 @@ class SpotifyTest(unittest.TestCase):
|
||||
self.client.seek(90000)
|
||||
self.assertEqual(self.fake.seeks, [90000])
|
||||
|
||||
def test_queue_lists_what_comes_next_in_order(self):
|
||||
self.fake.queue = [{"name": "Old King"}, {"name": "Natural Beauty"}]
|
||||
self.assertEqual([i["name"] for i in self.client.queue()], ["Old King", "Natural Beauty"])
|
||||
|
||||
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()
|
||||
|
||||
+2
-2
@@ -16,7 +16,7 @@ for one job -- if you have librsvg to hand, this does the same:
|
||||
|
||||
rsvg-convert -w 512 -h 512 --background-color '#0a0d14' static/logo.svg -o static/icon-512.png
|
||||
|
||||
Otherwise: pip install playwright && playwright install chromium
|
||||
Otherwise: pip install -r requirements-dev.txt && playwright install chromium
|
||||
"""
|
||||
|
||||
import sys
|
||||
@@ -42,7 +42,7 @@ def main():
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except ImportError:
|
||||
sys.exit("needs playwright: pip install playwright && playwright install chromium")
|
||||
sys.exit("needs playwright: pip install -r requirements-dev.txt && playwright install chromium")
|
||||
|
||||
if not LOGO.exists():
|
||||
sys.exit(f"no logo at {LOGO}")
|
||||
|
||||
Reference in new issue
Block a user