merge all rooms (inc. AVR)
Deploy HEOS panel / deploy (push) Successful in 28s

This commit is contained in:
franzz committed 2026-09-20 18:10:32 +02:00
1 parent 0560079b16
commit db00e57ef7
9 files changed
+328 -228

No files matched your search

+1 -1
View File
@@ -140,7 +140,7 @@ Used by the interface:
| `GET /api/targets` | every player and group HEOS can see |
| `POST /api/volume` | `{"target": "lego_room", "steps": 1}` — taps, snapped to `VOLUME_STEP`. Also takes `delta` (raw points) or `level` (absolute) |
| `POST /api/mute` | `{"target": "lego_room"}` |
| `POST /api/playback` | `{"target": "lego_room", "state": "pause"}`, or no `state` to toggle |
| `POST /api/playback` | `{"target": "lego_room", "state": "pause"}`, or no `state` to toggle — the Zidoo's film for the AVR (`play` and `pause` only), a room's own stream over HEOS otherwise |
| `POST /api/skip` | `{"target": "lego_room", "direction": "next"}` — `previous` too |
| `POST /api/seek` | `{"target": "lego_room", "position_ms": 90000}` — the Zidoo's film for the AVR, a room's Spotify stream otherwise (HEOS itself cannot seek) |
| `POST /api/group` | `{"target": "lego_room", "joined": true}` |
+11 -1
View File
@@ -422,12 +422,22 @@ def api_mute():
@handle_errors
def api_playback():
"""Start or stop a room. Send 'state' to be explicit, or leave it out to
flip whatever the speakers are actually doing."""
flip whatever the speakers are actually doing.
For the AVR this goes around HEOS to the Zidoo, the same way /api/seek
does and for the same reason: what the AVR's card shows playing is a
device on one of its inputs, which HEOS can no more pause than seek in.
The Zidoo has no 'stop', only play and pause."""
data = _payload()
key = _target_from(data)
state = data.get("state")
if state is not None and state not in ("play", "pause", "stop"):
raise ValueError("'state' must be play, pause or stop -- or left out to toggle")
if key == config.HOST_KEY:
if state == "stop":
raise ValueError("The AVR's card plays a device on one of its inputs, which can only "
"play or pause -- 'stop' is for a HEOS room")
return jsonify({"target": key, "state": controller.zidoo_play_state(state)})
return jsonify({"target": key, "state": controller.toggle_play(key, state)})
+12
View File
@@ -440,6 +440,18 @@ class Controller:
return None
return self.zidoo.now_playing()
def zidoo_play_state(self, state: str = None) -> str:
"""Start or stop the Zidoo's film -- the AVR card's now-playing. With
no state, flips what the Zidoo itself reports rather than what the
phone last saw, the same way toggle_play() does for a room."""
if not self.zidoo:
raise TargetError("No Zidoo is configured to play or pause")
if state is None:
playing = (self.zidoo.now_playing() or {}).get("play_state") == "play"
state = "pause" if playing else "play"
self.zidoo.play_state(state)
return state
def zidoo_seek(self, position_ms: int):
"""Jump the Zidoo's film -- the AVR card's now-playing -- to
position_ms."""
+12 -1
View File
@@ -7,6 +7,7 @@ network -- and lets the panel be tested without waking the house up.
import time
from controller import stepped_level
from zidoo import ZidooError
class _FakeAvr:
@@ -61,6 +62,7 @@ class DemoController:
# Its play button only shows for a stream one of your accounts plays.
self._account = next(iter(cfg.SPOTIFY_ACCOUNTS), None)
self._film_ms = 241000 # where the pretend Zidoo's film is, so a seek sticks
self._film_play = "play" # and whether it runs, so its play button sticks too
# -- what the UI uses ---------------------------------------------
def state(self):
@@ -80,7 +82,8 @@ class DemoController:
# 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": "2008", "image": None,
"play_state": "play", "position_ms": self._film_ms, "duration_ms": 596000}
"play_state": self._film_play,
"position_ms": self._film_ms, "duration_ms": 596000}
if self.avr.current_input()["code"] == self.cfg.ZIDOO_INPUT_CODE else None,
},
"heos_ok": True,
@@ -125,6 +128,14 @@ class DemoController:
self._joined = {k for k in self.cfg.ROOM_KEYS if k in joined}
return self.joined_keys()
def zidoo_play_state(self, state=None):
if state is None:
state = "pause" if self._film_play == "play" else "play"
if state not in ("play", "pause"):
raise ZidooError(f"The Zidoo has no key for '{state}' -- only play and pause")
self._film_play = state
return state
def zidoo_seek(self, position_ms):
self._film_ms = min(596000, int(position_ms))
+114 -141
View File
@@ -8,6 +8,9 @@ const BASE = document.documentElement.dataset.base || '/';
const POLL_MS = 5000;
// How long a dropped cursor waits for the player to report it got there.
const SEEK_HOLD_MS = 10000;
// And how long a pressed play or pause on the AVR waits for the same. The
// Zidoo acknowledges the key press at once but takes a moment to act on it.
const PLAY_HOLD_MS = 6000;
// Motion turned down in the OS means cards change places without sliding.
const CALM = matchMedia('(prefers-reduced-motion: reduce)');
@@ -63,45 +66,19 @@ const ui = {
app: el('.app'),
avr: el('.card.avr'),
joined: el('[data-role="joined"]'),
avrMedia: el('[data-role="avr-media"]'),
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,
// 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 = {
media: ui.avrMedia,
nowPlaying: ui.avrNowPlaying,
cover: ui.avrCover,
song: ui.avrSong,
artist: ui.avrArtist,
progress: ui.avrProgress,
// Only a Zidoo ever fills this card's now-playing in, and it can always seek.
canSeek: () => true,
// A film paused on the Zidoo reports its own play_state; nothing else
// ever lands here with a position and duration to tick.
playing: () => avrNowPlaying?.play_state === 'play',
seek: null,
};
// Same reasoning as a room's own cover: drop one that will not load rather
// than leave a broken-image box.
avrTrack.cover.addEventListener('error', () => { avrTrack.cover.hidden = true; });
seekable(avrTrack);
// The AVR is one of the rooms below, so its key comes off its card like
// theirs do. Which one it is, everything that only it can do goes through.
const HOST = ui.avr.dataset.room;
// Same as a room's card: named, so a view transition can follow it when it
// moves to the top and back.
// Named, so a view transition can follow the card when it moves to the top
// and back. A room's name is set as it is placed; the host's never moves.
ui.avr.style.viewTransitionName = 'avr';
const rooms = {};
let inputs = [];
let currentInput = null;
let avrNowPlaying = null;
/* --- transport -------------------------------------------------------- */
async function api(path, body) {
@@ -126,11 +103,12 @@ function toast(message, kind = 'error') {
}
/* --- rooms ------------------------------------------------------------ */
els('.room').forEach((node) => {
els('.card[data-room]').forEach((node) => {
const key = node.dataset.room;
const room = {
key,
node,
avr: node.classList.contains('avr'),
level: el('[data-role="level"]', node),
meter: el('[data-role="meter"]', node),
actions: el('[data-role="actions"]', node),
@@ -159,10 +137,12 @@ els('.room').forEach((node) => {
inflight: false,
busy: false, // a grouping change is in flight
playBusy: false,
wantedPlay: null, // {state, sentAt} from a press until the player reports it
// HEOS cannot seek, so only a stream Spotify can be asked to seek in: the
// same one the transport buttons show for (see paintRoom).
canSeek: () => room.onSpotify && room.spotifyAccount && !room.grouped,
playing: () => room.playState === 'play',
// same one the transport buttons show for (see paintTransport). Only a
// Zidoo ever fills the AVR's card in, and it can always seek.
canSeek: () => room.avr || (room.onSpotify && room.spotifyAccount && !room.grouped),
playing: () => shownPlayState(room) === 'play',
seek: null, // {ms, stage, ...} from a drag of the cursor until the player catches up
};
rooms[key] = room;
@@ -171,7 +151,7 @@ els('.room').forEach((node) => {
const direction = Number(button.dataset.delta);
holdable(button, () => nudge(key, direction));
});
draggable(room);
if (room.meter) draggable(room);
seekable(room);
// A cover that will not load (gone, or plain http on an https page) is
@@ -179,37 +159,43 @@ els('.room').forEach((node) => {
// 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'));
if (room.toggle) room.toggle.addEventListener('click', () => setGrouped(key, !room.grouped));
if (room.prev) room.prev.addEventListener('click', () => skipTrack(key, 'previous'));
room.play.addEventListener('click', () => togglePlay(key));
room.next.addEventListener('click', () => skipTrack(key, 'next'));
if (room.next) room.next.addEventListener('click', () => skipTrack(key, 'next'));
});
function paintRoom(room) {
/* The volume row, which the AVR's card has not got: its own level is not in
/api/state, so there is nothing to draw a meter from. */
function paintVolume(room) {
if (!room.meter) return;
const known = room.volume !== null && room.volume !== undefined;
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; });
renderTrack(room.available ? room.track : null, room);
const playing = room.playState === 'play';
}
function paintTransport(room) {
const state = shownPlayState(room);
const playing = state === 'play';
room.node.classList.toggle('playing', playing);
room.play.classList.toggle('playing', playing);
room.play.disabled = !room.available || room.playState === null;
room.play.disabled = !room.available || !state;
room.play.setAttribute(
'aria-label', `${room.node.querySelector('h2').textContent}: ${playing ? 'pause' : 'play'}`);
// 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;
room.actions.hidden = room.avr
? !state
: !(room.onSpotify && room.spotifyAccount && !room.grouped);
if (room.prev) room.prev.disabled = !room.available;
if (room.next) room.next.disabled = !room.available;
}
/* The Spotify buttons and the host toggle, which the AVR's card has not got:
those resume an account in a room, or join it to the AVR, and neither is
something the AVR does to itself. */
function paintSources(room) {
if (!room.sources) return;
// 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
@@ -231,6 +217,14 @@ function paintRoom(room) {
// 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';
}
function paintRoom(room) {
room.node.classList.toggle('offline', !room.available);
renderTrack(room.available ? room.track : null, room);
paintVolume(room);
paintTransport(room);
paintSources(room);
placeRoom(room);
placeCards();
}
@@ -254,15 +248,6 @@ function renderTrack(track, refs) {
}
}
/* 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. Around
a seek, the position seekPosition() picks shows instead of the one
reported. Also leaves refs.tick pointing at this position and the moment
it was true, for tickProgress() to run forward from until the next call
here -- a poll, a drag, or a seek landing -- corrects it and moves the
mark again. */
function renderProgress(track, refs) {
refs.shown = null;
refs.tick = null;
@@ -305,12 +290,6 @@ function paintProgress(refs, known, position, duration) {
el('[data-role="progress-total"]', progress).textContent = known ? clock(total) : '';
}
/* Between polls the cursor otherwise sits still for POLL_MS at a time and
then jumps -- so this nudges it along at real speed from the last known
position, purely cosmetic until the next renderProgress() (a poll, most
often) corrects it and moves the mark again. Skipped while a seek owns
the display (seekPosition already covers that) or nothing is actually
playing, so a paused cursor stays put. */
function tickProgress(refs) {
if (!refs.tick || !refs.shown || refs.seek || !refs.playing()) return;
const { ms, at, duration } = refs.tick;
@@ -318,17 +297,8 @@ function tickProgress(refs) {
paintProgress(refs, true, position, duration);
}
setInterval(() => {
tickProgress(avrTrack);
Object.values(rooms).forEach(tickProgress);
}, 250);
setInterval(() => Object.values(rooms).forEach(tickProgress), 250);
/* Drag the cursor, or either time riding with it, to jump backwards or
forwards. Like the volume knob, it moves by how far the finger travels
rather than jumping to where it lands, so grabbing a time off-centre never
lurches the song -- and only the release is sent, so the player is not
asked to seek a dozen times along the way. A vertical swipe stays a page
scroll (see panel.css), which cancels the drag. */
function seekable(refs) {
const { progress } = refs;
const line = el('.progress-line', progress);
@@ -384,7 +354,7 @@ async function sendSeek(refs) {
seek.stage = 'send';
seek.sentAt = Date.now();
try {
await api('/api/seek', { target: refs.progress.dataset.target, position_ms: seek.ms });
await api('/api/seek', { target: refs.key, position_ms: seek.ms });
seek.stage = 'hold';
// The player takes a moment to report where it has got to.
setTimeout(refresh, 1500);
@@ -396,13 +366,6 @@ async function sendSeek(refs) {
if (refs.shown) renderProgress(refs.shown, refs);
}
/* Where the cursor goes: under the finger while dragging, and where it was
dropped while that is sent. After that the player still reports its old
position for a poll or two -- HEOS only passes its progress on as it goes,
and a Zidoo mid-seek is no quicker -- so the cursor stays put rather than
bouncing back, until a reported position could only come after the seek
(the drop, give or take, plus however long it has played on since). A
different track, or SEEK_HOLD_MS with no such report, lets go as well. */
function seekPosition(track, refs) {
const { seek } = refs;
if (!seek) return track.position_ms;
@@ -417,8 +380,6 @@ function seekPosition(track, refs) {
return seek.ms;
}
/* Dragged or on its way -- not merely waiting for the player to catch up,
which is exactly what the polls must keep coming for. */
function seeking(refs) {
return Boolean(refs.seek) && refs.seek.stage !== 'hold';
}
@@ -431,11 +392,10 @@ function clock(seconds) {
return h ? `${h}:${String(m).padStart(2, '0')}:${s}` : `${m}:${s}`;
}
/* A merged room moves into the host's card, because that is what merging
means: one group, playing one thing. Leaving puts it back in the column,
ahead of the rooms declared after it, so the cards keep the order they are
declared in however they were grouped. */
function placeRoom(room) {
// The host's card is where the column ends, and every room placed above
// measures from it, so it is the one card that never moves.
if (room.avr) return;
if (room.grouped) {
if (room.node.parentElement !== ui.joined) ui.joined.appendChild(room.node);
} else if (room.node.parentElement !== ui.app) {
@@ -465,20 +425,11 @@ function paintGrouping() {
// the whole group is -- and the tint goes on the group's card.
// A film running on the Zidoo counts as the AVR playing too, grouped rooms or not.
const playing = inside.some((node) => node.classList.contains('playing'))
|| avrNowPlaying?.play_state === 'play';
|| shownPlayState(rooms[HOST]) === 'play';
ui.avr.classList.toggle('playing', playing);
placeCards();
}
/* Which card sits at the top of the column is CSS order (see .on-top), and
order is not a property that transitions: the cards change places between
one frame and the next. A view transition does the sliding instead -- the
browser snapshots the cards where they are, runs the swap, and animates
them to where they landed. One turn can paint several cards (a poll
touches every room and then the group), so the move is collected and run
once, and only started when a card really does change places. Without
support, or with motion turned down, the cards just change places the way
they always did. */
let placing = false;
function placeCards() {
if (placing) return;
@@ -488,9 +439,12 @@ function placeCards() {
// Grouped, a room's card has left the column and the AVR's is the one
// that moves up. The AVR's tint and its position answer the same
// question, and paintGrouping() has just settled it.
const wanted = Object.values(rooms)
.map((room) => [room.node, room.playing() && !room.grouped]);
wanted.push([ui.avr, ui.avr.classList.contains('playing')]);
const wanted = Object.values(rooms).map((room) => [
room.node,
// The AVR's tint already answers this: paintGrouping has just settled
// it, over the whole group rather than the host's own stream alone.
room.avr ? room.node.classList.contains('playing') : room.playing() && !room.grouped,
]);
const move = () => wanted.forEach(([node, top]) => node.classList.toggle('on-top', top));
const moves = wanted.some(([node, top]) => node.classList.contains('on-top') !== top);
@@ -520,10 +474,6 @@ function holdable(node, action) {
['pointerup', 'pointercancel', 'pointerleave'].forEach((type) => node.addEventListener(type, stop));
}
/* A tap lands on the next multiple of STEP rather than adding STEP, so a
level of 23 goes to 25 on the way up and 20 on the way down. Mirrors
stepped_level() in controller.py -- the panel guesses with it, the
speakers are the ones that decide. */
function nextLevel(current, direction) {
if (direction > 0) return Math.min(100, (Math.floor(current / STEP) + 1) * STEP);
const aligned = Math.floor(current / STEP) * STEP;
@@ -573,11 +523,6 @@ async function 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;
@@ -633,23 +578,46 @@ async function setGrouped(key, joined) {
}
}
/* Sends the state it wants rather than "toggle", so a stale idea of what
the room is doing cannot flip it the wrong way. */
function shownPlayState(room) {
const reported = room.playState;
if (!room.wantedPlay) return reported;
// Nothing loaded any more -- the film ended, or the AVR changed input --
// leaves nothing to hold; so does the player reporting the state asked for.
if (!reported || reported === room.wantedPlay.state
|| Date.now() - room.wantedPlay.sentAt > PLAY_HOLD_MS) {
room.wantedPlay = null;
}
return room.wantedPlay ? room.wantedPlay.state : reported;
}
/* Sends the state it wants rather than "toggle", so a stale idea of what the
room is doing cannot flip it the wrong way. For the AVR this reaches the
Zidoo rather than HEOS, which can no more pause a device on one of the
AVR's inputs than it can seek in it -- app.py routes it there. */
async function togglePlay(key) {
const room = rooms[key];
if (!room.available || room.playBusy) return;
const wanted = room.playState === 'play' ? 'pause' : 'play';
const state = shownPlayState(room);
if (!room.available || !state || room.playBusy) return;
const wanted = state === 'play' ? 'pause' : 'play';
room.playBusy = true;
room.playState = wanted;
room.wantedPlay = { state: wanted, sentAt: Date.now() };
paintRoom(room);
paintGrouping();
try {
const data = await api('/api/playback', { target: key, state: wanted });
room.playState = data.state;
// HEOS answers with the state the room is now in, which lets the hold go
// at once. The Zidoo only says the key press was taken, so the AVR's card
// keeps holding until a poll sees the film itself move.
if (!room.avr) room.playState = data.state;
} catch (error) {
toast(error.message);
room.wantedPlay = null;
} finally {
room.playBusy = false;
paintRoom(room);
paintGrouping();
// The Zidoo takes a moment to report that it has started or stopped.
if (room.avr) setTimeout(refresh, 1500);
}
}
@@ -792,31 +760,37 @@ async function chooseInput(source) {
}
/* --- state ------------------------------------------------------------- */
function applyRoom(room, incoming) {
if (!room) return;
room.available = Boolean(incoming.available);
room.grouped = Boolean(incoming.grouped);
// Do not stomp on a volume the user is in the middle of changing.
if (!room.taps && !room.inflight && !room.dragging && room.wanted === null) {
room.volume = incoming.volume;
}
if (!room.playBusy) room.playState = incoming.play_state || null;
room.track = incoming.now_playing || null;
room.onSpotify = Boolean(incoming.spotify);
room.spotifyAccount = incoming.spotify_account || null;
paintRoom(room);
}
function render(state) {
(state.rooms || []).forEach((incoming) => {
const room = rooms[incoming.key];
if (!room) return;
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.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);
});
(state.rooms || []).forEach((incoming) => applyRoom(rooms[incoming.key], incoming));
const avr = state.avr || {};
inputs = avr.inputs || [];
currentInput = avr.input || null;
ui.inputName.textContent = currentInput ? currentInput.name : '—';
setInputIcon(currentInput ? currentInput.code : null);
avrNowPlaying = avr.now_playing || null;
renderTrack(avrNowPlaying, avrTrack);
paintGrouping(); // after avrNowPlaying, which decides where the AVR's card sits
// Whatever is on one of its inputs is what the AVR is playing, and its own
// play state is that device's -- HEOS has none to give for an input.
applyRoom(rooms[HOST], {
available: avr.connected !== false,
now_playing: avr.now_playing || null,
play_state: (avr.now_playing || {}).play_state || null,
});
paintGrouping(); // after the AVR's card, which decides where it sits
const problems = state.errors || [];
ui.foot.textContent = problems.length ? problems[0] : (state.demo ? 'demo mode — no real speakers' : '');
@@ -839,7 +813,6 @@ async function refresh() {
function busy() {
return sheetOpen()
|| seeking(avrTrack)
|| Object.values(rooms).some((r) => r.taps || r.inflight || r.dragging || r.busy || r.playBusy || seeking(r));
}
+2 -2
View File
@@ -84,8 +84,8 @@ svg { width: 22px; height: 22px; fill: currentColor; }
/* Header */
.top { order: -2; display: flex; align-items: center; justify-content: center; }
.top .logo { height: 44px; width: auto; display: block; margin: var(--space-xs); }
header { order: -2; display: flex; align-items: center; justify-content: center; }
header .logo { height: 44px; width: auto; display: block; margin: var(--space-xs); }
/* Card */
+75 -82
View File
@@ -20,6 +20,16 @@
</head>
<body>
{# Play and pause are one button: panel.css shows whichever glyph the
.playing class calls for. A room's drives its Spotify stream, the AVR's
the film on the Zidoo plugged into it. #}
{% macro play_button(role, label) -%}
<button class="control-btn" data-role="{{ role }}" aria-label="{{ 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>
{%- endmacro %}
{% macro host_toggle() -%}
<button class="sources-btn toggle" data-role="group" aria-pressed="false">
<svg class="cinema icon" viewBox="0 0 512 512" aria-hidden="true"><path d="M256 512a256 256 0 1 0 0-512 256 256 0 1 0 0 512zM128 192c0-17.7 14.3-32 32-32l128 0c17.7 0 32 14.3 32 32l0 38.4 61-36.6c1.9-1.2 4.2-1.8 6.4-1.8 6.9 0 12.5 5.6 12.5 12.5l0 102.9c0 6.9-5.6 12.5-12.5 12.5-2.3 0-4.5-.6-6.4-1.8l-61-36.6 0 38.4c0 17.7-14.3 32-32 32l-128 0c-17.7 0-32-14.3-32-32l0-128z"/></svg>
@@ -47,99 +57,82 @@
</div>
{%- endmacro %}
<div class="app">
<header class="top">
<img class="logo" src="{{ url_for('static', filename='logo.svg') }}" alt="{{ app_name }}">
</header>
{% macro room_card(room, accounts=()) -%}
{% set avr = room.kind == 'avr' %}
<section class="card {{ 'avr' if avr else '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>
</div>
{% for room in rooms %}
<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>
</div>
<div class="media" data-role="media" hidden>
<div class="now-playing">
<img class="cover" data-role="cover" alt="">
<div class="media" data-role="media" hidden>
<div class="now-playing">
<img class="cover{{ ' poster' if avr }}" data-role="cover" alt="">
<div class="track">
<span class="song" data-role="song"></span>
<span class="artist" data-role="artist"></span>
</div>
</div>
<div class="progress" data-role="progress" data-target="{{ room.key }}">
<div class="progress-line">
<i data-role="progress-fill"></i>
<b class="progress-cursor" data-role="progress-cursor"></b>
<span class="progress-time elapsed" data-role="progress-elapsed"></span>
<span class="progress-time remaining" data-role="progress-remaining"></span>
</div>
<span class="progress-total" data-role="progress-total"></span>
</div>
</div>
<div class="volume">
<button class="control-btn 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" data-role="meter"><i></i><b class="knob" data-role="level">—</b></div>
<button class="control-btn 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" data-role="actions" hidden>
<button class="control-btn" 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="control-btn" 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="control-btn" 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>
</div>
{{ control_row(room.key, spotify_accounts) }}
</section>
{% endfor %}
<section class="card avr">
<div class="card-head">
<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>
</div>
<div class="media" data-role="avr-media" hidden>
<div class="now-playing" data-role="avr-now-playing">
<img class="cover poster" data-role="avr-cover" alt="">
<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" data-target="{{ host.key }}">
<div class="progress-line">
<i data-role="progress-fill"></i>
<b class="progress-cursor" data-role="progress-cursor"></b>
<span class="progress-time elapsed" data-role="progress-elapsed"></span>
<span class="progress-time remaining" data-role="progress-remaining"></span>
</div>
<span class="progress-total" data-role="progress-total"></span>
<div class="progress" data-role="progress">
<div class="progress-line">
<i data-role="progress-fill"></i>
<b class="progress-cursor" data-role="progress-cursor"></b>
<span class="progress-time elapsed" data-role="progress-elapsed"></span>
<span class="progress-time remaining" data-role="progress-remaining"></span>
</div>
<span class="progress-total" data-role="progress-total"></span>
</div>
</div>
<button class="input-select" 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>
<span class="value" data-role="input-name">—</span>
<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>
{% if not avr %}
<div class="volume">
<button class="control-btn 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" data-role="meter"><i></i><b class="knob" data-role="level">—</b></div>
<button class="control-btn 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>
{% endif %}
<div class="joined" data-role="joined" hidden></div>
</section>
<div class="actions" data-role="actions" hidden>
{% if not avr %}
<button class="control-btn" data-role="prev" aria-label="{{ room.label }}: previous track">
<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>
{% endif %}
{{ play_button('play', room.label) }}
{% if not avr %}
<button class="control-btn" 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>
{% endif %}
</div>
{% if avr %}
<button class="input-select" 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>
<span class="value" data-role="input-name">—</span>
<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="joined" data-role="joined" hidden></div>
{% else %}
{{ control_row(room.key, accounts) }}
{% endif %}
</section>
{%- endmacro %}
<div class="app">
<header>
<img class="logo" src="{{ url_for('static', filename='logo.svg') }}" alt="{{ app_name }}">
</header>
{% for room in rooms + [host] %}
{{ room_card(room, spotify_accounts) }}
{% endfor %}
<footer data-role="foot"></footer>
</div>
+81
View File
@@ -10,10 +10,12 @@ import threading
import unittest
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from types import SimpleNamespace
from urllib.parse import parse_qs, urlparse
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from controller import Controller, TargetError # noqa: E402
from zidoo import ZidooClient, ZidooError # noqa: E402
PATH = "/storage/disk/Movies/A.Private.War.2018.1080p.mp4"
@@ -26,6 +28,8 @@ class FakeZidoo(BaseHTTPRequestHandler):
posters = {} # poster id -> image bytes
lookups = [] # paths getAggregationOfFile was asked about
seeks = [] # every seekTo's position
keys = [] # every sendkey the remote endpoint was handed
accepts = {"Key.MediaPlay", "Key.MediaPause"} # the rest come back 805
def do_GET(self):
url = urlparse(self.path)
@@ -39,6 +43,14 @@ class FakeZidoo(BaseHTTPRequestHandler):
return self._send(404, b"")
self.seeks.append(int(query["positon"])) # sic, as the real one spells it
return self._json({"status": 200})
if url.path == "/ZidooControlCenter/RemoteControl/sendkey":
key = query.get("key")
if key not in self.accepts:
return self._json({"status": 805, "msg": "The parameter is error or no parameter is submitted"})
# The real box answers before the player has acted on the press,
# so getPlayStatus keeps reporting the old state for a moment.
self.keys.append(key)
return self._json({"status": 200})
if url.path == "/ZidooPoster/v2/getAggregationOfFile":
self.lookups.append(query["path"])
return self._json(self.library.get(query["path"], {"status": 804, "msg": "Error!!!"}))
@@ -69,6 +81,8 @@ class ZidooTest(unittest.TestCase):
FakeZidoo.posters = {108: PNG}
FakeZidoo.lookups = []
FakeZidoo.seeks = []
FakeZidoo.keys = []
FakeZidoo.accepts = {"Key.MediaPlay", "Key.MediaPause"}
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])
@@ -111,6 +125,29 @@ class ZidooTest(unittest.TestCase):
with self.assertRaises(ZidooError):
self.zidoo.seek(3600000)
def test_play_and_pause_send_the_matching_media_key(self):
self.zidoo.play_state("pause")
self.zidoo.play_state("play")
self.assertEqual(FakeZidoo.keys, ["Key.MediaPause", "Key.MediaPlay"])
def test_a_state_the_box_has_no_key_for_is_an_error(self):
"""HEOS rooms take "stop" as well; the Zidoo's remote has no such key,
so /api/playback's third state has to be refused rather than guessed at."""
with self.assertRaises(ZidooError):
self.zidoo.play_state("stop")
self.assertEqual(FakeZidoo.keys, [])
def test_a_key_the_box_rejects_is_an_error(self):
FakeZidoo.accepts = set()
with self.assertRaises(ZidooError):
self.zidoo.play_state("pause")
def test_an_unreachable_box_is_an_error_not_silence(self):
self.server.shutdown()
self.server.server_close()
with self.assertRaises(ZidooError):
self.zidoo.play_state("pause")
def test_poster_is_sniffed_from_its_bytes(self):
self.assertEqual(self.zidoo.poster(108), (PNG, "image/png"))
@@ -118,5 +155,49 @@ class ZidooTest(unittest.TestCase):
self.assertIsNone(self.zidoo.poster(2248))
class ZidooThroughTheControllerTest(unittest.TestCase):
"""zidoo_play_state() only ever talks to the Zidoo, so this needs no HEOS
behind it -- HeosClient does not dial until something asks it to."""
def setUp(self):
FakeZidoo.video = {"status": 1, "title": "A Private War", "path": PATH,
"currentPosition": 1589745, "duration": 6635092}
FakeZidoo.library = {}
FakeZidoo.keys = []
FakeZidoo.accepts = {"Key.MediaPlay", "Key.MediaPause"}
self.server = ThreadingHTTPServer(("127.0.0.1", 0), FakeZidoo)
threading.Thread(target=self.server.serve_forever, args=(0.05,), daemon=True).start()
self.cfg = SimpleNamespace(
HEOS_HOST="127.0.0.1", HEOS_PORT=1255,
HOST_KEY="avr", ROOM_KEYS=[],
TARGETS={"avr": {"label": "Home Cinema", "heos_name": "Home Cinema"}},
VOLUME_STEP=5,
ZIDOO_HOST="127.0.0.1", ZIDOO_PORT=self.server.server_address[1],
)
self.panel = Controller(self.cfg)
def tearDown(self):
self.server.shutdown()
self.server.server_close()
def test_an_explicit_state_goes_straight_through(self):
self.assertEqual(self.panel.zidoo_play_state("pause"), "pause")
self.assertEqual(FakeZidoo.keys, ["Key.MediaPause"])
def test_no_state_flips_what_the_zidoo_itself_reports(self):
self.assertEqual(self.panel.zidoo_play_state(), "pause")
self.assertEqual(FakeZidoo.keys, ["Key.MediaPause"])
def test_no_state_starts_a_film_the_zidoo_has_paused(self):
FakeZidoo.video["status"] = 0
self.assertEqual(self.panel.zidoo_play_state(), "play")
self.assertEqual(FakeZidoo.keys, ["Key.MediaPlay"])
def test_no_zidoo_configured_is_a_target_error(self):
panel = Controller(SimpleNamespace(**{**vars(self.cfg), "ZIDOO_HOST": None}))
with self.assertRaises(TargetError):
panel.zidoo_play_state("pause")
if __name__ == "__main__":
unittest.main()
+20
View File
@@ -22,6 +22,13 @@ class ZidooError(RuntimeError):
class ZidooClient:
# The video player answers getPlayStatus and seekTo, but has no route of
# its own for starting or stopping what it is showing -- the box's remote
# takes that, and its media keys reach the player like a press on the
# handset would. One key per state rather than a single toggle, for the
# same reason a room sends the state it wants (see controller.py).
KEYS = {"play": "Key.MediaPlay", "pause": "Key.MediaPause"}
def __init__(self, host, port=9529, timeout=1.5):
self.base_url = f"http://{host}:{port}"
self.timeout = timeout
@@ -70,6 +77,19 @@ class ZidooClient:
track["duration_ms"] = video["duration"]
return track
def play_state(self, state):
"""Start or stop the loaded video. Like seek(), this is something
someone asked for, so a Zidoo that does not answer is an error rather
than nothing to show. The key press is acknowledged at once but the
player takes a moment to act on it, so the next now_playing() can
still report the state it is on its way out of."""
key = self.KEYS.get(state)
if key is None:
raise ZidooError(f"The Zidoo has no key for '{state}' -- only play and pause")
payload = self._get_json("ZidooControlCenter/RemoteControl/sendkey", key=key)
if not payload or payload.get("status") != 200:
raise ZidooError(f"The Zidoo did not {state} -- is it on and reachable?")
def seek(self, position_ms):
"""Jump the loaded video to position_ms. Unlike now_playing(), this
is something someone asked for, so a Zidoo that does not answer is