diff --git a/README.md b/README.md index da834f4..c2aaffc 100644 --- a/README.md +++ b/README.md @@ -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}` | diff --git a/app.py b/app.py index 119af1d..ef427c9 100644 --- a/app.py +++ b/app.py @@ -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)}) diff --git a/controller.py b/controller.py index d82e0d5..dc7321e 100644 --- a/controller.py +++ b/controller.py @@ -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.""" diff --git a/demo.py b/demo.py index 454b83c..90cc21d 100644 --- a/demo.py +++ b/demo.py @@ -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)) diff --git a/static/app.js b/static/app.js index d19a54e..9a55ad8 100644 --- a/static/app.js +++ b/static/app.js @@ -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)); } diff --git a/static/panel.css b/static/panel.css index 4aec021..b98ce83 100644 --- a/static/panel.css +++ b/static/panel.css @@ -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 */ diff --git a/templates/index.html b/templates/index.html index 983f5e1..679870a 100644 --- a/templates/index.html +++ b/templates/index.html @@ -20,6 +20,16 @@
+{# 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) -%} + +{%- endmacro %} + {% macro host_toggle() -%}