Make progress bar cursor draggable
Deploy HEOS panel / deploy (push) Successful in 26s

This commit is contained in:
2026-09-16 23:19:08 +02:00
parent 51dff45b54
commit ec684b7803
12 changed files with 319 additions and 29 deletions
+1
View File
@@ -140,6 +140,7 @@ Used by the interface:
| `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 |
| `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/group` | `{"target": "lego_room", "joined": true}` | | `POST /api/group` | `{"target": "lego_room", "joined": true}` |
| `POST /api/group/none` | every room back on its own | | `POST /api/group/none` | every room back on its own |
| `GET /api/avr/inputs` | your renamed sources, over HEOS | | `GET /api/avr/inputs` | your renamed sources, over HEOS |
+39 -10
View File
@@ -24,6 +24,7 @@ import config
from controller import Controller, TargetError from controller import Controller, TargetError
from heos import HeosError from heos import HeosError
from spotify import SpotifyClient, SpotifyError from spotify import SpotifyClient, SpotifyError
from zidoo import ZidooError
app = Flask(__name__) app = Flask(__name__)
@@ -80,7 +81,7 @@ def handle_errors(view):
return view(*args, **kwargs) return view(*args, **kwargs)
except (TargetError, ValueError) as exc: except (TargetError, ValueError) as exc:
return jsonify({"error": str(exc)}), 400 return jsonify({"error": str(exc)}), 400
except (HeosError, SpotifyError) as exc: except (HeosError, SpotifyError, ZidooError) as exc:
return jsonify({"error": str(exc)}), 502 return jsonify({"error": str(exc)}), 502
return wrapped return wrapped
@@ -122,15 +123,11 @@ def _spotify_name(key: str) -> str:
return target.get("spotify_name", target["heos_name"]) return target.get("spotify_name", target["heos_name"])
def _mark_spotify_accounts(rooms: list): def _spotify_playing_on() -> dict:
"""Tag each room HEOS says is on Spotify with the account playing it, so """Device name -> the key of the account playing on it. An account
its card can pick out that account's button. Spotify is only asked when Spotify refuses (a revoked token, say) just plays on nothing, instead of
some room is on Spotify at all, and an account it refuses (a revoked failing whatever asked."""
token, say) just matches nothing instead of failing the whole poll.""" playing_on = {}
on_spotify = [room for room in rooms if room.get("spotify")]
if not (spotify and on_spotify):
return
playing_on = {} # device name -> account key
for account, client in spotify.items(): for account, client in spotify.items():
try: try:
player = client.playback() 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. # 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")): if device and (device not in playing_on or player.get("is_playing")):
playing_on[device] = account 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: for room in on_spotify:
room["spotify_account"] = playing_on.get(_spotify_name(room["key"])) room["spotify_account"] = playing_on.get(_spotify_name(room["key"]))
@@ -274,6 +282,27 @@ def api_skip():
return jsonify({"target": key, "direction": direction}) 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") @app.post("/api/group")
@handle_errors @handle_errors
def api_group(): def api_group():
+7
View File
@@ -418,6 +418,13 @@ class Controller:
return None return None
return self.zidoo.now_playing() 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 ------------------------------------------ # -- one snapshot for the UI ------------------------------------------
def state(self) -> dict: def state(self) -> dict:
snapshot = { snapshot = {
+5 -1
View File
@@ -55,6 +55,7 @@ class DemoController:
self._track = {"song": "Harvest Moon", "artist": "Neil Young", "image": None} self._track = {"song": "Harvest Moon", "artist": "Neil Young", "image": None}
# 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
# -- what the UI uses --------------------------------------------- # -- what the UI uses ---------------------------------------------
def state(self): def state(self):
@@ -74,7 +75,7 @@ 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": 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, if self.avr.current_input()["code"] == self.cfg.ZIDOO_INPUT_CODE else None,
}, },
"heos_ok": True, "heos_ok": True,
@@ -119,6 +120,9 @@ 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_seek(self, position_ms):
self._film_ms = min(596000, int(position_ms))
def joined_keys(self): def joined_keys(self):
return [k for k in self.cfg.ROOM_KEYS if k in self._joined] return [k for k in self.cfg.ROOM_KEYS if k in self._joined]
+10
View File
@@ -90,7 +90,12 @@ class SpotifyClient:
try: try:
with urllib.request.urlopen(request, timeout=self.timeout) as response: with urllib.request.urlopen(request, timeout=self.timeout) as response:
raw = response.read() 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 {} return json.loads(raw) if raw else {}
except ValueError:
return {}
except urllib.error.HTTPError as exc: except urllib.error.HTTPError as exc:
if exc.code == 401 and not retrying: if exc.code == 401 and not retrying:
# The access token can go stale between calls even inside # 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}) self._call("PUT", "/v1/me/player", body={"device_ids": [device["id"]], "play": True})
return device 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: def _error_detail(exc: urllib.error.HTTPError) -> str:
try: try:
+149 -5
View File
@@ -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. // Where the app is mounted: "/" on its own port, "/heos/" behind a proxy.
const BASE = document.documentElement.dataset.base || '/'; 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.
const SEEK_HOLD_MS = 10000;
const el = (sel, root = document) => root.querySelector(sel); const el = (sel, root = document) => root.querySelector(sel);
const els = (sel, root = document) => Array.from(root.querySelectorAll(sel)); const els = (sel, root = document) => Array.from(root.querySelectorAll(sel));
@@ -77,10 +79,14 @@ const avrTrack = {
song: ui.avrSong, song: ui.avrSong,
artist: ui.avrArtist, artist: ui.avrArtist,
progress: ui.avrProgress, 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 // Same reasoning as a room's own cover: drop one that will not load rather
// than leave a broken-image box. // than leave a broken-image box.
avrTrack.cover.addEventListener('error', () => { avrTrack.cover.hidden = true; }); avrTrack.cover.addEventListener('error', () => { avrTrack.cover.hidden = true; });
seekable(avrTrack);
const rooms = {}; const rooms = {};
let inputs = []; let inputs = [];
@@ -144,6 +150,10 @@ 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,
// 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; rooms[key] = room;
@@ -152,6 +162,7 @@ els('.room').forEach((node) => {
holdable(button, () => nudge(key, direction)); holdable(button, () => nudge(key, direction));
}); });
draggable(room); draggable(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
// dropped rather than left as a broken-image box. Its src stays put, so // 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 cover's src is only touched when the track changes, so a poll never makes
it flicker. */ it flicker. */
function renderTrack(track, refs) { function renderTrack(track, refs) {
renderProgress(track, refs.progress); renderProgress(track, refs);
refs.nowPlaying.hidden = !track; refs.nowPlaying.hidden = !track;
if (!track) return; if (!track) return;
refs.song.textContent = track.song; 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 /* The discreet cursor on the line below the song, sized off the same
{position_ms, duration_ms} the poll hands back -- absent for anything {position_ms, duration_ms} the poll hands back -- absent for anything
HEOS never sends a progress event for (an AVR input, an internet radio 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. */ stream with no fixed length), in which case the line stays plain. Around
function renderProgress(track, progress) { a seek, the position seekPosition() picks shows instead of the one reported. */
function renderProgress(track, refs) {
const { progress } = refs;
progress.hidden = !track; progress.hidden = !track;
refs.shown = null;
if (!track) return; 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 known = typeof duration === 'number' && duration > 0 && typeof position === 'number';
const percent = known ? Math.min(100, Math.max(0, (position / duration) * 100)) : 0; 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('known', known);
progress.classList.toggle('seekable', known && Boolean(refs.canSeek()));
progress.style.setProperty('--progress', percent); 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 /* A merged room moves into the host's card, because that is what merging
@@ -572,7 +715,8 @@ async function refresh() {
function busy() { function busy() {
return sheetOpen() 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', () => { ui.refresh.addEventListener('click', () => {
+38 -4
View File
@@ -105,7 +105,7 @@ svg { width: 22px; height: 22px; fill: currentColor; }
margin: 0; min-width: 0; margin: 0; min-width: 0;
font-size: 17px; font-weight: 600; 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; } .card-head h2 .room-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pill { .pill {
@@ -138,7 +138,8 @@ svg { width: 22px; height: 22px; fill: currentColor; }
block, when nothing is playing. Muted throughout, so it never competes 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 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). */ 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 { .progress-fill {
display: block; height: 100%; width: calc(var(--progress, 0) * 1%); display: block; height: 100%; width: calc(var(--progress, 0) * 1%);
border-radius: 999px; background: var(--muted); border-radius: 999px; background: var(--muted);
@@ -153,7 +154,40 @@ svg { width: 22px; height: 22px; fill: currentColor; }
transform: translateY(-50%); transform: translateY(-50%);
transition: left .12s ease-out; 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 ---------------------------------------------------------- */
.volume { display: flex; align-items: center; gap: 14px; } .volume { display: flex; align-items: center; gap: 14px; }
@@ -262,7 +296,7 @@ svg { width: 22px; height: 22px; fill: currentColor; }
/* --- source card ------------------------------------------------------ */ /* --- source card ------------------------------------------------------ */
.source-button { .source-button {
display: flex; align-items: center; gap: 12px; display: flex; align-items: center; gap: 12px;
width: 100%; min-height: 64px; width: 100%; min-height: 50px;
padding: 10px 14px; padding: 10px 14px;
border-radius: 16px; border-radius: 16px;
background: var(--raised); background: var(--raised);
+16 -6
View File
@@ -67,9 +67,14 @@
</div> </div>
</div> </div>
<div class="progress" data-role="progress" hidden> <div class="progress" data-role="progress" data-target="{{ room.key }}" hidden>
<i data-role="progress-fill"></i> <div class="progress-line">
<b class="progress-cursor" data-role="progress-cursor"></b> <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"> <div class="volume">
@@ -116,9 +121,14 @@
</div> </div>
</div> </div>
<div class="progress" data-role="avr-progress" hidden> <div class="progress" data-role="avr-progress" data-target="{{ host.key }}" hidden>
<i data-role="progress-fill"></i> <div class="progress-line">
<b class="progress-cursor" data-role="progress-cursor"></b> <i data-role="progress-fill"></i>
<b class="progress-cursor" data-role="progress-cursor"></b>
<span class="progress-time elapsed" data-role="progress-elapsed"></span>
<span class="progress-time remaining" data-role="progress-remaining"></span>
</div>
<span class="progress-total" data-role="progress-total"></span>
</div> </div>
<button class="source-button" data-role="input-button"> <button class="source-button" data-role="input-button">
+19 -2
View File
@@ -1,10 +1,11 @@
"""Stand-in Spotify Web API: just enough of /api/token, .../player/devices """Stand-in Spotify Web API: just enough of /api/token, .../player/devices,
and .../player to test spotify.py against, the same way fakes.py stands in .../player and .../player/seek to test spotify.py against, the same way fakes.py stands in
for real HEOS hardware.""" for real HEOS hardware."""
import json import json
import threading import threading
from http.server import BaseHTTPRequestHandler, HTTPServer from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import parse_qs, urlparse
class FakeSpotify(threading.Thread): class FakeSpotify(threading.Thread):
@@ -21,6 +22,7 @@ class FakeSpotify(threading.Thread):
self.valid_token = None self.valid_token = None
self.tokens_issued = 0 self.tokens_issued = 0
self.transfers = [] # every PUT /v1/me/player body self.transfers = [] # every PUT /v1/me/player body
self.seeks = [] # every PUT /v1/me/player/seek's position_ms
self.reject_refresh = False # simulate a revoked refresh token self.reject_refresh = False # simulate a revoked refresh token
self.player = None # GET /v1/me/player's body; None is no session (204) self.player = None # GET /v1/me/player's body; None is no session (204)
@@ -63,6 +65,21 @@ class FakeSpotify(threading.Thread):
self.send_response(204) self.send_response(204)
self.end_headers() self.end_headers()
return return
if self.path.startswith("/v1/me/player/seek?"):
if not self._authorized():
self._send(401, {"error": {"message": "The access token expired"}})
return
query = parse_qs(urlparse(self.path).query)
fake.seeks.append(int(query["position_ms"][0]))
# The real one answers some player commands with a
# non-JSON body rather than an empty 204.
body = b"a1b2c3d4"
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
self._send(404, {"error": {"message": "not found"}}) self._send(404, {"error": {"message": "not found"}})
def do_GET(self): def do_GET(self):
+4
View File
@@ -37,6 +37,10 @@ class SpotifyTest(unittest.TestCase):
self.client.resume("Kitchen") self.client.resume("Kitchen")
self.assertEqual(self.fake.transfers, []) self.assertEqual(self.fake.transfers, [])
def test_seek_asks_for_the_position(self):
self.client.seek(90000)
self.assertEqual(self.fake.seeks, [90000])
def test_playback_reports_the_device_and_whether_it_plays(self): def test_playback_reports_the_device_and_whether_it_plays(self):
self.fake.player = {"device": {"id": "dev-1", "name": "Lego Room"}, "is_playing": True} self.fake.player = {"device": {"id": "dev-1", "name": "Lego Room"}, "is_playing": True}
player = self.client.playback() player = self.client.playback()
+17 -1
View File
@@ -14,7 +14,7 @@ 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 zidoo import ZidooClient # 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"
PNG = b"\x89PNG\r\n\x1a\n" + b"\0" * 16 PNG = b"\x89PNG\r\n\x1a\n" + b"\0" * 16
@@ -25,6 +25,7 @@ class FakeZidoo(BaseHTTPRequestHandler):
library = {} # file path -> getAggregationOfFile's answer library = {} # file path -> getAggregationOfFile's answer
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
def do_GET(self): def do_GET(self):
url = urlparse(self.path) url = urlparse(self.path)
@@ -33,6 +34,11 @@ class FakeZidoo(BaseHTTPRequestHandler):
if self.video is None: if self.video is None:
return self._send(404, b"") return self._send(404, b"")
return self._json({"status": 200, "video": self.video}) return self._json({"status": 200, "video": self.video})
if url.path == "/ZidooVideoPlay/seekTo":
if self.video is None:
return self._send(404, b"")
self.seeks.append(int(query["positon"])) # sic, as the real one spells it
return self._json({"status": 200})
if url.path == "/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!!!"}))
@@ -62,6 +68,7 @@ class ZidooTest(unittest.TestCase):
"movie": {"id": 108, "name": "A Private War", "year": 2018}}} "movie": {"id": 108, "name": "A Private War", "year": 2018}}}
FakeZidoo.posters = {108: PNG} FakeZidoo.posters = {108: PNG}
FakeZidoo.lookups = [] FakeZidoo.lookups = []
FakeZidoo.seeks = []
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])
@@ -95,6 +102,15 @@ class ZidooTest(unittest.TestCase):
FakeZidoo.video = None FakeZidoo.video = None
self.assertIsNone(self.zidoo.now_playing()) self.assertIsNone(self.zidoo.now_playing())
def test_seek_moves_the_loaded_film(self):
self.zidoo.seek(3600000)
self.assertEqual(FakeZidoo.seeks, [3600000])
def test_seeking_with_nothing_loaded_is_an_error(self):
FakeZidoo.video = None
with self.assertRaises(ZidooError):
self.zidoo.seek(3600000)
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"))
+14
View File
@@ -16,6 +16,11 @@ import urllib.parse
import urllib.request import urllib.request
class ZidooError(RuntimeError):
"""The Zidoo did not do what it was asked -- off, unreachable, or with
nothing loaded to do it to."""
class ZidooClient: class ZidooClient:
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}"
@@ -65,6 +70,15 @@ class ZidooClient:
track["duration_ms"] = video["duration"] track["duration_ms"] = video["duration"]
return track return track
def seek(self, position_ms):
"""Jump the loaded video to position_ms. Unlike now_playing(), this
is something someone asked for, so a Zidoo that does not answer is
an error rather than simply nothing to show."""
# "positon" [sic] is the Zidoo's own spelling.
payload = self._get_json("ZidooVideoPlay/seekTo", positon=int(position_ms))
if not payload or payload.get("status") != 200:
raise ZidooError("The Zidoo did not seek -- is a film still loaded on it?")
def _film(self, path): def _film(self, path):
"""The poster wall's entry for the film a file belongs to -- its """The poster wall's entry for the film a file belongs to -- its
"id" (which is also its poster's) and "year" among the rest -- or {} "id" (which is also its poster's) and "year" among the rest -- or {}