diff --git a/README.md b/README.md index bf7a51b..3de0ce2 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,7 @@ Used by the interface: | `POST /api/mute` | `{"target": "lego_room"}` | | `POST /api/playback` | `{"target": "lego_room", "state": "pause"}`, or no `state` to toggle | | `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}` | | `POST /api/group/none` | every room back on its own | | `GET /api/avr/inputs` | your renamed sources, over HEOS | diff --git a/app.py b/app.py index da20a91..d1c85a7 100644 --- a/app.py +++ b/app.py @@ -24,6 +24,7 @@ import config from controller import Controller, TargetError from heos import HeosError from spotify import SpotifyClient, SpotifyError +from zidoo import ZidooError app = Flask(__name__) @@ -80,7 +81,7 @@ def handle_errors(view): return view(*args, **kwargs) except (TargetError, ValueError) as exc: return jsonify({"error": str(exc)}), 400 - except (HeosError, SpotifyError) as exc: + except (HeosError, SpotifyError, ZidooError) as exc: return jsonify({"error": str(exc)}), 502 return wrapped @@ -122,15 +123,11 @@ def _spotify_name(key: str) -> str: return target.get("spotify_name", target["heos_name"]) -def _mark_spotify_accounts(rooms: list): - """Tag each room HEOS says is on Spotify with the account playing it, so - its card can pick out that account's button. Spotify is only asked when - some room is on Spotify at all, and an account it refuses (a revoked - token, say) just matches nothing instead of failing the whole poll.""" - on_spotify = [room for room in rooms if room.get("spotify")] - if not (spotify and on_spotify): - return - playing_on = {} # device name -> account key +def _spotify_playing_on() -> dict: + """Device name -> the key of the account playing on it. An account + Spotify refuses (a revoked token, say) just plays on nothing, instead of + failing whatever asked.""" + playing_on = {} for account, client in spotify.items(): try: player = client.playback() @@ -140,6 +137,17 @@ def _mark_spotify_accounts(rooms: list): # Should two accounts both claim a device, the one actually playing wins. if device and (device not in playing_on or player.get("is_playing")): playing_on[device] = account + return playing_on + + +def _mark_spotify_accounts(rooms: list): + """Tag each room HEOS says is on Spotify with the account playing it, so + its card can pick out that account's button. Spotify is only asked when + some room is on Spotify at all.""" + on_spotify = [room for room in rooms if room.get("spotify")] + if not (spotify and on_spotify): + return + playing_on = _spotify_playing_on() for room in on_spotify: room["spotify_account"] = playing_on.get(_spotify_name(room["key"])) @@ -274,6 +282,27 @@ def api_skip(): return jsonify({"target": key, "direction": direction}) +@app.post("/api/seek") +@handle_errors +def api_seek(): + """Jump to a point in what a card is playing. HEOS itself cannot seek, + so this goes around it: to the Zidoo for the AVR's card, and for a room, + to Spotify, through whichever of your accounts is playing there.""" + data = _payload() + key = _target_from(data) + if data.get("position_ms") is None: + raise ValueError("Provide 'position_ms', where to jump to") + position = max(0, int(data["position_ms"])) + if key == config.HOST_KEY: + controller.zidoo_seek(position) + else: + account = _spotify_playing_on().get(_spotify_name(key)) + if account is None: + raise ValueError("Only a Spotify stream one of your accounts is playing can seek -- HEOS itself cannot") + spotify[account].seek(position) + return jsonify({"target": key, "position_ms": position}) + + @app.post("/api/group") @handle_errors def api_group(): diff --git a/controller.py b/controller.py index 399a220..15d0185 100644 --- a/controller.py +++ b/controller.py @@ -418,6 +418,13 @@ class Controller: return None return self.zidoo.now_playing() + def zidoo_seek(self, position_ms: int): + """Jump the Zidoo's film -- the AVR card's now-playing -- to + position_ms.""" + if not self.zidoo: + raise TargetError("No Zidoo is configured to seek in") + self.zidoo.seek(position_ms) + # -- one snapshot for the UI ------------------------------------------ def state(self) -> dict: snapshot = { diff --git a/demo.py b/demo.py index 488e97b..ae64ca2 100644 --- a/demo.py +++ b/demo.py @@ -55,6 +55,7 @@ class DemoController: self._track = {"song": "Harvest Moon", "artist": "Neil Young", "image": None} # 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 # -- what the UI uses --------------------------------------------- def state(self): @@ -74,7 +75,7 @@ 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": 241000, "duration_ms": 596000} + "play_state": "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, @@ -119,6 +120,9 @@ class DemoController: self._joined = {k for k in self.cfg.ROOM_KEYS if k in joined} return self.joined_keys() + def zidoo_seek(self, position_ms): + self._film_ms = min(596000, int(position_ms)) + def joined_keys(self): return [k for k in self.cfg.ROOM_KEYS if k in self._joined] diff --git a/spotify.py b/spotify.py index 8a372be..abc94b9 100644 --- a/spotify.py +++ b/spotify.py @@ -90,7 +90,12 @@ class SpotifyClient: try: with urllib.request.urlopen(request, timeout=self.timeout) as response: raw = response.read() + # A player command (seek, say) can answer 200 with a body that is + # not JSON at all -- it worked, there is just nothing to read. + try: return json.loads(raw) if raw else {} + except ValueError: + return {} except urllib.error.HTTPError as exc: if exc.code == 401 and not retrying: # The access token can go stale between calls even inside @@ -135,6 +140,11 @@ class SpotifyClient: self._call("PUT", "/v1/me/player", body={"device_ids": [device["id"]], "play": True}) return device + def seek(self, position_ms: int): + """Jump to position_ms in whatever the account is playing, on + whichever device is playing it.""" + self._call("PUT", f"/v1/me/player/seek?position_ms={int(position_ms)}") + def _error_detail(exc: urllib.error.HTTPError) -> str: try: diff --git a/static/app.js b/static/app.js index d9bc879..0cbfec5 100644 --- a/static/app.js +++ b/static/app.js @@ -6,6 +6,8 @@ const STEP = Number(document.documentElement.dataset.step) || 2; // Where the app is mounted: "/" on its own port, "/heos/" behind a proxy. 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; const el = (sel, root = document) => root.querySelector(sel); const els = (sel, root = document) => Array.from(root.querySelectorAll(sel)); @@ -77,10 +79,14 @@ const avrTrack = { 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, + 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); const rooms = {}; let inputs = []; @@ -144,6 +150,10 @@ els('.room').forEach((node) => { inflight: false, busy: false, // a grouping change is in flight playBusy: false, + // 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, + seek: null, // {ms, stage, ...} from a drag of the cursor until the player catches up }; rooms[key] = room; @@ -152,6 +162,7 @@ els('.room').forEach((node) => { holdable(button, () => nudge(key, direction)); }); draggable(room); + seekable(room); // A cover that will not load (gone, or plain http on an https page) is // dropped rather than left as a broken-image box. Its src stays put, so @@ -213,7 +224,7 @@ function paintRoom(room) { cover's src is only touched when the track changes, so a poll never makes it flicker. */ function renderTrack(track, refs) { - renderProgress(track, refs.progress); + renderProgress(track, refs); refs.nowPlaying.hidden = !track; if (!track) return; refs.song.textContent = track.song; @@ -231,15 +242,147 @@ 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. */ -function renderProgress(track, progress) { + 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. */ +function renderProgress(track, refs) { + const { progress } = refs; progress.hidden = !track; + refs.shown = null; if (!track) return; - const { position_ms: position, duration_ms: duration } = track; + const { duration_ms: duration } = track; + const position = seekPosition(track, refs); const known = typeof duration === 'number' && duration > 0 && typeof position === 'number'; const percent = known ? Math.min(100, Math.max(0, (position / duration) * 100)) : 0; + if (known) { + refs.shown = track; + refs.shownMs = position; + } progress.classList.toggle('known', known); + progress.classList.toggle('seekable', known && Boolean(refs.canSeek())); progress.style.setProperty('--progress', percent); + // Whole seconds on both sides, so elapsed and remaining always add up to + // the total shown at the end of the line. + const total = known ? Math.floor(duration / 1000) : 0; + const elapsed = known ? Math.min(total, Math.max(0, Math.floor(position / 1000))) : 0; + const labels = { + elapsed: known ? '+' + clock(elapsed) : '', + remaining: known ? '−' + clock(total - elapsed) : '', + }; + Object.entries(labels).forEach(([role, text]) => { + const label = el(`[data-role="progress-${role}"]`, progress); + label.textContent = text; + // Read back once the new text is in, for panel.css to clamp it by. + if (known) label.style.setProperty('--half', `${label.offsetWidth / 2}px`); + }); + el('[data-role="progress-total"]', progress).textContent = known ? clock(total) : ''; +} + +/* 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); + let startX = 0; + let startMs = 0; + let moved = false; + + let held = null; // a previous seek still waiting on the player, back if this drag goes nowhere + + line.addEventListener('pointerdown', (event) => { + if (event.button > 0 || seeking(refs) || !refs.shown || !progress.classList.contains('seekable')) return; + if (!event.target.closest('.progress-cursor, .progress-time')) return; + event.preventDefault(); + line.setPointerCapture(event.pointerId); + startX = event.clientX; + startMs = refs.shownMs; // where the cursor is, even if the player has not caught up with it yet + moved = false; + held = refs.seek; + refs.seek = { ms: startMs, stage: 'drag', song: refs.shown.song }; + progress.classList.add('dragging'); + }); + + line.addEventListener('pointermove', (event) => { + if (!refs.seek || refs.seek.stage !== 'drag' || !refs.shown) return; + const dx = event.clientX - startX; + // A few pixels of slack, so a tap that wobbles is still just a tap. + if (!moved && Math.abs(dx) < 6) return; + moved = true; + const { duration_ms: duration } = refs.shown; + const ms = startMs + (dx / line.clientWidth) * duration; + refs.seek.ms = Math.round(Math.max(0, Math.min(duration, ms))); + renderProgress(refs.shown, refs); + }); + + const stop = (event) => { + if (!refs.seek || refs.seek.stage !== 'drag') return; + progress.classList.remove('dragging'); + if (event.type === 'pointerup' && moved) { + sendSeek(refs); + } else { + refs.seek = held; + if (refs.shown) renderProgress(refs.shown, refs); + } + }; + line.addEventListener('pointerup', stop); + line.addEventListener('pointercancel', stop); +} + +/* A seek that fails snaps the cursor back; one that works holds it where it + was dropped (see seekPosition). */ +async function sendSeek(refs) { + const { seek } = refs; + seek.stage = 'send'; + seek.sentAt = Date.now(); + try { + await api('/api/seek', { target: refs.progress.dataset.target, position_ms: seek.ms }); + seek.stage = 'hold'; + // The player takes a moment to report where it has got to. + setTimeout(refresh, 1500); + } catch (error) { + toast(error.message); + refs.seek = null; + } + // Unless a poll in the meantime found nothing left to seek in. + 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; + if (seek.stage !== 'hold') return seek.ms; + const since = Date.now() - seek.sentAt; + const slack = 2000; + const caughtUp = track.position_ms >= seek.ms - slack && track.position_ms <= seek.ms + since + slack; + if (caughtUp || track.song !== seek.song || since > SEEK_HOLD_MS) { + refs.seek = null; + return track.position_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) { + return Boolean(refs.seek) && refs.seek.stage !== 'hold'; +} + +/* 83 -> "1:23", 4000 -> "1:06:40": hours only for what runs that long. */ +function clock(seconds) { + const h = Math.floor(seconds / 3600); + const m = Math.floor(seconds / 60) % 60; + const s = String(seconds % 60).padStart(2, '0'); + 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 @@ -572,7 +715,8 @@ async function refresh() { function busy() { return sheetOpen() - || Object.values(rooms).some((r) => r.taps || r.inflight || r.dragging || r.busy || r.playBusy); + || seeking(avrTrack) + || Object.values(rooms).some((r) => r.taps || r.inflight || r.dragging || r.busy || r.playBusy || seeking(r)); } ui.refresh.addEventListener('click', () => { diff --git a/static/panel.css b/static/panel.css index a7ecbbd..06b1857 100644 --- a/static/panel.css +++ b/static/panel.css @@ -105,7 +105,7 @@ svg { width: 22px; height: 22px; fill: currentColor; } margin: 0; min-width: 0; font-size: 17px; font-weight: 600; } -.card-head h2 .room-icon { flex: 0 0 auto; height: 20px; width: auto; fill: currentColor; color: var(--muted); } +.card-head h2 .room-icon { flex: 0 0 auto; height: 20px; width: auto; fill: currentColor; } .card-head h2 .room-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .pill { @@ -138,7 +138,8 @@ svg { width: 22px; height: 22px; fill: currentColor; } block, when nothing is playing. Muted throughout, so it never competes with the volume meter below it; the cursor only shows once a position is actually known (an AVR input or a stream with no duration never gets one). */ -.progress { position: relative; height: 3px; border-radius: 999px; background: #0c111b; } +.progress { display: flex; align-items: center; gap: 10px; } +.progress-line { position: relative; flex: 1; height: 3px; border-radius: 999px; background: #0c111b; } .progress-fill { display: block; height: 100%; width: calc(var(--progress, 0) * 1%); border-radius: 999px; background: var(--muted); @@ -153,7 +154,40 @@ svg { width: 22px; height: 22px; fill: currentColor; } transform: translateY(-50%); transition: left .12s ease-out; } -.progress.known .progress-cursor { display: block; } +/* Elapsed rides above the cursor and remaining below it, all three centred + on one axis, and the total waits at the end of the line. Near either end a + label stops half its own width (--half, measured in app.js) short of it, + so it never hangs off the card. */ +.progress-time, +.progress-total { + display: none; + font-size: 12px; line-height: 1; color: var(--muted); + font-variant-numeric: tabular-nums; white-space: nowrap; +} +.progress-time { + position: absolute; + left: clamp(var(--half, 0px), calc(var(--progress, 0) * 1%), calc(100% - var(--half, 0px))); + transform: translateX(-50%); + transition: left .12s ease-out; +} +.progress-time.elapsed { bottom: 9px; } +.progress-time.remaining { top: 9px; } +.progress.known { padding: 16px 0; } /* room for the labels above and below the cursor */ +.progress.known .progress-cursor, +.progress.known .progress-time, +.progress.known .progress-total { display: block; } + +/* Where the player can seek, the cursor and both times are one handle, with + a thumb-sized grip around a 7px dot. pan-y leaves a vertical swipe to the + page, so scrolling past never seeks; only a sideways drag is ours. Held, + it lights up and follows the finger without easing after it. */ +.progress.seekable .progress-cursor, +.progress.seekable .progress-time { cursor: grab; touch-action: pan-y; } +.progress.seekable .progress-cursor::before { content: ''; position: absolute; inset: -16px -14px; } +.progress.dragging .progress-cursor, +.progress.dragging .progress-time { transition: none; cursor: grabbing; } +.progress.dragging .progress-cursor { background: var(--ink); transform: translateY(-50%) scale(1.6); } +.progress.dragging .progress-time { color: var(--ink); } /* --- volume ---------------------------------------------------------- */ .volume { display: flex; align-items: center; gap: 14px; } @@ -262,7 +296,7 @@ svg { width: 22px; height: 22px; fill: currentColor; } /* --- source card ------------------------------------------------------ */ .source-button { display: flex; align-items: center; gap: 12px; - width: 100%; min-height: 64px; + width: 100%; min-height: 50px; padding: 10px 14px; border-radius: 16px; background: var(--raised); diff --git a/templates/index.html b/templates/index.html index b852346..42427e1 100644 --- a/templates/index.html +++ b/templates/index.html @@ -67,9 +67,14 @@ -