fix music navs
Deploy HEOS panel / deploy (push) Successful in 25s

This commit is contained in:
2026-09-15 23:19:11 +02:00
parent 7c19ff9096
commit 5b82a24ac4
9 changed files with 299 additions and 72 deletions
+10 -4
View File
@@ -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 - **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 — 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 - **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 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 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 lists what Spotify actually calls each device, if a room's button ever says
one isn't visible. one isn't visible.
A room's play/pause and next buttons only appear while HEOS reports it is A room's previous, play/pause and next buttons only appear while HEOS
playing (or paused on) Spotify. To see what HEOS reports for a room, use 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 `GET /raw/player/get_now_playing_media?pid=<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 ## Add it to the iOS home screen
+2
View File
@@ -158,6 +158,8 @@ def manifest():
@handle_errors @handle_errors
def api_state(): def api_state():
data = 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"]) _mark_spotify_accounts(data["rooms"])
return jsonify(data) return jsonify(data)
+35 -11
View File
@@ -378,20 +378,38 @@ class Controller:
reply = self.heos.command("player/get_play_state", pid=self.playback_pid(key)) reply = self.heos.command("player/get_play_state", pid=self.playback_pid(key))
return parse_message(reply["heos"]["message"]).get("state", "stop") return parse_message(reply["heos"]["message"]).get("state", "stop")
def on_spotify(self, key: str) -> bool: def now_playing_media(self, key: str) -> dict:
"""Whether a room's now-playing is a Spotify stream, playing or """HEOS's get_now_playing_media payload for a room. An idle player
paused -- the only thing its play/pause and next buttons make sense can refuse the query outright, which is the same as nothing loaded."""
for. An idle player can refuse the query outright, which is "not
Spotify" too."""
with self._lock: with self._lock:
self._fresh() self._fresh()
try: try:
reply = self.heos.command("player/get_now_playing_media", pid=self.playback_pid(key)) reply = self.heos.command("player/get_now_playing_media", pid=self.playback_pid(key))
except HeosError: except HeosError:
return False return {}
payload = reply.get("payload") or {} return reply.get("payload") or {}
return (str(payload.get("sid")) == self.SPOTIFY_SID
or str(payload.get("mid", "")).startswith("spotify:")) @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: def toggle_play(self, key: str, state: str = None) -> str:
"""Start or stop a room. With no state, flips whatever it is doing """Start or stop a room. With no state, flips whatever it is doing
@@ -506,13 +524,19 @@ class Controller:
"volume": None, "volume": None,
"play_state": None, "play_state": None,
"spotify": False, "spotify": False,
"now_playing": None,
"error": None, "error": None,
} }
try: try:
scope, obj_id = self._volume_handles(key)[0] scope, obj_id = self._volume_handles(key)[0]
room["volume"] = self._read_volume(scope, obj_id) room["volume"] = self._read_volume(scope, obj_id)
room["play_state"] = self.get_play_state(key) 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: except (TargetError, HeosError, KeyError) as exc:
room["available"] = False room["available"] = False
room["error"] = str(exc) room["error"] = str(exc)
@@ -523,7 +547,7 @@ class Controller:
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, "play_state": None, "spotify": 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 for key in self.cfg.ROOM_KEYS
] ]
+8 -1
View File
@@ -53,6 +53,10 @@ class DemoController:
self._joined = set() self._joined = set()
# One room on Spotify and one not, so both kinds of card show up. # 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)} 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 --------------------------------------------- # -- what the UI uses ---------------------------------------------
def state(self): def state(self):
@@ -61,7 +65,10 @@ class DemoController:
"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], "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 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()},
+118 -24
View File
@@ -59,20 +59,29 @@ 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), play: el('[data-role="play"]', node),
next: el('[data-role="next"]', node), next: el('[data-role="next"]', node),
spotify: el('.spotify-row', node), // absent with no Spotify account set up 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), steps: els('.step', node),
volume: null, volume: null,
playState: null, playState: null,
track: null, // {song, artist, image} while something is loaded
onSpotify: false, onSpotify: false,
spotifyAccount: null, // the account playing here, whose button gets a border 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, playBusy: false,
@@ -83,32 +92,42 @@ 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);
// 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.play.addEventListener('click', () => togglePlay(key));
room.next.addEventListener('click', () => skipTrack(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; });
paintTrack(room);
const playing = room.playState === 'play'; const playing = room.playState === 'play';
room.play.classList.toggle('playing', playing); room.play.classList.toggle('playing', playing);
room.play.disabled = !room.available || room.playState === null; room.play.disabled = !room.available || room.playState === null;
room.play.setAttribute( room.play.setAttribute(
'aria-label', `${room.node.querySelector('h2').textContent}: ${playing ? 'pause' : 'play'}`); 'aria-label', `${room.node.querySelector('h2').textContent}: ${playing ? 'pause' : 'play'}`);
// Play/pause and next only mean something for a Spotify stream: an AVR // Previous, play/pause and next only mean something for a Spotify stream:
// input has no queue to pause or skip, and starting Spotify is what the // an AVR input has no queue to pause or skip, and starting Spotify is what
// account buttons are for. Grouped, transport belongs to the AVR's card, // the account buttons are for. Grouped, transport belongs to the AVR's
// not this one -- pressing either here would still work (it shares the // card, not this one -- pressing them here would still work (it shares
// group's transport) but only invites confusion about which card is // the group's transport) but only invites confusion about which card is
// actually in charge of it. // actually in charge of it. And only for a stream one of our own accounts
const transport = room.onSpotify && !room.grouped; // is playing: someone else's phone keeps its own controls. The row shows
room.play.hidden = !transport; // or hides as one, so an empty row never leaves a gap in the card.
room.next.hidden = !transport || !playing; room.actions.hidden = !(room.onSpotify && room.spotifyAccount && !room.grouped);
room.prev.disabled = !room.available;
room.next.disabled = !room.available; room.next.disabled = !room.available;
// Resuming stays on offer while grouped: only the speakers have Spotify // Resuming stays on offer while grouped: only the speakers have Spotify
// buttons, so hiding them here would leave none at all. // buttons, so hiding them here would leave none at all.
@@ -128,6 +147,24 @@ function paintRoom(room) {
placeRoom(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 /* 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. */
@@ -193,13 +230,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);
} }
@@ -208,10 +255,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;
@@ -250,13 +339,15 @@ async function togglePlay(key) {
} }
} }
/* Fire-and-forget: the queue's next track has no local state to reconcile, /* Nothing to optimistically flip the way play/pause does -- the panel
so there is nothing to optimistically flip the way play/pause does. */ cannot guess which song comes up -- so it looks again once HEOS has
async function skipTrack(key) { moved on, rather than showing the old song until the next poll. */
async function skipTrack(key, direction) {
const room = rooms[key]; const room = rooms[key];
if (!room.available) return; if (!room.available) return;
try { try {
await api('/api/skip', { target: key, direction: 'next' }); await api('/api/skip', { target: key, direction });
setTimeout(refresh, 1000);
} catch (error) { } catch (error) {
toast(error.message); toast(error.message);
} }
@@ -359,8 +450,11 @@ 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; if (!room.playBusy) room.playState = incoming.play_state;
room.track = incoming.now_playing || null;
room.onSpotify = Boolean(incoming.spotify); room.onSpotify = Boolean(incoming.spotify);
room.spotifyAccount = incoming.spotify_account || null; room.spotifyAccount = incoming.spotify_account || null;
paintRoom(room); paintRoom(room);
@@ -395,7 +489,7 @@ async function refresh() {
function busy() { function busy() {
return sheetOpen() 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', () => { ui.refresh.addEventListener('click', () => {
+68 -20
View File
@@ -85,14 +85,10 @@ svg { width: 22px; height: 22px; fill: currentColor; }
.card.offline { opacity: .5; } .card.offline { opacity: .5; }
.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 {
margin: 0; min-width: 0;
.level { color: var(--muted); font-size: 13px; } font-size: 17px; font-weight: 600;
.level b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
color: var(--ink);
font-size: 26px;
font-weight: 640;
font-variant-numeric: tabular-nums;
} }
.pill { .pill {
@@ -102,6 +98,26 @@ svg { width: 22px; height: 22px; fill: currentColor; }
} }
.pill.on { color: var(--live); } .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 ---------------------------------------------------------- */
.volume { display: flex; align-items: center; gap: 14px; } .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: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%);
border-radius: 999px; background: var(--accent);
transition: width .12s ease-out;
}
/* --- play / pause, beside the join button ------------------------------ */ /* The level rides the bar. It travels the track less its own width, so it
.actions { display: flex; align-items: stretch; gap: 10px; } never hangs off either end over the buttons -- and the fill's end is
.actions .toggle { flex: 1; } 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 { .transport {
flex: 0 0 auto; flex: 0 0 auto;
@@ -141,15 +186,16 @@ svg { width: 22px; height: 22px; fill: currentColor; }
.transport.playing .icon-play { display: none; } .transport.playing .icon-play { display: none; }
.transport.playing .icon-pause { display: block; } .transport.playing .icon-pause { display: block; }
/* --- join / leave the AVR -------------------------------------------- */ /* --- join / leave the AVR, beside the room's name ---------------------- */
.toggle { .toggle {
height: 62px; border-radius: 16px; flex: 0 0 auto;
height: 40px; padding: 0 14px; border-radius: 999px;
background: var(--raised); background: var(--raised);
display: flex; align-items: center; justify-content: center; gap: 10px; display: flex; align-items: center; justify-content: center; gap: 8px;
font-size: 15px; font-weight: 550; font-size: 14px; font-weight: 550; white-space: nowrap;
color: var(--muted); 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"] { background: var(--accent); color: #fff; }
.toggle[aria-pressed="true"] .dot { background: #fff; opacity: 1; } .toggle[aria-pressed="true"] .dot { background: #fff; opacity: 1; }
.toggle:active { transform: scale(.985); } .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 { 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.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 .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 .step { height: 54px; }
.joined .toggle, .joined .toggle,
.joined .toggle[aria-pressed="true"] { .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); background: none; border: 1px solid var(--edge); color: var(--muted);
} }
.joined .toggle .dot { display: none; } .joined .toggle .dot { display: none; }
+21 -10
View File
@@ -64,33 +64,44 @@
<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>{{ room.label }}</h2>
<span class="level"><b data-role="level"></b></span> <button class="toggle" data-role="group" aria-pressed="false">
<span class="dot" aria-hidden="true"></span>
<span data-role="group-label">Separate</span>
</button>
</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>
<div class="volume"> <div class="volume">
<button class="step" data-delta="-1" aria-label="{{ room.label }}: volume down"> <button class="step" data-delta="-1" aria-label="{{ room.label }}: volume down">
<svg viewBox="0 0 448 512" aria-hidden="true"><path d="M432 256c0 17.7-14.3 32-32 32L48 288c-17.7 0-32-14.3-32-32s14.3-32 32-32l352 0c17.7 0 32 14.3 32 32z"/></svg> <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> </button>
<div class="meter"><i data-role="bar"></i></div> <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"> <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> <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> </button>
</div> </div>
<div class="actions"> <div class="actions" data-role="actions" hidden>
<button class="transport" data-role="play" aria-label="{{ room.label }}: play" hidden> <button class="transport" data-role="prev" aria-label="{{ room.label }}: previous track">
<!-- backward-step: the forward-step glyph below, mirrored -->
<svg viewBox="0 0 320 512" aria-hidden="true"><path transform="matrix(-1 0 0 1 320 0)" d="M52.5 440.6c-9.5 7.9-22.8 9.7-34.1 4.4S0 428.4 0 416L0 96C0 83.6 7.2 72.3 18.4 67s24.5-3.6 34.1 4.4l192 160L256 241l0-145c0-17.7 14.3-32 32-32s32 14.3 32 32l0 320c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-145-11.5 9.6-192 160z"/></svg>
</button>
<button class="transport" data-role="play" aria-label="{{ room.label }}: play">
<svg class="icon-play" viewBox="0 0 384 512" aria-hidden="true"><path d="M73 39c-14.8-9.1-33.4-9.4-48.5-.9S0 62.6 0 80L0 432c0 17.4 9.4 33.4 24.5 41.9s33.7 8.1 48.5-.9L361 297c14.3-8.7 23-24.2 23-41s-8.7-32.2-23-41L73 39z"/></svg> <svg class="icon-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> <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>
<button class="transport" data-role="next" aria-label="{{ room.label }}: next track" hidden> <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> <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> </button>
<button class="toggle" data-role="group" aria-pressed="false">
<span class="dot" aria-hidden="true"></span>
<span data-role="group-label">Separate</span>
</button>
</div> </div>
{{ spotify_row(room.key, spotify_accounts) }} {{ spotify_row(room.key, spotify_accounts) }}
+2
View File
@@ -32,6 +32,7 @@ class FakeHeos(threading.Thread):
self.group_volumes = {3: 25} self.group_volumes = {3: 25}
self.now_playing_mid = {1: "inputs/mediaplayer"} # pid -> what get_now_playing_media reports 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_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)
@@ -124,6 +125,7 @@ class FakeHeos(threading.Thread):
payload = {"mid": mid, "station": name} if mid else {} payload = {"mid": mid, "station": name} if mid else {}
if pid in self.now_playing_sid: if pid in self.now_playing_sid:
payload["sid"] = self.now_playing_sid[pid] payload["sid"] = self.now_playing_sid[pid]
payload.update(self.now_playing_track.get(pid, {}))
return self._ok(path, payload=payload) return self._ok(path, payload=payload)
if path == "browse/browse": if path == "browse/browse":
+33
View File
@@ -182,6 +182,39 @@ class PanelTest(unittest.TestCase):
state = self.panel.state() state = self.panel.state()
self.assertEqual([r["spotify"] for r in state["rooms"]], [True, False]) 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): def test_an_avr_input_is_not_spotify(self):
self.heos.now_playing_sid[AVR_PID] = 1027 self.heos.now_playing_sid[AVR_PID] = 1027
self.assertFalse(self.panel.on_spotify("avr")) self.assertFalse(self.panel.on_spotify("avr"))