This commit is contained in:
1 parent
0560079b16
commit
db00e57ef7
9 files changed
+282
-182
No files matched your search
@@ -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}` |
|
||||
|
||||
@@ -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)})
|
||||
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
+106
-133
@@ -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 render(state) {
|
||||
(state.rooms || []).forEach((incoming) => {
|
||||
const room = rooms[incoming.key];
|
||||
function applyRoom(room, incoming) {
|
||||
if (!room) return;
|
||||
room.available = incoming.available;
|
||||
room.grouped = incoming.grouped;
|
||||
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;
|
||||
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) => 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));
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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 */
|
||||
|
||||
|
||||
+37
-44
@@ -20,6 +20,16 @@
|
||||
</head>
|
||||
|
||||
<body>
|
||||
{# Play and pause are one button: panel.css shows whichever glyph the
|
||||
.playing class calls for. A room's drives its Spotify stream, the AVR's
|
||||
the film on the Zidoo plugged into it. #}
|
||||
{% macro play_button(role, label) -%}
|
||||
<button class="control-btn" data-role="{{ role }}" aria-label="{{ label }}: play">
|
||||
<svg class="icon-play" viewBox="0 0 384 512" aria-hidden="true"><path d="M73 39c-14.8-9.1-33.4-9.4-48.5-.9S0 62.6 0 80L0 432c0 17.4 9.4 33.4 24.5 41.9s33.7 8.1 48.5-.9L361 297c14.3-8.7 23-24.2 23-41s-8.7-32.2-23-41L73 39z"/></svg>
|
||||
<svg class="icon-pause" viewBox="0 0 320 512" aria-hidden="true"><path d="M48 64C21.5 64 0 85.5 0 112L0 400c0 26.5 21.5 48 48 48l32 0c26.5 0 48-21.5 48-48l0-288c0-26.5-21.5-48-48-48L48 64zm192 0c-26.5 0-48 21.5-48 48l0 288c0 26.5 21.5 48 48 48l32 0c26.5 0 48-21.5 48-48l0-288c0-26.5-21.5-48-48-48l-32 0z"/></svg>
|
||||
</button>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro host_toggle() -%}
|
||||
<button class="sources-btn toggle" data-role="group" aria-pressed="false">
|
||||
<svg class="cinema icon" viewBox="0 0 512 512" aria-hidden="true"><path d="M256 512a256 256 0 1 0 0-512 256 256 0 1 0 0 512zM128 192c0-17.7 14.3-32 32-32l128 0c17.7 0 32 14.3 32 32l0 38.4 61-36.6c1.9-1.2 4.2-1.8 6.4-1.8 6.9 0 12.5 5.6 12.5 12.5l0 102.9c0 6.9-5.6 12.5-12.5 12.5-2.3 0-4.5-.6-6.4-1.8l-61-36.6 0 38.4c0 17.7-14.3 32-32 32l-128 0c-17.7 0-32-14.3-32-32l0-128z"/></svg>
|
||||
@@ -47,26 +57,22 @@
|
||||
</div>
|
||||
{%- endmacro %}
|
||||
|
||||
<div class="app">
|
||||
<header class="top">
|
||||
<img class="logo" src="{{ url_for('static', filename='logo.svg') }}" alt="{{ app_name }}">
|
||||
</header>
|
||||
|
||||
{% for room in rooms %}
|
||||
<section class="card room" data-room="{{ room.key }}">
|
||||
{% macro room_card(room, accounts=()) -%}
|
||||
{% set avr = room.kind == 'avr' %}
|
||||
<section class="card {{ 'avr' if avr else 'room' }}" data-room="{{ room.key }}">
|
||||
<div class="card-head">
|
||||
<h2><svg class="room-icon" data-icon="{{ room.kind }}" viewBox="0 0 512 512" aria-hidden="true"><path d=""/></svg><span class="room-label">{{ room.label }}</span></h2>
|
||||
</div>
|
||||
|
||||
<div class="media" data-role="media" hidden>
|
||||
<div class="now-playing">
|
||||
<img class="cover" data-role="cover" alt="">
|
||||
<img class="cover{{ ' poster' if avr }}" data-role="cover" alt="">
|
||||
<div class="track">
|
||||
<span class="song" data-role="song"></span>
|
||||
<span class="artist" data-role="artist"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress" data-role="progress" data-target="{{ room.key }}">
|
||||
<div class="progress" data-role="progress">
|
||||
<div class="progress-line">
|
||||
<i data-role="progress-fill"></i>
|
||||
<b class="progress-cursor" data-role="progress-cursor"></b>
|
||||
@@ -77,6 +83,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not avr %}
|
||||
<div class="volume">
|
||||
<button class="control-btn step" data-delta="-1" aria-label="{{ room.label }}: volume down">
|
||||
<svg viewBox="0 0 448 512" aria-hidden="true"><path d="M432 256c0 17.7-14.3 32-32 32L48 288c-17.7 0-32-14.3-32-32s14.3-32 32-32l352 0c17.7 0 32 14.3 32 32z"/></svg>
|
||||
@@ -86,60 +93,46 @@
|
||||
<svg viewBox="0 0 448 512" aria-hidden="true"><path d="M256 80c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 144L48 224c-17.7 0-32 14.3-32 32s14.3 32 32 32l144 0 0 144c0 17.7 14.3 32 32 32s32-14.3 32-32l0-144 144 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-144 0 0-144z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="actions" data-role="actions" hidden>
|
||||
{% if not avr %}
|
||||
<button class="control-btn" data-role="prev" aria-label="{{ room.label }}: previous track">
|
||||
<!-- backward-step: the forward-step glyph below, mirrored -->
|
||||
<svg viewBox="0 0 320 512" aria-hidden="true"><path transform="matrix(-1 0 0 1 320 0)" d="M52.5 440.6c-9.5 7.9-22.8 9.7-34.1 4.4S0 428.4 0 416L0 96C0 83.6 7.2 72.3 18.4 67s24.5-3.6 34.1 4.4l192 160L256 241l0-145c0-17.7 14.3-32 32-32s32 14.3 32 32l0 320c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-145-11.5 9.6-192 160z"/></svg>
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
<button class="control-btn" data-role="play" aria-label="{{ room.label }}: play">
|
||||
<svg class="icon-play" viewBox="0 0 384 512" aria-hidden="true"><path d="M73 39c-14.8-9.1-33.4-9.4-48.5-.9S0 62.6 0 80L0 432c0 17.4 9.4 33.4 24.5 41.9s33.7 8.1 48.5-.9L361 297c14.3-8.7 23-24.2 23-41s-8.7-32.2-23-41L73 39z"/></svg>
|
||||
<svg class="icon-pause" viewBox="0 0 320 512" aria-hidden="true"><path d="M48 64C21.5 64 0 85.5 0 112L0 400c0 26.5 21.5 48 48 48l32 0c26.5 0 48-21.5 48-48l0-288c0-26.5-21.5-48-48-48L48 64zm192 0c-26.5 0-48 21.5-48 48l0 288c0 26.5 21.5 48 48 48l32 0c26.5 0 48-21.5 48-48l0-288c0-26.5-21.5-48-48-48l-32 0z"/></svg>
|
||||
</button>
|
||||
{{ play_button('play', room.label) }}
|
||||
|
||||
{% if not avr %}
|
||||
<button class="control-btn" data-role="next" aria-label="{{ room.label }}: next track">
|
||||
<svg viewBox="0 0 320 512" aria-hidden="true"><path d="M52.5 440.6c-9.5 7.9-22.8 9.7-34.1 4.4S0 428.4 0 416L0 96C0 83.6 7.2 72.3 18.4 67s24.5-3.6 34.1 4.4l192 160L256 241l0-145c0-17.7 14.3-32 32-32s32 14.3 32 32l0 320c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-145-11.5 9.6-192 160z"/></svg>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{{ control_row(room.key, spotify_accounts) }}
|
||||
</section>
|
||||
{% endfor %}
|
||||
|
||||
<section class="card avr">
|
||||
<div class="card-head">
|
||||
<h2><svg class="room-icon" data-icon="{{ host.kind }}" viewBox="0 0 512 512" aria-hidden="true"><path d=""/></svg><span class="room-label">{{ host.label }}</span></h2>
|
||||
</div>
|
||||
|
||||
<div class="media" data-role="avr-media" hidden>
|
||||
<div class="now-playing" data-role="avr-now-playing">
|
||||
<img class="cover poster" data-role="avr-cover" alt="">
|
||||
<div class="track">
|
||||
<span class="song" data-role="avr-song"></span>
|
||||
<span class="artist" data-role="avr-artist"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress" data-role="avr-progress" data-target="{{ host.key }}">
|
||||
<div class="progress-line">
|
||||
<i data-role="progress-fill"></i>
|
||||
<b class="progress-cursor" data-role="progress-cursor"></b>
|
||||
<span class="progress-time elapsed" data-role="progress-elapsed"></span>
|
||||
<span class="progress-time remaining" data-role="progress-remaining"></span>
|
||||
</div>
|
||||
<span class="progress-total" data-role="progress-total"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if avr %}
|
||||
<button class="input-select" data-role="input-button">
|
||||
<span class="eyebrow">Input</span>
|
||||
<svg class="input-icon" data-role="input-icon" viewBox="0 0 512 512" aria-hidden="true"><path d=""/></svg>
|
||||
<span class="value" data-role="input-name">—</span>
|
||||
<svg class="chevron" viewBox="0 0 320 512" aria-hidden="true"><path d="M310.6 233.4c12.5 12.5 12.5 32.8 0 45.3l-192 192c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L242.7 256 73.4 86.6c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l192 192z"/></svg>
|
||||
</button>
|
||||
|
||||
<div class="joined" data-role="joined" hidden></div>
|
||||
</section>
|
||||
{% else %}
|
||||
{{ control_row(room.key, accounts) }}
|
||||
{% endif %}
|
||||
</section>
|
||||
{%- endmacro %}
|
||||
|
||||
<div class="app">
|
||||
<header>
|
||||
<img class="logo" src="{{ url_for('static', filename='logo.svg') }}" alt="{{ app_name }}">
|
||||
</header>
|
||||
|
||||
{% for room in rooms + [host] %}
|
||||
{{ room_card(room, spotify_accounts) }}
|
||||
{% endfor %}
|
||||
|
||||
<footer data-role="foot"></footer>
|
||||
</div>
|
||||
|
||||
@@ -10,10 +10,12 @@ import threading
|
||||
import unittest
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from controller import Controller, TargetError # noqa: E402
|
||||
from zidoo import ZidooClient, ZidooError # noqa: E402
|
||||
|
||||
PATH = "/storage/disk/Movies/A.Private.War.2018.1080p.mp4"
|
||||
@@ -26,6 +28,8 @@ class FakeZidoo(BaseHTTPRequestHandler):
|
||||
posters = {} # poster id -> image bytes
|
||||
lookups = [] # paths getAggregationOfFile was asked about
|
||||
seeks = [] # every seekTo's position
|
||||
keys = [] # every sendkey the remote endpoint was handed
|
||||
accepts = {"Key.MediaPlay", "Key.MediaPause"} # the rest come back 805
|
||||
|
||||
def do_GET(self):
|
||||
url = urlparse(self.path)
|
||||
@@ -39,6 +43,14 @@ class FakeZidoo(BaseHTTPRequestHandler):
|
||||
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 == "/ZidooControlCenter/RemoteControl/sendkey":
|
||||
key = query.get("key")
|
||||
if key not in self.accepts:
|
||||
return self._json({"status": 805, "msg": "The parameter is error or no parameter is submitted"})
|
||||
# The real box answers before the player has acted on the press,
|
||||
# so getPlayStatus keeps reporting the old state for a moment.
|
||||
self.keys.append(key)
|
||||
return self._json({"status": 200})
|
||||
if url.path == "/ZidooPoster/v2/getAggregationOfFile":
|
||||
self.lookups.append(query["path"])
|
||||
return self._json(self.library.get(query["path"], {"status": 804, "msg": "Error!!!"}))
|
||||
@@ -69,6 +81,8 @@ class ZidooTest(unittest.TestCase):
|
||||
FakeZidoo.posters = {108: PNG}
|
||||
FakeZidoo.lookups = []
|
||||
FakeZidoo.seeks = []
|
||||
FakeZidoo.keys = []
|
||||
FakeZidoo.accepts = {"Key.MediaPlay", "Key.MediaPause"}
|
||||
self.server = ThreadingHTTPServer(("127.0.0.1", 0), FakeZidoo)
|
||||
threading.Thread(target=self.server.serve_forever, args=(0.05,), daemon=True).start()
|
||||
self.zidoo = ZidooClient("127.0.0.1", self.server.server_address[1])
|
||||
@@ -111,6 +125,29 @@ class ZidooTest(unittest.TestCase):
|
||||
with self.assertRaises(ZidooError):
|
||||
self.zidoo.seek(3600000)
|
||||
|
||||
def test_play_and_pause_send_the_matching_media_key(self):
|
||||
self.zidoo.play_state("pause")
|
||||
self.zidoo.play_state("play")
|
||||
self.assertEqual(FakeZidoo.keys, ["Key.MediaPause", "Key.MediaPlay"])
|
||||
|
||||
def test_a_state_the_box_has_no_key_for_is_an_error(self):
|
||||
"""HEOS rooms take "stop" as well; the Zidoo's remote has no such key,
|
||||
so /api/playback's third state has to be refused rather than guessed at."""
|
||||
with self.assertRaises(ZidooError):
|
||||
self.zidoo.play_state("stop")
|
||||
self.assertEqual(FakeZidoo.keys, [])
|
||||
|
||||
def test_a_key_the_box_rejects_is_an_error(self):
|
||||
FakeZidoo.accepts = set()
|
||||
with self.assertRaises(ZidooError):
|
||||
self.zidoo.play_state("pause")
|
||||
|
||||
def test_an_unreachable_box_is_an_error_not_silence(self):
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
with self.assertRaises(ZidooError):
|
||||
self.zidoo.play_state("pause")
|
||||
|
||||
def test_poster_is_sniffed_from_its_bytes(self):
|
||||
self.assertEqual(self.zidoo.poster(108), (PNG, "image/png"))
|
||||
|
||||
@@ -118,5 +155,49 @@ class ZidooTest(unittest.TestCase):
|
||||
self.assertIsNone(self.zidoo.poster(2248))
|
||||
|
||||
|
||||
class ZidooThroughTheControllerTest(unittest.TestCase):
|
||||
"""zidoo_play_state() only ever talks to the Zidoo, so this needs no HEOS
|
||||
behind it -- HeosClient does not dial until something asks it to."""
|
||||
|
||||
def setUp(self):
|
||||
FakeZidoo.video = {"status": 1, "title": "A Private War", "path": PATH,
|
||||
"currentPosition": 1589745, "duration": 6635092}
|
||||
FakeZidoo.library = {}
|
||||
FakeZidoo.keys = []
|
||||
FakeZidoo.accepts = {"Key.MediaPlay", "Key.MediaPause"}
|
||||
self.server = ThreadingHTTPServer(("127.0.0.1", 0), FakeZidoo)
|
||||
threading.Thread(target=self.server.serve_forever, args=(0.05,), daemon=True).start()
|
||||
self.cfg = SimpleNamespace(
|
||||
HEOS_HOST="127.0.0.1", HEOS_PORT=1255,
|
||||
HOST_KEY="avr", ROOM_KEYS=[],
|
||||
TARGETS={"avr": {"label": "Home Cinema", "heos_name": "Home Cinema"}},
|
||||
VOLUME_STEP=5,
|
||||
ZIDOO_HOST="127.0.0.1", ZIDOO_PORT=self.server.server_address[1],
|
||||
)
|
||||
self.panel = Controller(self.cfg)
|
||||
|
||||
def tearDown(self):
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
|
||||
def test_an_explicit_state_goes_straight_through(self):
|
||||
self.assertEqual(self.panel.zidoo_play_state("pause"), "pause")
|
||||
self.assertEqual(FakeZidoo.keys, ["Key.MediaPause"])
|
||||
|
||||
def test_no_state_flips_what_the_zidoo_itself_reports(self):
|
||||
self.assertEqual(self.panel.zidoo_play_state(), "pause")
|
||||
self.assertEqual(FakeZidoo.keys, ["Key.MediaPause"])
|
||||
|
||||
def test_no_state_starts_a_film_the_zidoo_has_paused(self):
|
||||
FakeZidoo.video["status"] = 0
|
||||
self.assertEqual(self.panel.zidoo_play_state(), "play")
|
||||
self.assertEqual(FakeZidoo.keys, ["Key.MediaPlay"])
|
||||
|
||||
def test_no_zidoo_configured_is_a_target_error(self):
|
||||
panel = Controller(SimpleNamespace(**{**vars(self.cfg), "ZIDOO_HOST": None}))
|
||||
with self.assertRaises(TargetError):
|
||||
panel.zidoo_play_state("pause")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -22,6 +22,13 @@ class ZidooError(RuntimeError):
|
||||
|
||||
|
||||
class ZidooClient:
|
||||
# The video player answers getPlayStatus and seekTo, but has no route of
|
||||
# its own for starting or stopping what it is showing -- the box's remote
|
||||
# takes that, and its media keys reach the player like a press on the
|
||||
# handset would. One key per state rather than a single toggle, for the
|
||||
# same reason a room sends the state it wants (see controller.py).
|
||||
KEYS = {"play": "Key.MediaPlay", "pause": "Key.MediaPause"}
|
||||
|
||||
def __init__(self, host, port=9529, timeout=1.5):
|
||||
self.base_url = f"http://{host}:{port}"
|
||||
self.timeout = timeout
|
||||
@@ -70,6 +77,19 @@ class ZidooClient:
|
||||
track["duration_ms"] = video["duration"]
|
||||
return track
|
||||
|
||||
def play_state(self, state):
|
||||
"""Start or stop the loaded video. Like seek(), this is something
|
||||
someone asked for, so a Zidoo that does not answer is an error rather
|
||||
than nothing to show. The key press is acknowledged at once but the
|
||||
player takes a moment to act on it, so the next now_playing() can
|
||||
still report the state it is on its way out of."""
|
||||
key = self.KEYS.get(state)
|
||||
if key is None:
|
||||
raise ZidooError(f"The Zidoo has no key for '{state}' -- only play and pause")
|
||||
payload = self._get_json("ZidooControlCenter/RemoteControl/sendkey", key=key)
|
||||
if not payload or payload.get("status") != 200:
|
||||
raise ZidooError(f"The Zidoo did not {state} -- is it on and reachable?")
|
||||
|
||||
def seek(self, position_ms):
|
||||
"""Jump the loaded video to position_ms. Unlike now_playing(), this
|
||||
is something someone asked for, so a Zidoo that does not answer is
|
||||
|
||||
Reference in new issue
Block a user