diff --git a/README.md b/README.md index af1fc17..9e3c0e1 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ Everything it does fits on one screen: - **Volume** up/down for the Home 400 and the Living Room pair. 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. + so the levels stay round. Hold to keep moving, or drag the level along + its bar to set it outright. - **Play or pause** either room, or skip a track, while it is playing Spotify — the buttons only show up then, since an AVR input has nothing to pause or skip. A room that is grouped shares the AVR's @@ -134,10 +135,15 @@ key is missing (they usually match). `GET /api/spotify/devices?account=fifou` lists what Spotify actually calls each device, if a room's button ever says one isn't visible. -A room's play/pause and next buttons only appear while HEOS reports it is -playing (or paused on) Spotify. To see what HEOS reports for a room, use +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=` with a pid from -`/api/targets`: Spotify shows up as `"sid": 4`. +`/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 diff --git a/app.py b/app.py index cc9ebd2..704c486 100644 --- a/app.py +++ b/app.py @@ -158,7 +158,9 @@ def manifest(): @handle_errors def api_state(): data = controller.state() - _mark_spotify_accounts(data["rooms"]) + # 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"]) return jsonify(data) diff --git a/controller.py b/controller.py index b427e4a..5bb01ee 100644 --- a/controller.py +++ b/controller.py @@ -378,20 +378,38 @@ class Controller: reply = self.heos.command("player/get_play_state", pid=self.playback_pid(key)) return parse_message(reply["heos"]["message"]).get("state", "stop") - 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. An idle player can refuse the query outright, which is "not - Spotify" too.""" + 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 False - payload = reply.get("payload") or {} - return (str(payload.get("sid")) == self.SPOTIFY_SID - or str(payload.get("mid", "")).startswith("spotify:")) + 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 @@ -506,13 +524,19 @@ class Controller: "volume": None, "play_state": None, "spotify": False, + "now_playing": None, "error": None, } try: scope, obj_id = self._volume_handles(key)[0] room["volume"] = self._read_volume(scope, obj_id) room["play_state"] = self.get_play_state(key) - room["spotify"] = self.on_spotify(key) + media = self.now_playing_media(key) + 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": + room["now_playing"] = self._track(media) except (TargetError, HeosError, KeyError) as exc: room["available"] = False room["error"] = str(exc) @@ -523,7 +547,7 @@ class Controller: snapshot["rooms"] = [ {"key": key, "label": self.cfg.TARGETS[key]["label"], "available": False, "grouped": False, "volume": None, "play_state": None, "spotify": False, - "error": str(exc)} + "now_playing": None, "error": str(exc)} for key in self.cfg.ROOM_KEYS ] diff --git a/demo.py b/demo.py index 2b06fe6..f9dc45d 100644 --- a/demo.py +++ b/demo.py @@ -53,6 +53,10 @@ class DemoController: 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) # -- what the UI uses --------------------------------------------- def state(self): @@ -61,7 +65,10 @@ class DemoController: "rooms": [ {"key": key, "label": self.cfg.TARGETS[key]["label"], "available": True, "grouped": key in self._joined, "volume": self._volume[key], - "play_state": self._play[key], "spotify": self._spotify[key], "error": None} + "play_state": self._play[key], "spotify": self._spotify[key], + "spotify_account": self._account if self._spotify[key] else None, + "now_playing": self._track if self._spotify[key] and self._play[key] != "stop" else None, + "error": None} for key in self.cfg.ROOM_KEYS ], "avr": {"connected": True, "inputs": self.avr.inputs(), "input": self.avr.current_input()}, diff --git a/static/app.js b/static/app.js index e8b00a6..92b063c 100644 --- a/static/app.js +++ b/static/app.js @@ -59,20 +59,29 @@ els('.room').forEach((node) => { node, slot: el(`.room-slot[data-slot="${key}"]`), 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), 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), // absent with no Spotify account set up + 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), steps: els('.step', node), volume: null, playState: null, + track: null, // {song, artist, image} while something is loaded onSpotify: false, spotifyAccount: null, // the account playing here, whose button gets a border grouped: false, available: false, taps: 0, // button presses not yet sent + wanted: null, // a level dragged to, not yet sent + dragging: false, inflight: false, busy: false, // a grouping change is in flight playBusy: false, @@ -83,32 +92,42 @@ els('.room').forEach((node) => { const direction = Number(button.dataset.delta); holdable(button, () => nudge(key, direction)); }); + draggable(room); + + // A cover that will not load (gone, or plain http on an https page) is + // dropped rather than left as a broken-image box. Its src stays put, so + // the next poll does not try it again until the track changes. + room.cover.addEventListener('error', () => { room.cover.hidden = true; }); room.toggle.addEventListener('click', () => setGrouped(key, !room.grouped)); + room.prev.addEventListener('click', () => skipTrack(key, 'previous')); room.play.addEventListener('click', () => togglePlay(key)); - room.next.addEventListener('click', () => skipTrack(key)); + room.next.addEventListener('click', () => skipTrack(key, 'next')); }); function paintRoom(room) { const known = room.volume !== null && room.volume !== undefined; - room.level.textContent = known ? room.volume + '%' : '—'; - room.bar.style.width = `${known ? room.volume : 0}%`; + room.level.textContent = known ? room.volume : '—'; + // The fill and the knob riding it both size themselves off this. + room.meter.style.setProperty('--level', known ? room.volume : 0); room.node.classList.toggle('offline', !room.available); room.steps.forEach((button) => { button.disabled = !room.available; }); + paintTrack(room); const playing = room.playState === 'play'; 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'}`); - // 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 either here would still work (it shares the - // group's transport) but only invites confusion about which card is - // actually in charge of it. - const transport = room.onSpotify && !room.grouped; - room.play.hidden = !transport; - room.next.hidden = !transport || !playing; + // 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; // Resuming stays on offer while grouped: only the speakers have Spotify // buttons, so hiding them here would leave none at all. @@ -128,6 +147,24 @@ function paintRoom(room) { placeRoom(room); } +/* Song, artist and cover, each only when HEOS has one. The cover's src is + only touched when the track changes, so a poll never makes it flicker. */ +function paintTrack(room) { + const track = room.available ? room.track : null; + room.nowPlaying.hidden = !track; + if (!track) return; + room.song.textContent = track.song; + room.artist.textContent = track.artist || ''; + room.artist.hidden = !track.artist; + if (!track.image) { + room.cover.hidden = true; + room.cover.removeAttribute('src'); + } else if (room.cover.getAttribute('src') !== track.image) { + room.cover.hidden = false; + room.cover.src = track.image; + } +} + /* A merged room moves into the host's card, because that is what merging means: one group, playing one thing. Leaving puts the card back in its own slot, which is why the slots exist. */ @@ -193,13 +230,23 @@ function nudge(key, direction) { thirty requests the speakers then have to chew through. */ async function flushVolume(key) { const room = rooms[key]; - if (room.inflight || !room.taps) return; - const steps = room.taps; - room.taps = 0; + if (room.inflight) return; + // A dragged level goes first: any taps still waiting were pressed after it, + // so they are meant to move on from it. + let body; + if (room.wanted !== null) { + body = { target: key, level: room.wanted }; + room.wanted = null; + } else if (room.taps) { + body = { target: key, steps: room.taps }; + room.taps = 0; + } else { + return; + } room.inflight = true; try { - const data = await api('/api/volume', { target: key, steps }); - if (!room.taps) { + const data = await api('/api/volume', body); + if (!room.taps && room.wanted === null && !room.dragging) { room.volume = data.level; paintRoom(room); } @@ -208,10 +255,52 @@ async function flushVolume(key) { refresh(); } finally { 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) { const room = rooms[key]; if (room.busy || !room.available) return; @@ -250,13 +339,15 @@ async function togglePlay(key) { } } -/* Fire-and-forget: the queue's next track has no local state to reconcile, - so there is nothing to optimistically flip the way play/pause does. */ -async function skipTrack(key) { +/* 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: 'next' }); + await api('/api/skip', { target: key, direction }); + setTimeout(refresh, 1000); } catch (error) { toast(error.message); } @@ -359,8 +450,11 @@ function render(state) { room.available = incoming.available; room.grouped = incoming.grouped; // Do not stomp on a volume the user is in the middle of changing. - if (!room.taps && !room.inflight) room.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); @@ -395,7 +489,7 @@ async function refresh() { function busy() { return sheetOpen() - || Object.values(rooms).some((r) => r.taps || r.inflight || r.busy || r.playBusy); + || Object.values(rooms).some((r) => r.taps || r.inflight || r.dragging || r.busy || r.playBusy); } ui.refresh.addEventListener('click', () => { diff --git a/static/panel.css b/static/panel.css index ae2ec57..b29bafa 100644 --- a/static/panel.css +++ b/static/panel.css @@ -85,14 +85,10 @@ svg { width: 22px; height: 22px; fill: currentColor; } .card.offline { opacity: .5; } .card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; } -.card-head h2 { margin: 0; font-size: 17px; font-weight: 600; } - -.level { color: var(--muted); font-size: 13px; } -.level b { - color: var(--ink); - font-size: 26px; - font-weight: 640; - font-variant-numeric: tabular-nums; +.card-head h2 { + margin: 0; min-width: 0; + font-size: 17px; font-weight: 600; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .pill { @@ -102,6 +98,26 @@ svg { width: 22px; height: 22px; fill: currentColor; } } .pill.on { color: var(--live); } +/* --- now playing, between the room's name and its volume --------------- */ +/* The padding matches the card's gap, so the line sits halfway between the + song and the volume. It goes with the block, when nothing is playing. */ +.now-playing { + display: flex; align-items: center; gap: 16px; min-width: 0; + padding-bottom: 14px; + border-bottom: 1px solid var(--edge); +} +.cover { + flex: 0 0 auto; + width: 62px; height: 62px; + border-radius: 13px; + object-fit: cover; + background: var(--raised); +} +.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); } + /* --- volume ---------------------------------------------------------- */ .volume { display: flex; align-items: center; gap: 14px; } @@ -116,12 +132,41 @@ svg { width: 22px; height: 22px; fill: currentColor; } .step:active { background: var(--accent); transform: scale(.96); } .step:disabled { opacity: .4; } -.meter { flex: 1; height: 8px; border-radius: 999px; background: #0c111b; overflow: hidden; } -.meter i { display: block; height: 100%; width: 0; border-radius: 999px; background: var(--accent); transition: width .12s ease-out; } +.meter { position: relative; flex: 1; height: 8px; border-radius: 999px; background: #0c111b; } +.meter i { + display: block; height: 100%; width: calc(var(--level, 0) * 1%); + border-radius: 999px; background: var(--accent); + transition: width .12s ease-out; +} -/* --- play / pause, beside the join button ------------------------------ */ -.actions { display: flex; align-items: stretch; gap: 10px; } -.actions .toggle { flex: 1; } +/* 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; @@ -141,15 +186,16 @@ svg { width: 22px; height: 22px; fill: currentColor; } .transport.playing .icon-play { display: none; } .transport.playing .icon-pause { display: block; } -/* --- join / leave the AVR -------------------------------------------- */ +/* --- join / leave the AVR, beside the room's name ---------------------- */ .toggle { - height: 62px; border-radius: 16px; + flex: 0 0 auto; + height: 40px; padding: 0 14px; border-radius: 999px; background: var(--raised); - display: flex; align-items: center; justify-content: center; gap: 10px; - font-size: 15px; font-weight: 550; + display: flex; align-items: center; justify-content: center; gap: 8px; + font-size: 14px; font-weight: 550; white-space: nowrap; color: var(--muted); } -.toggle .dot { width: 9px; height: 9px; border-radius: 50%; background: currentColor; opacity: .6; } +.toggle .dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; opacity: .6; } .toggle[aria-pressed="true"] { background: var(--accent); color: #fff; } .toggle[aria-pressed="true"] .dot { background: #fff; opacity: 1; } .toggle:active { transform: scale(.985); } @@ -196,11 +242,13 @@ svg { width: 22px; height: 22px; fill: currentColor; } .joined .card.room { background: none; border: 0; border-radius: 0; padding: 0; gap: 12px; } .joined .card.room + .card.room { border-top: 1px solid var(--edge); padding-top: 14px; } .joined .card-head h2 { font-size: 15px; font-weight: 550; color: var(--muted); } -.joined .level b { font-size: 22px; } +.joined .now-playing { padding-bottom: 12px; } +.joined .cover { width: 52px; height: 52px; border-radius: 10px; } +.joined .song { font-size: 18px; } .joined .step { height: 54px; } .joined .toggle, .joined .toggle[aria-pressed="true"] { - height: 40px; font-size: 13px; font-weight: 500; + height: 36px; font-size: 13px; font-weight: 500; background: none; border: 1px solid var(--edge); color: var(--muted); } .joined .toggle .dot { display: none; } diff --git a/templates/index.html b/templates/index.html index cfedc16..7a49c7a 100644 --- a/templates/index.html +++ b/templates/index.html @@ -64,33 +64,44 @@

{{ room.label }}

- + +
+ +
-
+
-
- + + - - -
{{ spotify_row(room.key, spotify_accounts) }} diff --git a/tests/fakes.py b/tests/fakes.py index 984eddc..d65ca7d 100644 --- a/tests/fakes.py +++ b/tests/fakes.py @@ -32,6 +32,7 @@ class FakeHeos(threading.Thread): self.group_volumes = {3: 25} self.now_playing_mid = {1: "inputs/mediaplayer"} # pid -> what get_now_playing_media reports self.now_playing_sid = {} # pid -> its source id; 4 is Spotify + self.now_playing_track = {} # pid -> song/artist/image_url fields self.commands = [] # everything we were asked to do self.server = socket.socket() self.server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) @@ -124,6 +125,7 @@ class FakeHeos(threading.Thread): 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": diff --git a/tests/test_panel.py b/tests/test_panel.py index c2a4c2a..170b6a6 100644 --- a/tests/test_panel.py +++ b/tests/test_panel.py @@ -182,6 +182,39 @@ class PanelTest(unittest.TestCase): state = self.panel.state() self.assertEqual([r["spotify"] for r in state["rooms"]], [True, False]) + def test_state_carries_the_song_a_room_is_playing(self): + self.heos.now_playing_mid[HOME400_PID] = "spotify:track:4uLU6hMCjMI75M1A2tKUQC" + self.heos.now_playing_track[HOME400_PID] = { + "song": "Harvest Moon", "artist": "Neil Young", "image_url": "https://i.scdn.co/image/abc", + } + state = self.panel.state() + self.assertEqual( + [r["now_playing"] for r in state["rooms"]], + [{"song": "Harvest Moon", "artist": "Neil Young", "image": "https://i.scdn.co/image/abc"}, None], + ) + + def test_a_song_without_artist_or_cover_still_shows(self): + self.heos.now_playing_mid[HOME400_PID] = "spotify:track:4uLU6hMCjMI75M1A2tKUQC" + self.heos.now_playing_track[HOME400_PID] = {"song": "Harvest Moon", "artist": "", "image_url": ""} + self.assertEqual( + self.panel.state()["rooms"][0]["now_playing"], + {"song": "Harvest Moon", "artist": None, "image": None}, + ) + + def test_a_paused_room_keeps_its_song_and_a_stopped_one_drops_it(self): + self.heos.now_playing_mid[HOME400_PID] = "spotify:track:4uLU6hMCjMI75M1A2tKUQC" + self.heos.now_playing_track[HOME400_PID] = {"song": "Harvest Moon"} + self.heos.play_states[HOME400_PID] = "pause" + self.assertIsNotNone(self.panel.state()["rooms"][0]["now_playing"]) + self.heos.play_states[HOME400_PID] = "stop" + self.assertIsNone(self.panel.state()["rooms"][0]["now_playing"]) + + def test_an_avr_input_is_not_a_song(self): + """HEOS puts an input's own name where the song goes.""" + self.heos.now_playing_mid[HOME400_PID] = "inputs/mediaplayer" + self.heos.now_playing_track[HOME400_PID] = {"song": "Z30 Pro"} + self.assertIsNone(self.panel.state()["rooms"][0]["now_playing"]) + def test_an_avr_input_is_not_spotify(self): self.heos.now_playing_sid[AVR_PID] = 1027 self.assertFalse(self.panel.on_spotify("avr"))