Fix grouped AVR / room card + add movie info
Deploy HEOS panel / deploy (push) Successful in 25s

This commit is contained in:
2026-09-16 20:36:29 +02:00
parent 69476134f7
commit 532b48ad37
7 changed files with 316 additions and 123 deletions
+26 -1
View File
@@ -17,7 +17,7 @@ import argparse
import os import os
from functools import wraps from functools import wraps
from flask import Flask, jsonify, render_template, request from flask import Flask, jsonify, render_template, request, url_for
from werkzeug.middleware.proxy_fix import ProxyFix from werkzeug.middleware.proxy_fix import ProxyFix
import config import config
@@ -144,6 +144,18 @@ def _mark_spotify_accounts(rooms: list):
room["spotify_account"] = playing_on.get(_spotify_name(room["key"])) room["spotify_account"] = playing_on.get(_spotify_name(room["key"]))
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
http, which a phone will not load into an https page."""
track = avr.get("now_playing")
if not track:
return
poster_id = track.pop("poster_id", None)
if poster_id is not None:
track["image"] = url_for("api_zidoo_poster", poster_id=poster_id)
# --- The UI ----------------------------------------------------------- # --- The UI -----------------------------------------------------------
@app.route("/") @app.route("/")
def index(): def index():
@@ -177,9 +189,22 @@ def api_state():
# Demo rooms are not on anyone's real Spotify, so they bring their own account. # Demo rooms are not on anyone's real Spotify, so they bring their own account.
if not data.get("demo"): if not data.get("demo"):
_mark_spotify_accounts(data["rooms"]) _mark_spotify_accounts(data["rooms"])
_link_zidoo_poster(data["avr"])
return jsonify(data) return jsonify(data)
@app.get("/api/zidoo/poster/<int:poster_id>")
def api_zidoo_poster(poster_id):
"""A film's poster, fetched from the Zidoo on the phone's behalf -- see
_link_zidoo_poster()."""
zidoo = getattr(controller, "zidoo", None)
image = zidoo.poster(poster_id) if zidoo else None
if image is None:
return "", 404
body, mimetype = image
return app.response_class(body, mimetype=mimetype)
@app.get("/api/targets") @app.get("/api/targets")
@handle_errors @handle_errors
def api_targets(): def api_targets():
+2 -1
View File
@@ -73,7 +73,8 @@ class DemoController:
"connected": True, "inputs": self.avr.inputs(), "input": self.avr.current_input(), "connected": True, "inputs": self.avr.inputs(), "input": self.avr.current_input(),
# Standing in for a Zidoo plugged into this input, so the # Standing in for a Zidoo plugged into this input, so the
# AVR card's now-playing block has something to show here too. # AVR card's now-playing block has something to show here too.
"now_playing": {"song": "Big Buck Bunny", "artist": None, "image": None} "now_playing": {"song": "Big Buck Bunny", "artist": "2008", "image": None,
"position_ms": 241000, "duration_ms": 596000}
if self.avr.current_input()["code"] == self.cfg.ZIDOO_INPUT_CODE else None, if self.avr.current_input()["code"] == self.cfg.ZIDOO_INPUT_CODE else None,
}, },
"heos_ok": True, "heos_ok": True,
+47 -50
View File
@@ -3,7 +3,6 @@
network round trip before it looks like it did anything feels broken. */ network round trip before it looks like it did anything feels broken. */
const STEP = Number(document.documentElement.dataset.step) || 2; const STEP = Number(document.documentElement.dataset.step) || 2;
const HOST_LABEL = document.documentElement.dataset.host || 'the AVR';
// Where the app is mounted: "/" on its own port, "/heos/" behind a proxy. // Where the app is mounted: "/" on its own port, "/heos/" behind a proxy.
const BASE = document.documentElement.dataset.base || '/'; const BASE = document.documentElement.dataset.base || '/';
const POLL_MS = 5000; const POLL_MS = 5000;
@@ -26,7 +25,7 @@ const ICONS = {
'lcr-fronts': SPEAKERS, 'lcr-fronts': SPEAKERS,
'surround-sound-system': SPEAKERS, 'surround-sound-system': SPEAKERS,
'subwoofer': SPEAKER, 'subwoofer': SPEAKER,
mediaplayer: { viewbox: '0 0 640 544', path: 'M192 64c0-35.3 28.7-64 64-64L544 0c35.3 0 64 28.7 64 64l0 120.4c-6.8-.8-13.8-.6-20.9 .6l-64.1 11.7-54.5-89.2C464.1 100.4 456.4 96 448 96s-16.1 4.4-20.5 11.5l-54 88.3-17.9-25.6c-4.5-6.4-11.8-10.2-19.7-10.2s-15.2 3.8-19.7 10.2l-56 80c-5.1 7.3-5.8 16.9-1.6 24.8S271.1 288 280 288l72 0 0 64-96 0c-35.3 0-64-28.7-64-64l0-224zM352 403.8c-15.3 3.8-29.7 10.5-42.1 19.7-8.7 6.5-16.8 14.8-23.3 24.5L64 448c-35.3 0-64-28.7-64-64L0 160c0-35.3 28.7-64 64-64l80 0 0 192c0 61.9 50.1 112 112 112l96 0 0 3.8zM320 96a32 32 0 1 0 -64 0 32 32 0 1 0 64 0zM52 164l0 24c0 8.8 7.2 16 16 16l24 0c8.8 0 16-7.2 16-16l0-24c0-8.8-7.2-16-16-16l-24 0c-8.8 0-16 7.2-16 16zm16 80c-8.8 0-16 7.2-16 16l0 24c0 8.8 7.2 16 16 16l24 0c8.8 0 16-7.2 16-16l0-24c0-8.8-7.2-16-16-16l-24 0zm0 96c-8.8 0-16 7.2-16 16l0 24c0 8.8 7.2 16 16 16l24 0c8.8 0 16-7.2 16-16l0-24c0-8.8-7.2-16-16-16l-24 0zm556-84.1l0 208c0 23.3-18 35.4-29.1 40.5-11.9 5.4-25.4 7.5-34.9 7.5s-22.9-2.1-34.9-7.5c-11.1-5-29.1-17.2-29.1-40.5s18-35.4 29.1-40.5c11.9-5.4 25.4-7.5 34.9-7.5 4.6 0 10.1 .5 16 1.6l0-84.9-128 23.3 0 139.9c0 23.3-18 35.4-29.1 40.5-11.9 5.4-25.4 7.5-34.9 7.5s-22.9-2.1-34.9-7.5c-11.1-5-29.1-17.2-29.1-40.5s18-35.4 29.1-40.5c11.9-5.4 25.4-7.5 34.9-7.5 4.6 0 10.1 .5 16 1.6l0-161.6c0-11.6 8.3-21.5 19.7-23.6l176-32c7-1.3 14.2 .6 19.7 5.2s8.6 11.3 8.6 18.4z' }, mediaplayer: { viewbox: '0 -16 576 512', path: 'M0 112c0 70.7 57.3 128 128 128l224 0c70.7 0 128-57.3 128-128S422.7-16 352-16c-48.2 0-90.2 26.6-112 66-21.8-39.4-63.8-66-112-66-70.7 0-128 57.3-128 128zm304 0a48 48 0 1 1 96 0 48 48 0 1 1 -96 0zM128 64a48 48 0 1 1 0 96 48 48 0 1 1 0-96zM64 352l0 64c0 35.3 28.7 64 64 64l224 0c35.3 0 64-28.7 64-64l0-64c0-35.3-28.7-64-64-64l-224 0c-35.3 0-64 28.7-64 64zM537.5 490.8c4.2 3.4 9.4 5.2 14.8 5.2 13.1 0 23.7-10.6 23.7-23.7l0-240.6c0-13.1-10.6-23.7-23.7-23.7-5.4 0-10.6 1.8-14.8 5.2l-73.5 58.8 0 160 73.5 58.8z' },
game: { viewbox: '0 0 640 512', path: 'M448 64c106 0 192 86 192 192S554 448 448 448l-256 0C86 448 0 362 0 256S86 64 192 64l256 0zM192 176c-13.3 0-24 10.7-24 24l0 32-32 0c-13.3 0-24 10.7-24 24s10.7 24 24 24l32 0 0 32c0 13.3 10.7 24 24 24s24-10.7 24-24l0-32 32 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-32 0 0-32c0-13.3-10.7-24-24-24zm240 96a32 32 0 1 0 0 64 32 32 0 1 0 0-64zm64-96a32 32 0 1 0 0 64 32 32 0 1 0 0-64z' }, game: { viewbox: '0 0 640 512', path: 'M448 64c106 0 192 86 192 192S554 448 448 448l-256 0C86 448 0 362 0 256S86 64 192 64l256 0zM192 176c-13.3 0-24 10.7-24 24l0 32-32 0c-13.3 0-24 10.7-24 24s10.7 24 24 24l32 0 0 32c0 13.3 10.7 24 24 24s24-10.7 24-24l0-32 32 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-32 0 0-32c0-13.3-10.7-24-24-24zm240 96a32 32 0 1 0 0 64 32 32 0 1 0 0-64zm64-96a32 32 0 1 0 0 64 32 32 0 1 0 0-64z' },
tv: TV, tv: TV,
tvaudio: TV, tvaudio: TV,
@@ -53,29 +52,31 @@ const ui = {
foot: el('[data-role="foot"]'), foot: el('[data-role="foot"]'),
toast: el('[data-role="toast"]'), toast: el('[data-role="toast"]'),
refresh: el('[data-role="refresh"]'), refresh: el('[data-role="refresh"]'),
splitAll: el('[data-role="split-all"]'),
avrStatus: el('[data-role="avr-status"]'), avrStatus: el('[data-role="avr-status"]'),
inputButton: el('[data-role="input-button"]'), inputButton: el('[data-role="input-button"]'),
inputName: el('[data-role="input-name"]'), inputName: el('[data-role="input-name"]'),
inputIcon: el('[data-role="input-icon"]'), inputIcon: el('[data-role="input-icon"]'),
sheet: el('[data-role="sheet"]'), sheet: el('[data-role="sheet"]'),
options: el('[data-role="options"]'), options: el('[data-role="options"]'),
source: el('.card.source'),
joined: el('[data-role="joined"]'), joined: el('[data-role="joined"]'),
joinedRooms: el('[data-role="joined-rooms"]'),
avrNowPlaying: el('[data-role="avr-now-playing"]'), avrNowPlaying: el('[data-role="avr-now-playing"]'),
avrCover: el('[data-role="avr-cover"]'), avrCover: el('[data-role="avr-cover"]'),
avrSong: el('[data-role="avr-song"]'), avrSong: el('[data-role="avr-song"]'),
avrArtist: el('[data-role="avr-artist"]'), avrArtist: el('[data-role="avr-artist"]'),
avrProgress: el('[data-role="avr-progress"]'),
}; };
// Shares its shape with a room's {nowPlaying, cover, song, artist} refs, so // Shares its shape with a room's {nowPlaying, cover, song, artist,
// renderTrack() below works for both -- a device plugged into the AVR (a // progress} refs, so renderTrack() below works for both -- a device plugged
// Zidoo, say) is "now playing" the same way a room's own stream is. // into the AVR (a Zidoo, say) is "now playing" the same way a room's own
// stream is.
const avrTrack = { const avrTrack = {
nowPlaying: ui.avrNowPlaying, nowPlaying: ui.avrNowPlaying,
cover: ui.avrCover, cover: ui.avrCover,
song: ui.avrSong, song: ui.avrSong,
artist: ui.avrArtist, artist: ui.avrArtist,
progress: ui.avrProgress,
}; };
// Same reasoning as a room's own cover: drop one that will not load rather // Same reasoning as a room's own cover: drop one that will not load rather
// than leave a broken-image box. // than leave a broken-image box.
@@ -84,6 +85,7 @@ avrTrack.cover.addEventListener('error', () => { avrTrack.cover.hidden = true; }
const rooms = {}; const rooms = {};
let inputs = []; let inputs = [];
let currentInput = null; let currentInput = null;
let avrNowPlaying = null;
/* --- transport -------------------------------------------------------- */ /* --- transport -------------------------------------------------------- */
async function api(path, body) { async function api(path, body) {
@@ -122,7 +124,7 @@ els('.room').forEach((node) => {
prev: el('[data-role="prev"]', 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), // the resume buttons and the host toggle
nowPlaying: el('[data-role="now-playing"]', node), nowPlaying: el('[data-role="now-playing"]', node),
cover: el('[data-role="cover"]', node), cover: el('[data-role="cover"]', node),
song: el('[data-role="song"]', node), song: el('[data-role="song"]', node),
@@ -169,7 +171,7 @@ function paintRoom(room) {
room.meter.style.setProperty('--level', known ? room.volume : 0); 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); renderTrack(room.available ? room.track : null, room);
const playing = room.playState === 'play'; const playing = room.playState === 'play';
room.node.classList.toggle('playing', playing); room.node.classList.toggle('playing', playing);
room.play.classList.toggle('playing', playing); room.play.classList.toggle('playing', playing);
@@ -187,27 +189,31 @@ function paintRoom(room) {
room.actions.hidden = !(room.onSpotify && room.spotifyAccount && !room.grouped); room.actions.hidden = !(room.onSpotify && room.spotifyAccount && !room.grouped);
room.prev.disabled = !room.available; 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 // Grouped, the room plays whatever the AVR does, so resuming Spotify on it
// buttons, so hiding them here would leave none at all. // is not on offer. They hide one by one rather than the row hiding with
if (room.spotify) { // them, because the row also holds the host toggle, which stays put and
room.spotify.hidden = !room.available; // merely disables.
els('.spotify', room.spotify).forEach((button) => { els('.spotify', room.spotify).forEach((button) => {
button.setAttribute('aria-pressed', String(button.dataset.account === room.spotifyAccount)); button.hidden = !room.available || room.grouped;
}); button.setAttribute('aria-pressed', String(button.dataset.account === room.spotifyAccount));
} });
room.toggle.disabled = !room.available; room.toggle.disabled = !room.available;
room.toggle.classList.toggle('busy', room.busy); room.toggle.classList.toggle('busy', room.busy);
room.toggle.setAttribute('aria-pressed', String(room.grouped)); room.toggle.setAttribute('aria-pressed', String(room.grouped));
// Where the card sits already says whether it is grouped, so the button // Out of the group the button names where tapping it sends the room; in it,
// says what tapping it does instead. // where the card sits already says so, so the button says what it does.
room.toggleLabel.textContent = room.grouped ? 'Leave' : `Join ${HOST_LABEL}`; room.toggleLabel.textContent = room.grouped ? 'Ungroup' : 'Cinema';
// Grouped, its slot is empty and the AVR's card is the one that moves up.
room.slot.classList.toggle('on-top', playing && !room.grouped);
placeRoom(room); placeRoom(room);
} }
/* Song, artist and cover, each only when there is one. The cover's src is /* Song, artist, cover and progress, each only when there is one. The
only touched when the track changes, so a poll never makes it flicker. */ cover's src is only touched when the track changes, so a poll never makes
it flicker. */
function renderTrack(track, refs) { function renderTrack(track, refs) {
renderProgress(track, refs.progress);
refs.nowPlaying.hidden = !track; refs.nowPlaying.hidden = !track;
if (!track) return; if (!track) return;
refs.song.textContent = track.song; refs.song.textContent = track.song;
@@ -222,44 +228,46 @@ function renderTrack(track, refs) {
} }
} }
function paintTrack(room) {
renderTrack(room.available ? room.track : null, room);
paintProgress(room);
}
/* The discreet cursor on the line below the song, sized off the same /* The discreet cursor on the line below the song, sized off the same
{position_ms, duration_ms} the poll hands back -- absent for anything {position_ms, duration_ms} the poll hands back -- absent for anything
HEOS never sends a progress event for (an AVR input, an internet radio HEOS never sends a progress event for (an AVR input, an internet radio
stream with no fixed length), in which case the line stays plain. */ stream with no fixed length), in which case the line stays plain. */
function paintProgress(room) { function renderProgress(track, progress) {
const track = room.available ? room.track : null; progress.hidden = !track;
room.progress.hidden = !track;
if (!track) return; if (!track) return;
const { position_ms: position, duration_ms: duration } = track; const { position_ms: position, duration_ms: duration } = track;
const known = typeof duration === 'number' && duration > 0 && typeof position === 'number'; const known = typeof duration === 'number' && duration > 0 && typeof position === 'number';
const percent = known ? Math.min(100, Math.max(0, (position / duration) * 100)) : 0; const percent = known ? Math.min(100, Math.max(0, (position / duration) * 100)) : 0;
room.progress.classList.toggle('known', known); progress.classList.toggle('known', known);
room.progress.style.setProperty('--progress', percent); progress.style.setProperty('--progress', percent);
} }
/* 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. */
function placeRoom(room) { function placeRoom(room) {
const target = room.grouped ? ui.joinedRooms : room.slot; const target = room.grouped ? ui.joined : room.slot;
if (room.node.parentElement !== target) target.appendChild(room.node); if (room.node.parentElement !== target) target.appendChild(room.node);
// Left empty, a slot would still take a gap of its own and double the space.
room.slot.hidden = room.grouped;
} }
function paintGrouping() { function paintGrouping() {
const order = Object.keys(rooms); const order = Object.keys(rooms);
const inside = Array.from(ui.joinedRooms.children); const inside = Array.from(ui.joined.children);
// Keep them in the order the cards are declared, not the order they joined. // Keep them in the order the cards are declared, not the order they joined.
inside inside
.slice() .slice()
.sort((a, b) => order.indexOf(a.dataset.room) - order.indexOf(b.dataset.room)) .sort((a, b) => order.indexOf(a.dataset.room) - order.indexOf(b.dataset.room))
.forEach((node) => ui.joinedRooms.appendChild(node)); .forEach((node) => ui.joined.appendChild(node));
ui.joined.hidden = inside.length === 0; ui.joined.hidden = inside.length === 0;
ui.splitAll.hidden = inside.length === 0; // Grouped rooms share the host's transport, so one of them playing means
// the whole group is -- and the tint goes on the group's card.
const groupPlaying = inside.some((node) => node.classList.contains('playing'));
ui.source.classList.toggle('playing', groupPlaying);
// A film on the Zidoo also moves the AVR up. The Zidoo does not say whether
// it is paused, so a loaded film counts, and it gets no tint.
ui.source.classList.toggle('on-top', groupPlaying || Boolean(avrNowPlaying));
} }
/* Press and hold to keep moving, accelerating as you hold. */ /* Press and hold to keep moving, accelerating as you hold. */
@@ -438,18 +446,6 @@ function applyJoined(joined) {
paintGrouping(); paintGrouping();
} }
ui.splitAll.addEventListener('click', async () => {
try {
applyJoined([]);
const data = await api('/api/group/none', {}); // {} so it is a POST, as the route requires
applyJoined(data.joined || []);
} catch (error) {
toast(error.message);
} finally {
refresh();
}
});
/* --- Spotify: one button per account, asking the card's own Connect /* --- Spotify: one button per account, asking the card's own Connect
receiver to resume that account, instead of always connecting to it receiver to resume that account, instead of always connecting to it
from the Spotify app -------------------------------------------------- */ from the Spotify app -------------------------------------------------- */
@@ -543,7 +539,6 @@ function render(state) {
room.spotifyAccount = incoming.spotify_account || null; room.spotifyAccount = incoming.spotify_account || null;
paintRoom(room); paintRoom(room);
}); });
paintGrouping();
const avr = state.avr || {}; const avr = state.avr || {};
inputs = avr.inputs || []; inputs = avr.inputs || [];
@@ -552,7 +547,9 @@ function render(state) {
setInputIcon(currentInput ? currentInput.code : null); setInputIcon(currentInput ? currentInput.code : null);
ui.avrStatus.textContent = avr.connected ? 'ready' : 'offline'; ui.avrStatus.textContent = avr.connected ? 'ready' : 'offline';
ui.avrStatus.classList.toggle('on', Boolean(avr.connected)); ui.avrStatus.classList.toggle('on', Boolean(avr.connected));
renderTrack(avr.now_playing || null, avrTrack); avrNowPlaying = avr.now_playing || null;
renderTrack(avrNowPlaying, avrTrack);
paintGrouping(); // after avrNowPlaying, which decides where the AVR's card sits
const problems = state.errors || []; const problems = state.errors || [];
ui.foot.textContent = problems.length ? problems[0] : (state.demo ? 'demo mode — no real speakers' : ''); ui.foot.textContent = problems.length ? problems[0] : (state.demo ? 'demo mode — no real speakers' : '');
+42 -36
View File
@@ -13,6 +13,7 @@
--live: #3ddc97; --live: #3ddc97;
--warn: #ff7a6b; --warn: #ff7a6b;
--radius: 22px; --radius: 22px;
--gutter: 16px; /* from the screen's side to a card, and from one card to the next */
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
@@ -51,15 +52,20 @@ svg { width: 22px; height: 22px; fill: currentColor; }
.app { .app {
max-width: 520px; max-width: 520px;
margin: 0 auto; margin: 0 auto;
padding: max(12px, env(safe-area-inset-top)) max(16px, env(safe-area-inset-right)) padding: max(12px, env(safe-area-inset-top)) max(var(--gutter), env(safe-area-inset-right))
max(24px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left)); max(24px, env(safe-area-inset-bottom)) max(var(--gutter), env(safe-area-inset-left));
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 14px; gap: var(--gutter);
} }
/* A playing card goes to the top. The rest stay in markup order (rooms as
room_keys lists them, then the AVR), because items with the same order
keep their place, so playing cards keep that order among themselves too.
Only the order changes, not the DOM, so a knob held mid-drag stays put. */
.app > .on-top { order: -1; }
/* --- header ---------------------------------------------------------- */ /* --- header ---------------------------------------------------------- */
.top { display: flex; align-items: center; justify-content: space-between; padding: 6px 4px 0; } .top { order: -2; display: flex; align-items: center; justify-content: space-between; padding: 6px 4px 0; }
.top h1 { margin: 0; line-height: 0; } .top h1 { margin: 0; line-height: 0; }
.top .logo { height: 34px; width: auto; display: block; } .top .logo { height: 34px; width: auto; display: block; }
@@ -84,8 +90,10 @@ svg { width: 22px; height: 22px; fill: currentColor; }
} }
.card.offline { opacity: .5; } .card.offline { opacity: .5; }
/* A tint, not a repaint -- it should read at a glance without competing /* A tint, not a repaint -- it should read at a glance without competing
with the song title right above it. */ with the song title right above it. A group plays one thing, so it is the
.card.room.playing { host's card that lights up, not the rooms merged into it. */
.card.room.playing,
.card.source.playing {
background: linear-gradient(135deg, background: linear-gradient(135deg,
color-mix(in srgb, var(--live) 25%, var(--card)) 0%, color-mix(in srgb, var(--live) 25%, var(--card)) 0%,
var(--card) 70%); var(--card) 70%);
@@ -118,6 +126,9 @@ svg { width: 22px; height: 22px; fill: currentColor; }
object-fit: cover; object-fit: cover;
background: var(--raised); background: var(--raised);
} }
/* A film's jacket keeps its own portrait shape rather than being cropped
square like an album's. */
.cover.poster { width: 50px; height: 75px; border-radius: 9px; }
.track { display: flex; flex-direction: column; min-width: 0; } .track { display: flex; flex-direction: column; min-width: 0; }
.track span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .track span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.song { font-size: 20px; font-weight: 550; } .song { font-size: 20px; font-weight: 550; }
@@ -212,23 +223,27 @@ 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, beside the room's name ---------------------- */ /* --- join / leave the AVR, between the Spotify buttons ----------------- */
/* Styled like the .spotify buttons it sits between -- same height, corner,
type and full-strength ink -- but sized to its own label instead of
sharing the row equally. Like them, it says "not the active one" with a
plain background rather than by dimming itself, so the three read as
equal choices. */
.toggle { .toggle {
flex: 0 0 auto; flex: 0 0 auto;
padding: 7px 14px; border-radius: 999px; height: 50px; padding: 0 14px; border-radius: 16px;
background: var(--raised); background: var(--raised);
display: flex; align-items: center; justify-content: center; gap: 8px; display: flex; align-items: center; justify-content: center; gap: 9px;
font-size: 14px; font-weight: 550; white-space: nowrap; font-size: 15px; font-weight: 550; white-space: nowrap;
color: var(--muted);
} }
.toggle .dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; opacity: .6; } .toggle .cinema { flex: 0 0 auto; height: 18px; width: auto; color: #fff; }
.toggle .leave { display: none; flex: 0 0 auto; color: #fff; }
.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:active { transform: scale(.985); } .toggle:active { transform: scale(.985); }
.toggle:disabled { opacity: .5; } .toggle:disabled { opacity: .5; }
.toggle.busy { opacity: .6; } .toggle.busy { opacity: .6; }
/* --- Spotify: one resume button per account ----------------------------- */ /* --- Spotify: one resume button per account, around the host toggle ----- */
.spotify-row { display: flex; gap: 10px; } .spotify-row { display: flex; gap: 10px; }
.spotify { .spotify {
flex: 1; min-width: 0; flex: 1; min-width: 0;
@@ -260,33 +275,24 @@ svg { width: 22px; height: 22px; fill: currentColor; }
.source-button .chevron { color: var(--muted); } .source-button .chevron { color: var(--muted); }
/* --- rooms merged into the host's card --------------------------------- */ /* --- rooms merged into the host's card --------------------------------- */
.joined { display: flex; flex-direction: column; gap: 12px; } .joined { display: flex; flex-direction: column; gap: 14px; }
.joined-title { margin: 0 2px; font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); }
.joined-rooms { display: flex; flex-direction: column; gap: 14px; }
/* The card stops being a card in here: one border around the group, not /* In here a room is an outline rather than a card: its border is what says
one around every room in it. */ it is playing with the AVR, and the fill -- a tint too, when it plays --
.joined .card.room { background: none; border: 0; border-radius: 0; padding: 0; gap: 12px; } belongs to the group's card instead. Same specificity as
.joined .card.room + .card.room { border-top: 1px solid var(--edge); padding-top: 14px; } .card.room.playing, so being later is what wins. */
/* Flush against the group's own card, so no radius here -- .playing has .joined .card.room { background: none; border-color: var(--ink); border-radius: 18px; padding: 14px; gap: 12px; }
to out-specificity the plain "background: none" above to show at all. */
.joined .card.room.playing {
background: linear-gradient(135deg,
color-mix(in srgb, var(--live) 25%, transparent) 0%,
transparent 70%);
}
.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 .cover { width: 52px; height: 52px; border-radius: 10px; } .joined .cover { width: 52px; height: 52px; border-radius: 10px; }
.joined .song { font-size: 18px; } .joined .song { font-size: 18px; }
.joined .step { height: 54px; } .joined .step { height: 54px; }
.joined .toggle, /* In here the Spotify buttons hide and the toggle says "Detach", so it takes
.joined .toggle[aria-pressed="true"] { their place and their look: the whole row, a plain background rather than
height: 36px; font-size: 13px; font-weight: 500; the pressed accent, and an exit icon where the host's would only muddle. */
background: none; border: 1px solid var(--edge); color: var(--muted); .joined .toggle { flex: 1; min-width: 0; }
} .joined .toggle[aria-pressed="true"] { background: var(--raised); color: inherit; }
.joined .toggle .dot { display: none; } .joined .toggle .cinema { display: none; }
.joined .spotify { height: 40px; font-size: 13px; font-weight: 500; } .joined .toggle .leave { display: block; }
.joined .toggle:active { background: var(--raised); color: var(--ink); }
/* --- misc ------------------------------------------------------------- */ /* --- misc ------------------------------------------------------------- */
.wide { width: 100%; height: 50px; border-radius: 16px; font-size: 15px; } .wide { width: 100%; height: 50px; border-radius: 16px; font-size: 15px; }
+31 -23
View File
@@ -19,18 +19,29 @@
</head> </head>
<body> <body>
{% macro spotify_row(target, accounts) -%} {% macro host_toggle() -%}
{% if accounts %} <button class="toggle" data-role="group" aria-pressed="false">
<div class="spotify-row" hidden> <svg class="cinema" viewBox="0 -16 576 512" aria-hidden="true"><path d="M0 112c0 70.7 57.3 128 128 128l224 0c70.7 0 128-57.3 128-128S422.7-16 352-16c-48.2 0-90.2 26.6-112 66-21.8-39.4-63.8-66-112-66-70.7 0-128 57.3-128 128zm304 0a48 48 0 1 1 96 0 48 48 0 1 1 -96 0zM128 64a48 48 0 1 1 0 96 48 48 0 1 1 0-96zM64 352l0 64c0 35.3 28.7 64 64 64l224 0c35.3 0 64-28.7 64-64l0-64c0-35.3-28.7-64-64-64l-224 0c-35.3 0-64 28.7-64 64zM537.5 490.8c4.2 3.4 9.4 5.2 14.8 5.2 13.1 0 23.7-10.6 23.7-23.7l0-240.6c0-13.1-10.6-23.7-23.7-23.7-5.4 0-10.6 1.8-14.8 5.2l-73.5 58.8 0 160 73.5 58.8z"/></svg>
<svg class="leave" viewBox="0 0 512 512" aria-hidden="true"><path d="M502.6 278.6c12.5-12.5 12.5-32.8 0-45.3l-128-128c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L402.7 224 192 224c-17.7 0-32 14.3-32 32s14.3 32 32 32l210.7 0-73.4 73.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l128-128zM160 96c17.7 0 32-14.3 32-32s-14.3-32-32-32L96 32C43 32 0 75 0 128L0 384c0 53 43 96 96 96l64 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-64 0c-17.7 0-32-14.3-32-32l0-256c0-17.7 14.3-32 32-32l64 0z"/></svg>
<span data-role="group-label">Cinema</span>
</button>
{%- endmacro %}
{# The Spotify resume buttons with the host toggle in the middle of them. #}
{% macro control_row(target, accounts) -%}
{% set split = (accounts | length + 1) // 2 %}
<div class="spotify-row">
{% for account in accounts %} {% for account in accounts %}
{% if loop.index0 == split %}{{ host_toggle() }}{% endif %}
<button class="spotify" data-target="{{ target }}" data-account="{{ account.key }}" <button class="spotify" data-target="{{ target }}" data-account="{{ account.key }}"
aria-label="Resume {{ account.label }}'s Spotify"> aria-label="Resume {{ account.label }}'s Spotify" hidden>
<svg viewBox="0 0 496 512" aria-hidden="true"><path d="M248 8C111.1 8 0 119.1 0 256s111.1 248 248 248 248-111.1 248-248S384.9 8 248 8zm100.7 364.9c-4.2 0-6.8-1.3-10.7-3.6-62.4-37.6-135-39.2-206.7-24.5-3.9 1-9 2.6-11.9 2.6-9.7 0-15.8-7.7-15.8-15.8 0-10.3 6.1-15.2 13.6-16.8 81.9-18.1 165.6-16.5 237 26.2 6.1 3.9 9.7 7.4 9.7 16.5s-7.1 15.4-15.2 15.4zm26.9-65.6c-5.2 0-8.7-2.3-12.3-4.2-62.5-37-155.7-51.9-238.6-29.4-4.8 1.3-7.4 2.6-11.9 2.6-10.7 0-19.4-8.7-19.4-19.4s5.2-17.8 15.5-20.7c27.8-7.8 56.2-13.6 97.8-13.6 64.9 0 127.6 16.1 177 45.5 8.1 4.8 11.3 11 11.3 19.7-.1 10.8-8.5 19.5-19.4 19.5zm31-76.2c-5.2 0-8.4-1.3-12.9-3.9-71.2-42.5-198.5-52.7-280.9-29.7-3.6 1-8.1 2.6-12.9 2.6-13.2 0-23.3-10.3-23.3-23.6 0-13.6 8.4-21.3 17.4-23.9 35.2-10.3 74.6-15.2 117.5-15.2 73 0 149.5 15.2 205.4 47.8 7.8 4.5 12.9 10.7 12.9 22.6 0 13.6-11 23.3-23.2 23.3z"/></svg> <svg viewBox="0 0 496 512" aria-hidden="true"><path d="M248 8C111.1 8 0 119.1 0 256s111.1 248 248 248 248-111.1 248-248S384.9 8 248 8zm100.7 364.9c-4.2 0-6.8-1.3-10.7-3.6-62.4-37.6-135-39.2-206.7-24.5-3.9 1-9 2.6-11.9 2.6-9.7 0-15.8-7.7-15.8-15.8 0-10.3 6.1-15.2 13.6-16.8 81.9-18.1 165.6-16.5 237 26.2 6.1 3.9 9.7 7.4 9.7 16.5s-7.1 15.4-15.2 15.4zm26.9-65.6c-5.2 0-8.7-2.3-12.3-4.2-62.5-37-155.7-51.9-238.6-29.4-4.8 1.3-7.4 2.6-11.9 2.6-10.7 0-19.4-8.7-19.4-19.4s5.2-17.8 15.5-20.7c27.8-7.8 56.2-13.6 97.8-13.6 64.9 0 127.6 16.1 177 45.5 8.1 4.8 11.3 11 11.3 19.7-.1 10.8-8.5 19.5-19.4 19.5zm31-76.2c-5.2 0-8.4-1.3-12.9-3.9-71.2-42.5-198.5-52.7-280.9-29.7-3.6 1-8.1 2.6-12.9 2.6-13.2 0-23.3-10.3-23.3-23.6 0-13.6 8.4-21.3 17.4-23.9 35.2-10.3 74.6-15.2 117.5-15.2 73 0 149.5 15.2 205.4 47.8 7.8 4.5 12.9 10.7 12.9 22.6 0 13.6-11 23.3-23.2 23.3z"/></svg>
<span>{{ account.label }}</span> <span>{{ account.label }}</span>
</button> </button>
{% endfor %} {% endfor %}
{# Fewer accounts than the midpoint means the loop never reached it. #}
{% if accounts | length <= split %}{{ host_toggle() }}{% endif %}
</div> </div>
{% endif %}
{%- endmacro %} {%- endmacro %}
<div class="app"> <div class="app">
@@ -46,10 +57,6 @@
<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><svg class="room-icon" data-icon="{{ room.kind }}" viewBox="0 0 512 512" aria-hidden="true"><path d=""/></svg><span class="room-label">{{ room.label }}</span></h2> <h2><svg class="room-icon" data-icon="{{ room.kind }}" viewBox="0 0 512 512" aria-hidden="true"><path d=""/></svg><span class="room-label">{{ room.label }}</span></h2>
<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>
<div class="now-playing" data-role="now-playing" hidden> <div class="now-playing" data-role="now-playing" hidden>
@@ -91,7 +98,7 @@
</button> </button>
</div> </div>
{{ spotify_row(room.key, spotify_accounts) }} {{ control_row(room.key, spotify_accounts) }}
</section> </section>
</div> </div>
{% endfor %} {% endfor %}
@@ -101,6 +108,19 @@
<h2><svg class="room-icon" data-icon="{{ host.kind }}" viewBox="0 0 512 512" aria-hidden="true"><path d=""/></svg><span class="room-label">{{ host.label }}</span></h2> <h2><svg class="room-icon" data-icon="{{ host.kind }}" viewBox="0 0 512 512" aria-hidden="true"><path d=""/></svg><span class="room-label">{{ host.label }}</span></h2>
<span class="pill" data-role="avr-status">offline</span> <span class="pill" data-role="avr-status">offline</span>
</div> </div>
<div class="now-playing" data-role="avr-now-playing" hidden>
<img class="cover poster" data-role="avr-cover" alt="" hidden>
<div class="track">
<span class="song" data-role="avr-song"></span>
<span class="artist" data-role="avr-artist"></span>
</div>
</div>
<div class="progress" data-role="avr-progress" hidden>
<i data-role="progress-fill"></i>
<b class="progress-cursor" data-role="progress-cursor"></b>
</div>
<button class="source-button" data-role="input-button"> <button class="source-button" data-role="input-button">
<span class="eyebrow">Input</span> <span class="eyebrow">Input</span>
<svg class="input-icon" data-role="input-icon" viewBox="0 0 512 512" aria-hidden="true"><path d=""/></svg> <svg class="input-icon" data-role="input-icon" viewBox="0 0 512 512" aria-hidden="true"><path d=""/></svg>
@@ -108,21 +128,9 @@
<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> <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>
</button> </button>
<div class="now-playing" data-role="avr-now-playing" hidden> <div class="joined" data-role="joined" hidden></div>
<img class="cover" data-role="avr-cover" alt="" hidden>
<div class="track">
<span class="song" data-role="avr-song"></span>
<span class="artist" data-role="avr-artist"></span>
</div>
</div>
<div class="joined" data-role="joined" hidden>
<p class="joined-title">Playing together</p>
<div class="joined-rooms" data-role="joined-rooms"></div>
</div>
</section> </section>
<button class="wide ghost" data-role="split-all" hidden>Separate everything</button>
<p class="foot" data-role="foot"></p> <p class="foot" data-role="foot"></p>
</div> </div>
+102
View File
@@ -0,0 +1,102 @@
"""The Zidoo client against a pretend Zidoo, answering the way a real one
does (see zidoo.py).
python3 -m unittest discover -s tests -t .
"""
import json
import sys
import threading
import unittest
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlparse
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from zidoo import ZidooClient # noqa: E402
PATH = "/storage/disk/Movies/A.Private.War.2018.1080p.mp4"
PNG = b"\x89PNG\r\n\x1a\n" + b"\0" * 16
class FakeZidoo(BaseHTTPRequestHandler):
video = None # getPlayStatus's "video", or None for nothing loaded
library = {} # file path -> getAggregationOfFile's answer
posters = {} # poster id -> image bytes
lookups = [] # paths getAggregationOfFile was asked about
def do_GET(self):
url = urlparse(self.path)
query = {key: values[0] for key, values in parse_qs(url.query).items()}
if url.path == "/ZidooVideoPlay/getPlayStatus":
if self.video is None:
return self._send(404, b"")
return self._json({"status": 200, "video": self.video})
if url.path == "/ZidooPoster/v2/getAggregationOfFile":
self.lookups.append(query["path"])
return self._json(self.library.get(query["path"], {"status": 804, "msg": "Error!!!"}))
if url.path == "/ZidooPoster/getFile/getPoster":
image = self.posters.get(int(query["id"]))
return self._send(200, image) if image else self._json({"status": 804, "msg": "Error!!!"})
self._send(404, b"")
def _json(self, payload):
self._send(200, json.dumps(payload).encode())
def _send(self, code, body):
self.send_response(code) # no Content-Type, like the real thing
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass
class ZidooTest(unittest.TestCase):
def setUp(self):
FakeZidoo.video = {"status": 1, "title": "A Private War", "path": PATH,
"currentPosition": 1589745, "duration": 6635092}
FakeZidoo.library = {PATH: {"path": PATH, "type": "movie", "video": {"id": 2248, "parentId": 108},
"movie": {"id": 108, "name": "A Private War", "year": 2018}}}
FakeZidoo.posters = {108: PNG}
FakeZidoo.lookups = []
self.server = ThreadingHTTPServer(("127.0.0.1", 0), FakeZidoo)
threading.Thread(target=self.server.serve_forever, args=(0.05,), daemon=True).start()
self.zidoo = ZidooClient("127.0.0.1", self.server.server_address[1])
def tearDown(self):
self.server.shutdown()
self.server.server_close()
def test_a_film_carries_its_year_progress_and_poster(self):
self.assertEqual(self.zidoo.now_playing(), {
"song": "A Private War", "artist": "2018", "image": None, "poster_id": 108,
"position_ms": 1589745, "duration_ms": 6635092,
})
def test_the_poster_is_looked_up_once_per_file(self):
self.zidoo.now_playing()
self.zidoo.now_playing()
self.assertEqual(FakeZidoo.lookups, [PATH])
def test_a_file_outside_the_library_has_no_poster_or_year(self):
FakeZidoo.library = {}
track = self.zidoo.now_playing()
self.assertIsNone(track["poster_id"])
self.assertIsNone(track["artist"])
def test_nothing_loaded_is_nothing_playing(self):
FakeZidoo.video = None
self.assertIsNone(self.zidoo.now_playing())
def test_poster_is_sniffed_from_its_bytes(self):
self.assertEqual(self.zidoo.poster(108), (PNG, "image/png"))
def test_an_id_without_a_poster_is_none_not_its_json_error(self):
self.assertIsNone(self.zidoo.poster(2248))
if __name__ == "__main__":
unittest.main()
+66 -12
View File
@@ -12,7 +12,7 @@ an AVR input, not something the rest of the panel depends on.
""" """
import json import json
import urllib.error import urllib.parse
import urllib.request import urllib.request
@@ -20,20 +20,74 @@ class ZidooClient:
def __init__(self, host, port=9529, timeout=1.5): def __init__(self, host, port=9529, timeout=1.5):
self.base_url = f"http://{host}:{port}" self.base_url = f"http://{host}:{port}"
self.timeout = timeout self.timeout = timeout
self._film_for = (None, {}) # (file path, _film()'s answer) last looked up
def _get(self, route, **params):
"""The raw body of a GET, or None if the Zidoo did not answer."""
query = f"?{urllib.parse.urlencode(params)}" if params else ""
try:
with urllib.request.urlopen(f"{self.base_url}/{route}{query}", timeout=self.timeout) as response:
return response.read()
except OSError: # URLError, and a timeout mid-read, which urlopen does not wrap
return None
def _get_json(self, route, **params):
body = self._get(route, **params)
try:
return json.loads(body) if body is not None else None
except ValueError:
return None
def now_playing(self): def now_playing(self):
"""{"song", "artist", "image"} for whatever video is loaded, in the """{"song", "artist", "image"} for whatever video is loaded, in the
same shape a room's now-playing card already expects -- or None.""" same shape a room's now-playing card already expects -- or None.
try: A film has no artist, so that line under the title carries its year
with urllib.request.urlopen( instead. "image" is always None: the poster lives on the Zidoo, which
f"{self.base_url}/ZidooVideoPlay/getPlayStatus", timeout=self.timeout the phone cannot load over plain http, so "poster_id" names it
) as response: instead for app.py to serve. "position_ms" and "duration_ms" come
payload = json.loads(response.read()) along whenever the length is known, as a room's do."""
except (urllib.error.URLError, ValueError): payload = self._get_json("ZidooVideoPlay/getPlayStatus")
if not payload or payload.get("status") != 200:
return None return None
if payload.get("status") != 200: video = payload.get("video") or {}
return None title = video.get("title")
title = (payload.get("video") or {}).get("title")
if not title: if not title:
return None return None
return {"song": title, "artist": None, "image": None} film = self._film(video.get("path"))
year = film.get("year")
track = {"song": title, "artist": str(year) if year else None, "image": None,
"poster_id": film.get("id")}
if video.get("duration"):
track["position_ms"] = video.get("currentPosition") or 0
track["duration_ms"] = video["duration"]
return track
def _film(self, path):
"""The poster wall's entry for the film a file belongs to -- its
"id" (which is also its poster's) and "year" among the rest -- or {}
when the file is not in the library. Looked up once per file rather
than on every poll: the answer comes wrapped in the film's whole cast
and crew, and does not change halfway through it."""
if not path:
return {}
if self._film_for[0] != path:
payload = self._get_json("ZidooPoster/v2/getAggregationOfFile", path=path)
if payload is None:
return {} # unreachable for now -- ask again next poll
# "type" names the key holding the item itself: "movie" for a
# film. Anything else without one simply gets no poster or year.
self._film_for = (path, payload.get(payload.get("type")) or {})
return self._film_for[1]
def poster(self, poster_id, width=200, height=300):
"""(bytes, mimetype) for a film's poster, or None. The Zidoo sends
no Content-Type, and answers an id it has no poster for with a JSON
error instead, so the image is told apart by its first bytes."""
body = self._get("ZidooPoster/getFile/getPoster", id=poster_id, w=width, h=height)
if body is None:
return None
if body.startswith(b"\x89PNG"):
return body, "image/png"
if body.startswith(b"\xff\xd8"):
return body, "image/jpeg"
return None