This commit is contained in:
@@ -17,7 +17,7 @@ import argparse
|
||||
import os
|
||||
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
|
||||
|
||||
import config
|
||||
@@ -144,6 +144,18 @@ def _mark_spotify_accounts(rooms: list):
|
||||
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 -----------------------------------------------------------
|
||||
@app.route("/")
|
||||
def index():
|
||||
@@ -177,9 +189,22 @@ def api_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"])
|
||||
_link_zidoo_poster(data["avr"])
|
||||
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")
|
||||
@handle_errors
|
||||
def api_targets():
|
||||
|
||||
@@ -73,7 +73,8 @@ class DemoController:
|
||||
"connected": True, "inputs": self.avr.inputs(), "input": self.avr.current_input(),
|
||||
# Standing in for a Zidoo plugged into this input, so the
|
||||
# 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,
|
||||
},
|
||||
"heos_ok": True,
|
||||
|
||||
+47
-50
@@ -3,7 +3,6 @@
|
||||
network round trip before it looks like it did anything feels broken. */
|
||||
|
||||
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.
|
||||
const BASE = document.documentElement.dataset.base || '/';
|
||||
const POLL_MS = 5000;
|
||||
@@ -26,7 +25,7 @@ const ICONS = {
|
||||
'lcr-fronts': SPEAKERS,
|
||||
'surround-sound-system': SPEAKERS,
|
||||
'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' },
|
||||
tv: TV,
|
||||
tvaudio: TV,
|
||||
@@ -53,29 +52,31 @@ const ui = {
|
||||
foot: el('[data-role="foot"]'),
|
||||
toast: el('[data-role="toast"]'),
|
||||
refresh: el('[data-role="refresh"]'),
|
||||
splitAll: el('[data-role="split-all"]'),
|
||||
avrStatus: el('[data-role="avr-status"]'),
|
||||
inputButton: el('[data-role="input-button"]'),
|
||||
inputName: el('[data-role="input-name"]'),
|
||||
inputIcon: el('[data-role="input-icon"]'),
|
||||
sheet: el('[data-role="sheet"]'),
|
||||
options: el('[data-role="options"]'),
|
||||
source: el('.card.source'),
|
||||
joined: el('[data-role="joined"]'),
|
||||
joinedRooms: el('[data-role="joined-rooms"]'),
|
||||
avrNowPlaying: el('[data-role="avr-now-playing"]'),
|
||||
avrCover: el('[data-role="avr-cover"]'),
|
||||
avrSong: el('[data-role="avr-song"]'),
|
||||
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
|
||||
// renderTrack() below works for both -- a device plugged into the AVR (a
|
||||
// Zidoo, say) is "now playing" the same way a room's own stream is.
|
||||
// Shares its shape with a room's {nowPlaying, cover, song, artist,
|
||||
// progress} refs, so renderTrack() below works for both -- a device plugged
|
||||
// into the AVR (a Zidoo, say) is "now playing" the same way a room's own
|
||||
// stream is.
|
||||
const avrTrack = {
|
||||
nowPlaying: ui.avrNowPlaying,
|
||||
cover: ui.avrCover,
|
||||
song: ui.avrSong,
|
||||
artist: ui.avrArtist,
|
||||
progress: ui.avrProgress,
|
||||
};
|
||||
// Same reasoning as a room's own cover: drop one that will not load rather
|
||||
// than leave a broken-image box.
|
||||
@@ -84,6 +85,7 @@ avrTrack.cover.addEventListener('error', () => { avrTrack.cover.hidden = true; }
|
||||
const rooms = {};
|
||||
let inputs = [];
|
||||
let currentInput = null;
|
||||
let avrNowPlaying = null;
|
||||
|
||||
/* --- transport -------------------------------------------------------- */
|
||||
async function api(path, body) {
|
||||
@@ -122,7 +124,7 @@ els('.room').forEach((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
|
||||
spotify: el('.spotify-row', node), // the resume buttons and the host toggle
|
||||
nowPlaying: el('[data-role="now-playing"]', node),
|
||||
cover: el('[data-role="cover"]', node),
|
||||
song: el('[data-role="song"]', node),
|
||||
@@ -169,7 +171,7 @@ function paintRoom(room) {
|
||||
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);
|
||||
renderTrack(room.available ? room.track : null, room);
|
||||
const playing = room.playState === 'play';
|
||||
room.node.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.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.
|
||||
if (room.spotify) {
|
||||
room.spotify.hidden = !room.available;
|
||||
els('.spotify', room.spotify).forEach((button) => {
|
||||
button.setAttribute('aria-pressed', String(button.dataset.account === room.spotifyAccount));
|
||||
});
|
||||
}
|
||||
// Grouped, the room plays whatever the AVR does, so resuming Spotify on it
|
||||
// is not on offer. They hide one by one rather than the row hiding with
|
||||
// them, because the row also holds the host toggle, which stays put and
|
||||
// merely disables.
|
||||
els('.spotify', room.spotify).forEach((button) => {
|
||||
button.hidden = !room.available || room.grouped;
|
||||
button.setAttribute('aria-pressed', String(button.dataset.account === room.spotifyAccount));
|
||||
});
|
||||
|
||||
room.toggle.disabled = !room.available;
|
||||
room.toggle.classList.toggle('busy', room.busy);
|
||||
room.toggle.setAttribute('aria-pressed', String(room.grouped));
|
||||
// Where the card sits already says whether it is grouped, so the button
|
||||
// says what tapping it does instead.
|
||||
room.toggleLabel.textContent = room.grouped ? 'Leave' : `Join ${HOST_LABEL}`;
|
||||
// Out of the group the button names where tapping it sends the room; in it,
|
||||
// where the card sits already says so, so the button says what it does.
|
||||
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);
|
||||
}
|
||||
|
||||
/* Song, artist and cover, each only when there is one. The cover's src is
|
||||
only touched when the track changes, so a poll never makes it flicker. */
|
||||
/* Song, artist, cover and progress, each only when there is one. The
|
||||
cover's src is only touched when the track changes, so a poll never makes
|
||||
it flicker. */
|
||||
function renderTrack(track, refs) {
|
||||
renderProgress(track, refs.progress);
|
||||
refs.nowPlaying.hidden = !track;
|
||||
if (!track) return;
|
||||
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
|
||||
{position_ms, duration_ms} the poll hands back -- absent for anything
|
||||
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. */
|
||||
function paintProgress(room) {
|
||||
const track = room.available ? room.track : null;
|
||||
room.progress.hidden = !track;
|
||||
function renderProgress(track, progress) {
|
||||
progress.hidden = !track;
|
||||
if (!track) return;
|
||||
const { position_ms: position, duration_ms: duration } = track;
|
||||
const known = typeof duration === 'number' && duration > 0 && typeof position === 'number';
|
||||
const percent = known ? Math.min(100, Math.max(0, (position / duration) * 100)) : 0;
|
||||
room.progress.classList.toggle('known', known);
|
||||
room.progress.style.setProperty('--progress', percent);
|
||||
progress.classList.toggle('known', known);
|
||||
progress.style.setProperty('--progress', percent);
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
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);
|
||||
// Left empty, a slot would still take a gap of its own and double the space.
|
||||
room.slot.hidden = room.grouped;
|
||||
}
|
||||
|
||||
function paintGrouping() {
|
||||
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.
|
||||
inside
|
||||
.slice()
|
||||
.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.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. */
|
||||
@@ -438,18 +446,6 @@ function applyJoined(joined) {
|
||||
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
|
||||
receiver to resume that account, instead of always connecting to it
|
||||
from the Spotify app -------------------------------------------------- */
|
||||
@@ -543,7 +539,6 @@ function render(state) {
|
||||
room.spotifyAccount = incoming.spotify_account || null;
|
||||
paintRoom(room);
|
||||
});
|
||||
paintGrouping();
|
||||
|
||||
const avr = state.avr || {};
|
||||
inputs = avr.inputs || [];
|
||||
@@ -552,7 +547,9 @@ function render(state) {
|
||||
setInputIcon(currentInput ? currentInput.code : null);
|
||||
ui.avrStatus.textContent = avr.connected ? 'ready' : 'offline';
|
||||
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 || [];
|
||||
ui.foot.textContent = problems.length ? problems[0] : (state.demo ? 'demo mode — no real speakers' : '');
|
||||
|
||||
+42
-36
@@ -13,6 +13,7 @@
|
||||
--live: #3ddc97;
|
||||
--warn: #ff7a6b;
|
||||
--radius: 22px;
|
||||
--gutter: 16px; /* from the screen's side to a card, and from one card to the next */
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
@@ -51,15 +52,20 @@ svg { width: 22px; height: 22px; fill: currentColor; }
|
||||
.app {
|
||||
max-width: 520px;
|
||||
margin: 0 auto;
|
||||
padding: max(12px, env(safe-area-inset-top)) max(16px, env(safe-area-inset-right))
|
||||
max(24px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left));
|
||||
padding: max(12px, env(safe-area-inset-top)) max(var(--gutter), env(safe-area-inset-right))
|
||||
max(24px, env(safe-area-inset-bottom)) max(var(--gutter), env(safe-area-inset-left));
|
||||
display: flex;
|
||||
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 ---------------------------------------------------------- */
|
||||
.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 .logo { height: 34px; width: auto; display: block; }
|
||||
|
||||
@@ -84,8 +90,10 @@ svg { width: 22px; height: 22px; fill: currentColor; }
|
||||
}
|
||||
.card.offline { opacity: .5; }
|
||||
/* A tint, not a repaint -- it should read at a glance without competing
|
||||
with the song title right above it. */
|
||||
.card.room.playing {
|
||||
with the song title right above it. A group plays one thing, so it is the
|
||||
host's card that lights up, not the rooms merged into it. */
|
||||
.card.room.playing,
|
||||
.card.source.playing {
|
||||
background: linear-gradient(135deg,
|
||||
color-mix(in srgb, var(--live) 25%, var(--card)) 0%,
|
||||
var(--card) 70%);
|
||||
@@ -118,6 +126,9 @@ svg { width: 22px; height: 22px; fill: currentColor; }
|
||||
object-fit: cover;
|
||||
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 span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.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-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 {
|
||||
flex: 0 0 auto;
|
||||
padding: 7px 14px; border-radius: 999px;
|
||||
height: 50px; padding: 0 14px; border-radius: 16px;
|
||||
background: var(--raised);
|
||||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||
font-size: 14px; font-weight: 550; white-space: nowrap;
|
||||
color: var(--muted);
|
||||
display: flex; align-items: center; justify-content: center; gap: 9px;
|
||||
font-size: 15px; font-weight: 550; white-space: nowrap;
|
||||
}
|
||||
.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"] .dot { background: #fff; opacity: 1; }
|
||||
.toggle:active { transform: scale(.985); }
|
||||
.toggle:disabled { opacity: .5; }
|
||||
.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 {
|
||||
flex: 1; min-width: 0;
|
||||
@@ -260,33 +275,24 @@ svg { width: 22px; height: 22px; fill: currentColor; }
|
||||
.source-button .chevron { color: var(--muted); }
|
||||
|
||||
/* --- rooms merged into the host's card --------------------------------- */
|
||||
.joined { display: flex; flex-direction: column; gap: 12px; }
|
||||
.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; }
|
||||
.joined { display: flex; flex-direction: column; gap: 14px; }
|
||||
|
||||
/* The card stops being a card in here: one border around the group, not
|
||||
one around every room in it. */
|
||||
.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; }
|
||||
/* Flush against the group's own card, so no radius here -- .playing has
|
||||
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%);
|
||||
}
|
||||
/* In here a room is an outline rather than a card: its border is what says
|
||||
it is playing with the AVR, and the fill -- a tint too, when it plays --
|
||||
belongs to the group's card instead. Same specificity as
|
||||
.card.room.playing, so being later is what wins. */
|
||||
.joined .card.room { background: none; border-color: var(--ink); border-radius: 18px; padding: 14px; gap: 12px; }
|
||||
.joined .card-head h2 { font-size: 15px; font-weight: 550; color: var(--muted); }
|
||||
.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: 36px; font-size: 13px; font-weight: 500;
|
||||
background: none; border: 1px solid var(--edge); color: var(--muted);
|
||||
}
|
||||
.joined .toggle .dot { display: none; }
|
||||
.joined .spotify { height: 40px; font-size: 13px; font-weight: 500; }
|
||||
.joined .toggle:active { background: var(--raised); color: var(--ink); }
|
||||
/* In here the Spotify buttons hide and the toggle says "Detach", so it takes
|
||||
their place and their look: the whole row, a plain background rather than
|
||||
the pressed accent, and an exit icon where the host's would only muddle. */
|
||||
.joined .toggle { flex: 1; min-width: 0; }
|
||||
.joined .toggle[aria-pressed="true"] { background: var(--raised); color: inherit; }
|
||||
.joined .toggle .cinema { display: none; }
|
||||
.joined .toggle .leave { display: block; }
|
||||
|
||||
/* --- misc ------------------------------------------------------------- */
|
||||
.wide { width: 100%; height: 50px; border-radius: 16px; font-size: 15px; }
|
||||
|
||||
+31
-23
@@ -19,18 +19,29 @@
|
||||
</head>
|
||||
<body>
|
||||
|
||||
{% macro spotify_row(target, accounts) -%}
|
||||
{% if accounts %}
|
||||
<div class="spotify-row" hidden>
|
||||
{% macro host_toggle() -%}
|
||||
<button class="toggle" data-role="group" aria-pressed="false">
|
||||
<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 %}
|
||||
{% if loop.index0 == split %}{{ host_toggle() }}{% endif %}
|
||||
<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>
|
||||
<span>{{ account.label }}</span>
|
||||
</button>
|
||||
{% endfor %}
|
||||
{# Fewer accounts than the midpoint means the loop never reached it. #}
|
||||
{% if accounts | length <= split %}{{ host_toggle() }}{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{%- endmacro %}
|
||||
|
||||
<div class="app">
|
||||
@@ -46,10 +57,6 @@
|
||||
<section class="card room" data-room="{{ room.key }}">
|
||||
<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>
|
||||
<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>
|
||||
@@ -91,7 +98,7 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{ spotify_row(room.key, spotify_accounts) }}
|
||||
{{ control_row(room.key, spotify_accounts) }}
|
||||
</section>
|
||||
</div>
|
||||
{% 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>
|
||||
<span class="pill" data-role="avr-status">offline</span>
|
||||
</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">
|
||||
<span class="eyebrow">Input</span>
|
||||
<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>
|
||||
</button>
|
||||
|
||||
<div class="now-playing" data-role="avr-now-playing" hidden>
|
||||
<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>
|
||||
<div class="joined" data-role="joined" hidden></div>
|
||||
</section>
|
||||
|
||||
<button class="wide ghost" data-role="split-all" hidden>Separate everything</button>
|
||||
<p class="foot" data-role="foot"></p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -12,7 +12,7 @@ an AVR input, not something the rest of the panel depends on.
|
||||
"""
|
||||
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
|
||||
@@ -20,20 +20,74 @@ class ZidooClient:
|
||||
def __init__(self, host, port=9529, timeout=1.5):
|
||||
self.base_url = f"http://{host}:{port}"
|
||||
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):
|
||||
"""{"song", "artist", "image"} for whatever video is loaded, in the
|
||||
same shape a room's now-playing card already expects -- or None."""
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
f"{self.base_url}/ZidooVideoPlay/getPlayStatus", timeout=self.timeout
|
||||
) as response:
|
||||
payload = json.loads(response.read())
|
||||
except (urllib.error.URLError, ValueError):
|
||||
same shape a room's now-playing card already expects -- or None.
|
||||
A film has no artist, so that line under the title carries its year
|
||||
instead. "image" is always None: the poster lives on the Zidoo, which
|
||||
the phone cannot load over plain http, so "poster_id" names it
|
||||
instead for app.py to serve. "position_ms" and "duration_ms" come
|
||||
along whenever the length is known, as a room's do."""
|
||||
payload = self._get_json("ZidooVideoPlay/getPlayStatus")
|
||||
if not payload or payload.get("status") != 200:
|
||||
return None
|
||||
if payload.get("status") != 200:
|
||||
return None
|
||||
title = (payload.get("video") or {}).get("title")
|
||||
video = payload.get("video") or {}
|
||||
title = video.get("title")
|
||||
if not title:
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user