This commit is contained in:
@@ -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 |
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
+12
-2
@@ -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>
|
||||||
|
<div class="progress-line">
|
||||||
<i data-role="progress-fill"></i>
|
<i data-role="progress-fill"></i>
|
||||||
<b class="progress-cursor" data-role="progress-cursor"></b>
|
<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>
|
||||||
|
<div class="progress-line">
|
||||||
<i data-role="progress-fill"></i>
|
<i data-role="progress-fill"></i>
|
||||||
<b class="progress-cursor" data-role="progress-cursor"></b>
|
<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
@@ -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):
|
||||||
|
|||||||
@@ -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
@@ -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"))
|
||||||
|
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
Reference in New Issue
Block a user