@@ -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=<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
|
||||
|
||||
|
||||
@@ -158,6 +158,8 @@ def manifest():
|
||||
@handle_errors
|
||||
def api_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"])
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
+35
-11
@@ -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
|
||||
]
|
||||
|
||||
|
||||
@@ -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()},
|
||||
|
||||
+118
-24
@@ -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;
|
||||
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', () => {
|
||||
|
||||
+68
-20
@@ -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; }
|
||||
|
||||
+21
-10
@@ -64,33 +64,44 @@
|
||||
<section class="card room" data-room="{{ room.key }}">
|
||||
<div class="card-head">
|
||||
<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 class="volume">
|
||||
<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>
|
||||
</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">
|
||||
<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 class="actions">
|
||||
<button class="transport" data-role="play" aria-label="{{ room.label }}: play" hidden>
|
||||
<div class="actions" data-role="actions" 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-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" 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>
|
||||
</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>
|
||||
|
||||
{{ spotify_row(room.key, spotify_accounts) }}
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user