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 | | `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/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/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/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/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}` | | `POST /api/group` | `{"target": "lego_room", "joined": true}` |
+11 -1
View File
@@ -422,12 +422,22 @@ def api_mute():
@handle_errors @handle_errors
def api_playback(): def api_playback():
"""Start or stop a room. Send 'state' to be explicit, or leave it out to """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() data = _payload()
key = _target_from(data) key = _target_from(data)
state = data.get("state") state = data.get("state")
if state is not None and state not in ("play", "pause", "stop"): 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") 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)}) return jsonify({"target": key, "state": controller.toggle_play(key, state)})
+12
View File
@@ -440,6 +440,18 @@ class Controller:
return None return None
return self.zidoo.now_playing() 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): def zidoo_seek(self, position_ms: int):
"""Jump the Zidoo's film -- the AVR card's now-playing -- to """Jump the Zidoo's film -- the AVR card's now-playing -- to
position_ms.""" position_ms."""
+12 -1
View File
@@ -7,6 +7,7 @@ network -- and lets the panel be tested without waking the house up.
import time import time
from controller import stepped_level from controller import stepped_level
from zidoo import ZidooError
class _FakeAvr: class _FakeAvr:
@@ -61,6 +62,7 @@ class DemoController:
# Its play button only shows for a stream one of your accounts plays. # Its play button only shows for a stream one of your accounts plays.
self._account = next(iter(cfg.SPOTIFY_ACCOUNTS), None) 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_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 --------------------------------------------- # -- what the UI uses ---------------------------------------------
def state(self): def state(self):
@@ -80,7 +82,8 @@ class DemoController:
# 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": "2008", "image": None, "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, if self.avr.current_input()["code"] == self.cfg.ZIDOO_INPUT_CODE else None,
}, },
"heos_ok": True, "heos_ok": True,
@@ -125,6 +128,14 @@ class DemoController:
self._joined = {k for k in self.cfg.ROOM_KEYS if k in joined} self._joined = {k for k in self.cfg.ROOM_KEYS if k in joined}
return self.joined_keys() 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): def zidoo_seek(self, position_ms):
self._film_ms = min(596000, int(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; const POLL_MS = 5000;
// How long a dropped cursor waits for the player to report it got there. // How long a dropped cursor waits for the player to report it got there.
const SEEK_HOLD_MS = 10000; 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. // Motion turned down in the OS means cards change places without sliding.
const CALM = matchMedia('(prefers-reduced-motion: reduce)'); const CALM = matchMedia('(prefers-reduced-motion: reduce)');
@@ -63,45 +66,19 @@ const ui = {
app: el('.app'), app: el('.app'),
avr: el('.card.avr'), avr: el('.card.avr'),
joined: el('[data-role="joined"]'), 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, // The AVR is one of the rooms below, so its key comes off its card like
// progress} refs, so renderTrack() below works for both -- a device plugged // theirs do. Which one it is, everything that only it can do goes through.
// into the AVR (a Zidoo, say) is "now playing" the same way a room's own const HOST = ui.avr.dataset.room;
// 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);
// Same as a room's card: named, so a view transition can follow it when it // Named, so a view transition can follow the card when it moves to the top
// moves to the top and back. // and back. A room's name is set as it is placed; the host's never moves.
ui.avr.style.viewTransitionName = 'avr'; ui.avr.style.viewTransitionName = 'avr';
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) {
@@ -126,11 +103,12 @@ function toast(message, kind = 'error') {
} }
/* --- rooms ------------------------------------------------------------ */ /* --- rooms ------------------------------------------------------------ */
els('.room').forEach((node) => { els('.card[data-room]').forEach((node) => {
const key = node.dataset.room; const key = node.dataset.room;
const room = { const room = {
key, key,
node, node,
avr: node.classList.contains('avr'),
level: el('[data-role="level"]', node), level: el('[data-role="level"]', node),
meter: el('[data-role="meter"]', node), meter: el('[data-role="meter"]', node),
actions: el('[data-role="actions"]', node), actions: el('[data-role="actions"]', node),
@@ -159,10 +137,12 @@ els('.room').forEach((node) => {
inflight: false, inflight: false,
busy: false, // a grouping change is in flight busy: false, // a grouping change is in flight
playBusy: false, 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 // HEOS cannot seek, so only a stream Spotify can be asked to seek in: the
// same one the transport buttons show for (see paintRoom). // same one the transport buttons show for (see paintTransport). Only a
canSeek: () => room.onSpotify && room.spotifyAccount && !room.grouped, // Zidoo ever fills the AVR's card in, and it can always seek.
playing: () => room.playState === 'play', 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 seek: null, // {ms, stage, ...} from a drag of the cursor until the player catches up
}; };
rooms[key] = room; rooms[key] = room;
@@ -171,7 +151,7 @@ els('.room').forEach((node) => {
const direction = Number(button.dataset.delta); const direction = Number(button.dataset.delta);
holdable(button, () => nudge(key, direction)); holdable(button, () => nudge(key, direction));
}); });
draggable(room); if (room.meter) draggable(room);
seekable(room); seekable(room);
// A cover that will not load (gone, or plain http on an https page) is // 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. // the next poll does not try it again until the track changes.
room.cover.addEventListener('error', () => { room.cover.hidden = true; }); room.cover.addEventListener('error', () => { room.cover.hidden = true; });
room.toggle.addEventListener('click', () => setGrouped(key, !room.grouped)); if (room.toggle) room.toggle.addEventListener('click', () => setGrouped(key, !room.grouped));
room.prev.addEventListener('click', () => skipTrack(key, 'previous')); if (room.prev) room.prev.addEventListener('click', () => skipTrack(key, 'previous'));
room.play.addEventListener('click', () => togglePlay(key)); room.play.addEventListener('click', () => togglePlay(key));
room.next.addEventListener('click', () => skipTrack(key, '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; const known = room.volume !== null && room.volume !== undefined;
room.level.textContent = known ? room.volume : '—'; room.level.textContent = known ? room.volume : '—';
// The fill and the knob riding it both size themselves off this. // The fill and the knob riding it both size themselves off this.
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.steps.forEach((button) => { button.disabled = !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.node.classList.toggle('playing', playing);
room.play.classList.toggle('playing', playing); room.play.classList.toggle('playing', playing);
room.play.disabled = !room.available || room.playState === null; room.play.disabled = !room.available || !state;
room.play.setAttribute( room.play.setAttribute(
'aria-label', `${room.node.querySelector('h2').textContent}: ${playing ? 'pause' : 'play'}`); 'aria-label', `${room.node.querySelector('h2').textContent}: ${playing ? 'pause' : 'play'}`);
// Previous, play/pause and next only mean something for a Spotify stream: room.actions.hidden = room.avr
// an AVR input has no queue to pause or skip, and starting Spotify is what ? !state
// the account buttons are for. Grouped, transport belongs to the AVR's : !(room.onSpotify && room.spotifyAccount && !room.grouped);
// card, not this one -- pressing them here would still work (it shares if (room.prev) room.prev.disabled = !room.available;
// the group's transport) but only invites confusion about which card is if (room.next) room.next.disabled = !room.available;
// 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. /* The Spotify buttons and the host toggle, which the AVR's card has not got:
room.actions.hidden = !(room.onSpotify && room.spotifyAccount && !room.grouped); those resume an account in a room, or join it to the AVR, and neither is
room.prev.disabled = !room.available; something the AVR does to itself. */
room.next.disabled = !room.available; function paintSources(room) {
if (!room.sources) return;
// Grouped, the room plays whatever the AVR does, so resuming Spotify on it // 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 // 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 // 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, // 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. // where the card sits already says so, so the button says what it does.
room.toggleLabel.textContent = room.grouped ? 'Ungroup' : 'Cinema'; 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); placeRoom(room);
placeCards(); 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) { function renderProgress(track, refs) {
refs.shown = null; refs.shown = null;
refs.tick = null; refs.tick = null;
@@ -305,12 +290,6 @@ function paintProgress(refs, known, position, duration) {
el('[data-role="progress-total"]', progress).textContent = known ? clock(total) : ''; 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) { function tickProgress(refs) {
if (!refs.tick || !refs.shown || refs.seek || !refs.playing()) return; if (!refs.tick || !refs.shown || refs.seek || !refs.playing()) return;
const { ms, at, duration } = refs.tick; const { ms, at, duration } = refs.tick;
@@ -318,17 +297,8 @@ function tickProgress(refs) {
paintProgress(refs, true, position, duration); paintProgress(refs, true, position, duration);
} }
setInterval(() => { setInterval(() => Object.values(rooms).forEach(tickProgress), 250);
tickProgress(avrTrack);
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) { function seekable(refs) {
const { progress } = refs; const { progress } = refs;
const line = el('.progress-line', progress); const line = el('.progress-line', progress);
@@ -384,7 +354,7 @@ async function sendSeek(refs) {
seek.stage = 'send'; seek.stage = 'send';
seek.sentAt = Date.now(); seek.sentAt = Date.now();
try { 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'; seek.stage = 'hold';
// The player takes a moment to report where it has got to. // The player takes a moment to report where it has got to.
setTimeout(refresh, 1500); setTimeout(refresh, 1500);
@@ -396,13 +366,6 @@ async function sendSeek(refs) {
if (refs.shown) renderProgress(refs.shown, 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) { function seekPosition(track, refs) {
const { seek } = refs; const { seek } = refs;
if (!seek) return track.position_ms; if (!seek) return track.position_ms;
@@ -417,8 +380,6 @@ function seekPosition(track, refs) {
return seek.ms; 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) { function seeking(refs) {
return Boolean(refs.seek) && refs.seek.stage !== 'hold'; 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}`; 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) { 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.grouped) {
if (room.node.parentElement !== ui.joined) ui.joined.appendChild(room.node); if (room.node.parentElement !== ui.joined) ui.joined.appendChild(room.node);
} else if (room.node.parentElement !== ui.app) { } 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. // 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. // 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')) const playing = inside.some((node) => node.classList.contains('playing'))
|| avrNowPlaying?.play_state === 'play'; || shownPlayState(rooms[HOST]) === 'play';
ui.avr.classList.toggle('playing', playing); ui.avr.classList.toggle('playing', playing);
placeCards(); 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; let placing = false;
function placeCards() { function placeCards() {
if (placing) return; 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 // 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 // that moves up. The AVR's tint and its position answer the same
// question, and paintGrouping() has just settled it. // question, and paintGrouping() has just settled it.
const wanted = Object.values(rooms) const wanted = Object.values(rooms).map((room) => [
.map((room) => [room.node, room.playing() && !room.grouped]); room.node,
wanted.push([ui.avr, ui.avr.classList.contains('playing')]); // 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 move = () => wanted.forEach(([node, top]) => node.classList.toggle('on-top', top));
const moves = wanted.some(([node, top]) => node.classList.contains('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)); ['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) { function nextLevel(current, direction) {
if (direction > 0) return Math.min(100, (Math.floor(current / STEP) + 1) * STEP); if (direction > 0) return Math.min(100, (Math.floor(current / STEP) + 1) * STEP);
const aligned = Math.floor(current / STEP) * 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) { function draggable(room) {
const knob = room.level; const knob = room.level;
let startX = 0; 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 function shownPlayState(room) {
the room is doing cannot flip it the wrong way. */ 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) { async function togglePlay(key) {
const room = rooms[key]; const room = rooms[key];
if (!room.available || room.playBusy) return; const state = shownPlayState(room);
const wanted = room.playState === 'play' ? 'pause' : 'play'; if (!room.available || !state || room.playBusy) return;
const wanted = state === 'play' ? 'pause' : 'play';
room.playBusy = true; room.playBusy = true;
room.playState = wanted; room.wantedPlay = { state: wanted, sentAt: Date.now() };
paintRoom(room); paintRoom(room);
paintGrouping();
try { try {
const data = await api('/api/playback', { target: key, state: wanted }); 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) { } catch (error) {
toast(error.message); toast(error.message);
room.wantedPlay = null;
} finally { } finally {
room.playBusy = false; room.playBusy = false;
paintRoom(room); 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 ------------------------------------------------------------- */ /* --- 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) { function render(state) {
(state.rooms || []).forEach((incoming) => { (state.rooms || []).forEach((incoming) => applyRoom(rooms[incoming.key], 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);
});
const avr = state.avr || {}; const avr = state.avr || {};
inputs = avr.inputs || []; inputs = avr.inputs || [];
currentInput = avr.input || null; currentInput = avr.input || null;
ui.inputName.textContent = currentInput ? currentInput.name : '—'; ui.inputName.textContent = currentInput ? currentInput.name : '—';
setInputIcon(currentInput ? currentInput.code : null); setInputIcon(currentInput ? currentInput.code : null);
avrNowPlaying = avr.now_playing || null; // Whatever is on one of its inputs is what the AVR is playing, and its own
renderTrack(avrNowPlaying, avrTrack); // play state is that device's -- HEOS has none to give for an input.
paintGrouping(); // after avrNowPlaying, which decides where the AVR's card sits 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 || []; 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' : '');
@@ -839,7 +813,6 @@ async function refresh() {
function busy() { function busy() {
return sheetOpen() return sheetOpen()
|| seeking(avrTrack)
|| Object.values(rooms).some((r) => r.taps || r.inflight || r.dragging || r.busy || r.playBusy || seeking(r)); || 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 */ /* Header */
.top { order: -2; display: flex; align-items: center; justify-content: center; } header { order: -2; display: flex; align-items: center; justify-content: center; }
.top .logo { height: 44px; width: auto; display: block; margin: var(--space-xs); } header .logo { height: 44px; width: auto; display: block; margin: var(--space-xs); }
/* Card */ /* Card */
+75 -82
View File
@@ -20,6 +20,16 @@
</head> </head>
<body> <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() -%} {% macro host_toggle() -%}
<button class="sources-btn toggle" data-role="group" aria-pressed="false"> <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> <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> </div>
{%- endmacro %} {%- endmacro %}
<div class="app"> {% macro room_card(room, accounts=()) -%}
<header class="top"> {% set avr = room.kind == 'avr' %}
<img class="logo" src="{{ url_for('static', filename='logo.svg') }}" alt="{{ app_name }}"> <section class="card {{ 'avr' if avr else 'room' }}" data-room="{{ room.key }}">
</header> <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 %} <div class="media" data-role="media" hidden>
<section class="card room" data-room="{{ room.key }}"> <div class="now-playing">
<div class="card-head"> <img class="cover{{ ' poster' if avr }}" data-role="cover" alt="">
<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="track"> <div class="track">
<span class="song" data-role="song"></span> <span class="song" data-role="song"></span>
<span class="artist" data-role="artist"></span> <span class="artist" data-role="artist"></span>
</div> </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>
<div class="progress" data-role="progress">
<div class="actions" data-role="actions" hidden> <div class="progress-line">
<button class="control-btn" data-role="prev" aria-label="{{ room.label }}: previous track"> <i data-role="progress-fill"></i>
<!-- backward-step: the forward-step glyph below, mirrored --> <b class="progress-cursor" data-role="progress-cursor"></b>
<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> <span class="progress-time elapsed" data-role="progress-elapsed"></span>
</button> <span class="progress-time remaining" data-role="progress-remaining"></span>
<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> </div>
<span class="progress-total" data-role="progress-total"></span>
</div> </div>
</div>
<button class="input-select" data-role="input-button"> {% if not avr %}
<span class="eyebrow">Input</span> <div class="volume">
<svg class="input-icon" data-role="input-icon" viewBox="0 0 512 512" aria-hidden="true"><path d=""/></svg> <button class="control-btn step" data-delta="-1" aria-label="{{ room.label }}: volume down">
<span class="value" data-role="input-name">—</span> <svg viewBox="0 0 448 512" aria-hidden="true"><path d="M432 256c0 17.7-14.3 32-32 32L48 288c-17.7 0-32-14.3-32-32s14.3-32 32-32l352 0c17.7 0 32 14.3 32 32z"/></svg>
<svg 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="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> <div class="actions" data-role="actions" hidden>
</section> {% 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> <footer data-role="foot"></footer>
</div> </div>
+81
View File
@@ -10,10 +10,12 @@ import threading
import unittest import unittest
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
from types import SimpleNamespace
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from controller import Controller, TargetError # noqa: E402
from zidoo import ZidooClient, ZidooError # noqa: E402 from zidoo import ZidooClient, ZidooError # noqa: E402
PATH = "/storage/disk/Movies/A.Private.War.2018.1080p.mp4" PATH = "/storage/disk/Movies/A.Private.War.2018.1080p.mp4"
@@ -26,6 +28,8 @@ class FakeZidoo(BaseHTTPRequestHandler):
posters = {} # poster id -> image bytes posters = {} # poster id -> image bytes
lookups = [] # paths getAggregationOfFile was asked about lookups = [] # paths getAggregationOfFile was asked about
seeks = [] # every seekTo's position 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): def do_GET(self):
url = urlparse(self.path) url = urlparse(self.path)
@@ -39,6 +43,14 @@ class FakeZidoo(BaseHTTPRequestHandler):
return self._send(404, b"") return self._send(404, b"")
self.seeks.append(int(query["positon"])) # sic, as the real one spells it self.seeks.append(int(query["positon"])) # sic, as the real one spells it
return self._json({"status": 200}) 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": if url.path == "/ZidooPoster/v2/getAggregationOfFile":
self.lookups.append(query["path"]) self.lookups.append(query["path"])
return self._json(self.library.get(query["path"], {"status": 804, "msg": "Error!!!"})) 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.posters = {108: PNG}
FakeZidoo.lookups = [] FakeZidoo.lookups = []
FakeZidoo.seeks = [] FakeZidoo.seeks = []
FakeZidoo.keys = []
FakeZidoo.accepts = {"Key.MediaPlay", "Key.MediaPause"}
self.server = ThreadingHTTPServer(("127.0.0.1", 0), FakeZidoo) self.server = ThreadingHTTPServer(("127.0.0.1", 0), FakeZidoo)
threading.Thread(target=self.server.serve_forever, args=(0.05,), daemon=True).start() 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]) self.zidoo = ZidooClient("127.0.0.1", self.server.server_address[1])
@@ -111,6 +125,29 @@ class ZidooTest(unittest.TestCase):
with self.assertRaises(ZidooError): with self.assertRaises(ZidooError):
self.zidoo.seek(3600000) 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): def test_poster_is_sniffed_from_its_bytes(self):
self.assertEqual(self.zidoo.poster(108), (PNG, "image/png")) self.assertEqual(self.zidoo.poster(108), (PNG, "image/png"))
@@ -118,5 +155,49 @@ class ZidooTest(unittest.TestCase):
self.assertIsNone(self.zidoo.poster(2248)) 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__": if __name__ == "__main__":
unittest.main() unittest.main()
+20
View File
@@ -22,6 +22,13 @@ class ZidooError(RuntimeError):
class ZidooClient: 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): 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
@@ -70,6 +77,19 @@ class ZidooClient:
track["duration_ms"] = video["duration"] track["duration_ms"] = video["duration"]
return track 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): def seek(self, position_ms):
"""Jump the loaded video to position_ms. Unlike now_playing(), this """Jump the loaded video to position_ms. Unlike now_playing(), this
is something someone asked for, so a Zidoo that does not answer is is something someone asked for, so a Zidoo that does not answer is