This commit is contained in:
1 parent
0560079b16
commit
db00e57ef7
9 files changed
+328
-228
No files matched your search
+114
-141
@@ -8,6 +8,9 @@ const BASE = document.documentElement.dataset.base || '/';
|
||||
const POLL_MS = 5000;
|
||||
// How long a dropped cursor waits for the player to report it got there.
|
||||
const SEEK_HOLD_MS = 10000;
|
||||
// And how long a pressed play or pause on the AVR waits for the same. The
|
||||
// Zidoo acknowledges the key press at once but takes a moment to act on it.
|
||||
const PLAY_HOLD_MS = 6000;
|
||||
// Motion turned down in the OS means cards change places without sliding.
|
||||
const CALM = matchMedia('(prefers-reduced-motion: reduce)');
|
||||
|
||||
@@ -63,45 +66,19 @@ const ui = {
|
||||
app: el('.app'),
|
||||
avr: el('.card.avr'),
|
||||
joined: el('[data-role="joined"]'),
|
||||
avrMedia: el('[data-role="avr-media"]'),
|
||||
avrNowPlaying: el('[data-role="avr-now-playing"]'),
|
||||
avrCover: el('[data-role="avr-cover"]'),
|
||||
avrSong: el('[data-role="avr-song"]'),
|
||||
avrArtist: el('[data-role="avr-artist"]'),
|
||||
avrProgress: el('[data-role="avr-progress"]'),
|
||||
};
|
||||
|
||||
// Shares its shape with a room's {nowPlaying, cover, song, artist,
|
||||
// progress} refs, so renderTrack() below works for both -- a device plugged
|
||||
// into the AVR (a Zidoo, say) is "now playing" the same way a room's own
|
||||
// stream is.
|
||||
const avrTrack = {
|
||||
media: ui.avrMedia,
|
||||
nowPlaying: ui.avrNowPlaying,
|
||||
cover: ui.avrCover,
|
||||
song: ui.avrSong,
|
||||
artist: ui.avrArtist,
|
||||
progress: ui.avrProgress,
|
||||
// Only a Zidoo ever fills this card's now-playing in, and it can always seek.
|
||||
canSeek: () => true,
|
||||
// A film paused on the Zidoo reports its own play_state; nothing else
|
||||
// ever lands here with a position and duration to tick.
|
||||
playing: () => avrNowPlaying?.play_state === 'play',
|
||||
seek: null,
|
||||
};
|
||||
// Same reasoning as a room's own cover: drop one that will not load rather
|
||||
// than leave a broken-image box.
|
||||
avrTrack.cover.addEventListener('error', () => { avrTrack.cover.hidden = true; });
|
||||
seekable(avrTrack);
|
||||
// The AVR is one of the rooms below, so its key comes off its card like
|
||||
// theirs do. Which one it is, everything that only it can do goes through.
|
||||
const HOST = ui.avr.dataset.room;
|
||||
|
||||
// Same as a room's card: named, so a view transition can follow it when it
|
||||
// moves to the top and back.
|
||||
// Named, so a view transition can follow the card when it moves to the top
|
||||
// and back. A room's name is set as it is placed; the host's never moves.
|
||||
ui.avr.style.viewTransitionName = 'avr';
|
||||
|
||||
const rooms = {};
|
||||
let inputs = [];
|
||||
let currentInput = null;
|
||||
let avrNowPlaying = null;
|
||||
|
||||
/* --- transport -------------------------------------------------------- */
|
||||
async function api(path, body) {
|
||||
@@ -126,11 +103,12 @@ function toast(message, kind = 'error') {
|
||||
}
|
||||
|
||||
/* --- rooms ------------------------------------------------------------ */
|
||||
els('.room').forEach((node) => {
|
||||
els('.card[data-room]').forEach((node) => {
|
||||
const key = node.dataset.room;
|
||||
const room = {
|
||||
key,
|
||||
node,
|
||||
avr: node.classList.contains('avr'),
|
||||
level: el('[data-role="level"]', node),
|
||||
meter: el('[data-role="meter"]', node),
|
||||
actions: el('[data-role="actions"]', node),
|
||||
@@ -159,10 +137,12 @@ els('.room').forEach((node) => {
|
||||
inflight: false,
|
||||
busy: false, // a grouping change is in flight
|
||||
playBusy: false,
|
||||
wantedPlay: null, // {state, sentAt} from a press until the player reports it
|
||||
// HEOS cannot seek, so only a stream Spotify can be asked to seek in: the
|
||||
// same one the transport buttons show for (see paintRoom).
|
||||
canSeek: () => room.onSpotify && room.spotifyAccount && !room.grouped,
|
||||
playing: () => room.playState === 'play',
|
||||
// same one the transport buttons show for (see paintTransport). Only a
|
||||
// Zidoo ever fills the AVR's card in, and it can always seek.
|
||||
canSeek: () => room.avr || (room.onSpotify && room.spotifyAccount && !room.grouped),
|
||||
playing: () => shownPlayState(room) === 'play',
|
||||
seek: null, // {ms, stage, ...} from a drag of the cursor until the player catches up
|
||||
};
|
||||
rooms[key] = room;
|
||||
@@ -171,7 +151,7 @@ els('.room').forEach((node) => {
|
||||
const direction = Number(button.dataset.delta);
|
||||
holdable(button, () => nudge(key, direction));
|
||||
});
|
||||
draggable(room);
|
||||
if (room.meter) draggable(room);
|
||||
seekable(room);
|
||||
|
||||
// A cover that will not load (gone, or plain http on an https page) is
|
||||
@@ -179,37 +159,43 @@ els('.room').forEach((node) => {
|
||||
// the next poll does not try it again until the track changes.
|
||||
room.cover.addEventListener('error', () => { room.cover.hidden = true; });
|
||||
|
||||
room.toggle.addEventListener('click', () => setGrouped(key, !room.grouped));
|
||||
room.prev.addEventListener('click', () => skipTrack(key, 'previous'));
|
||||
if (room.toggle) room.toggle.addEventListener('click', () => setGrouped(key, !room.grouped));
|
||||
if (room.prev) room.prev.addEventListener('click', () => skipTrack(key, 'previous'));
|
||||
room.play.addEventListener('click', () => togglePlay(key));
|
||||
room.next.addEventListener('click', () => skipTrack(key, 'next'));
|
||||
if (room.next) room.next.addEventListener('click', () => skipTrack(key, 'next'));
|
||||
});
|
||||
|
||||
function paintRoom(room) {
|
||||
/* The volume row, which the AVR's card has not got: its own level is not in
|
||||
/api/state, so there is nothing to draw a meter from. */
|
||||
function paintVolume(room) {
|
||||
if (!room.meter) return;
|
||||
const known = room.volume !== null && room.volume !== undefined;
|
||||
room.level.textContent = known ? room.volume : '—';
|
||||
// The fill and the knob riding it both size themselves off this.
|
||||
room.meter.style.setProperty('--level', known ? room.volume : 0);
|
||||
room.node.classList.toggle('offline', !room.available);
|
||||
room.steps.forEach((button) => { button.disabled = !room.available; });
|
||||
renderTrack(room.available ? room.track : null, room);
|
||||
const playing = room.playState === 'play';
|
||||
}
|
||||
|
||||
function paintTransport(room) {
|
||||
const state = shownPlayState(room);
|
||||
const playing = state === 'play';
|
||||
room.node.classList.toggle('playing', playing);
|
||||
room.play.classList.toggle('playing', playing);
|
||||
room.play.disabled = !room.available || room.playState === null;
|
||||
room.play.disabled = !room.available || !state;
|
||||
room.play.setAttribute(
|
||||
'aria-label', `${room.node.querySelector('h2').textContent}: ${playing ? 'pause' : 'play'}`);
|
||||
// Previous, play/pause and next only mean something for a Spotify stream:
|
||||
// an AVR input has no queue to pause or skip, and starting Spotify is what
|
||||
// the account buttons are for. Grouped, transport belongs to the AVR's
|
||||
// card, not this one -- pressing them here would still work (it shares
|
||||
// the group's transport) but only invites confusion about which card is
|
||||
// actually in charge of it. And only for a stream one of our own accounts
|
||||
// is playing: someone else's phone keeps its own controls. The row shows
|
||||
// or hides as one, so an empty row never leaves a gap in the card.
|
||||
room.actions.hidden = !(room.onSpotify && room.spotifyAccount && !room.grouped);
|
||||
room.prev.disabled = !room.available;
|
||||
room.next.disabled = !room.available;
|
||||
room.actions.hidden = room.avr
|
||||
? !state
|
||||
: !(room.onSpotify && room.spotifyAccount && !room.grouped);
|
||||
if (room.prev) room.prev.disabled = !room.available;
|
||||
if (room.next) room.next.disabled = !room.available;
|
||||
}
|
||||
|
||||
/* The Spotify buttons and the host toggle, which the AVR's card has not got:
|
||||
those resume an account in a room, or join it to the AVR, and neither is
|
||||
something the AVR does to itself. */
|
||||
function paintSources(room) {
|
||||
if (!room.sources) return;
|
||||
// Grouped, the room plays whatever the AVR does, so resuming Spotify on it
|
||||
// is not on offer. They hide one by one rather than the row hiding with
|
||||
// them, because the row also holds the host toggle, which stays put and
|
||||
@@ -231,6 +217,14 @@ function paintRoom(room) {
|
||||
// Out of the group the button names where tapping it sends the room; in it,
|
||||
// where the card sits already says so, so the button says what it does.
|
||||
room.toggleLabel.textContent = room.grouped ? 'Ungroup' : 'Cinema';
|
||||
}
|
||||
|
||||
function paintRoom(room) {
|
||||
room.node.classList.toggle('offline', !room.available);
|
||||
renderTrack(room.available ? room.track : null, room);
|
||||
paintVolume(room);
|
||||
paintTransport(room);
|
||||
paintSources(room);
|
||||
placeRoom(room);
|
||||
placeCards();
|
||||
}
|
||||
@@ -254,15 +248,6 @@ function renderTrack(track, refs) {
|
||||
}
|
||||
}
|
||||
|
||||
/* The discreet cursor on the line below the song, sized off the same
|
||||
{position_ms, duration_ms} the poll hands back -- absent for anything
|
||||
HEOS never sends a progress event for (an AVR input, an internet radio
|
||||
stream with no fixed length), in which case the line stays plain. Around
|
||||
a seek, the position seekPosition() picks shows instead of the one
|
||||
reported. Also leaves refs.tick pointing at this position and the moment
|
||||
it was true, for tickProgress() to run forward from until the next call
|
||||
here -- a poll, a drag, or a seek landing -- corrects it and moves the
|
||||
mark again. */
|
||||
function renderProgress(track, refs) {
|
||||
refs.shown = null;
|
||||
refs.tick = null;
|
||||
@@ -305,12 +290,6 @@ function paintProgress(refs, known, position, duration) {
|
||||
el('[data-role="progress-total"]', progress).textContent = known ? clock(total) : '';
|
||||
}
|
||||
|
||||
/* Between polls the cursor otherwise sits still for POLL_MS at a time and
|
||||
then jumps -- so this nudges it along at real speed from the last known
|
||||
position, purely cosmetic until the next renderProgress() (a poll, most
|
||||
often) corrects it and moves the mark again. Skipped while a seek owns
|
||||
the display (seekPosition already covers that) or nothing is actually
|
||||
playing, so a paused cursor stays put. */
|
||||
function tickProgress(refs) {
|
||||
if (!refs.tick || !refs.shown || refs.seek || !refs.playing()) return;
|
||||
const { ms, at, duration } = refs.tick;
|
||||
@@ -318,17 +297,8 @@ function tickProgress(refs) {
|
||||
paintProgress(refs, true, position, duration);
|
||||
}
|
||||
|
||||
setInterval(() => {
|
||||
tickProgress(avrTrack);
|
||||
Object.values(rooms).forEach(tickProgress);
|
||||
}, 250);
|
||||
setInterval(() => Object.values(rooms).forEach(tickProgress), 250);
|
||||
|
||||
/* Drag the cursor, or either time riding with it, to jump backwards or
|
||||
forwards. Like the volume knob, it moves by how far the finger travels
|
||||
rather than jumping to where it lands, so grabbing a time off-centre never
|
||||
lurches the song -- and only the release is sent, so the player is not
|
||||
asked to seek a dozen times along the way. A vertical swipe stays a page
|
||||
scroll (see panel.css), which cancels the drag. */
|
||||
function seekable(refs) {
|
||||
const { progress } = refs;
|
||||
const line = el('.progress-line', progress);
|
||||
@@ -384,7 +354,7 @@ async function sendSeek(refs) {
|
||||
seek.stage = 'send';
|
||||
seek.sentAt = Date.now();
|
||||
try {
|
||||
await api('/api/seek', { target: refs.progress.dataset.target, position_ms: seek.ms });
|
||||
await api('/api/seek', { target: refs.key, position_ms: seek.ms });
|
||||
seek.stage = 'hold';
|
||||
// The player takes a moment to report where it has got to.
|
||||
setTimeout(refresh, 1500);
|
||||
@@ -396,13 +366,6 @@ async function sendSeek(refs) {
|
||||
if (refs.shown) renderProgress(refs.shown, refs);
|
||||
}
|
||||
|
||||
/* Where the cursor goes: under the finger while dragging, and where it was
|
||||
dropped while that is sent. After that the player still reports its old
|
||||
position for a poll or two -- HEOS only passes its progress on as it goes,
|
||||
and a Zidoo mid-seek is no quicker -- so the cursor stays put rather than
|
||||
bouncing back, until a reported position could only come after the seek
|
||||
(the drop, give or take, plus however long it has played on since). A
|
||||
different track, or SEEK_HOLD_MS with no such report, lets go as well. */
|
||||
function seekPosition(track, refs) {
|
||||
const { seek } = refs;
|
||||
if (!seek) return track.position_ms;
|
||||
@@ -417,8 +380,6 @@ function seekPosition(track, refs) {
|
||||
return seek.ms;
|
||||
}
|
||||
|
||||
/* Dragged or on its way -- not merely waiting for the player to catch up,
|
||||
which is exactly what the polls must keep coming for. */
|
||||
function seeking(refs) {
|
||||
return Boolean(refs.seek) && refs.seek.stage !== 'hold';
|
||||
}
|
||||
@@ -431,11 +392,10 @@ function clock(seconds) {
|
||||
return h ? `${h}:${String(m).padStart(2, '0')}:${s}` : `${m}:${s}`;
|
||||
}
|
||||
|
||||
/* A merged room moves into the host's card, because that is what merging
|
||||
means: one group, playing one thing. Leaving puts it back in the column,
|
||||
ahead of the rooms declared after it, so the cards keep the order they are
|
||||
declared in however they were grouped. */
|
||||
function placeRoom(room) {
|
||||
// The host's card is where the column ends, and every room placed above
|
||||
// measures from it, so it is the one card that never moves.
|
||||
if (room.avr) return;
|
||||
if (room.grouped) {
|
||||
if (room.node.parentElement !== ui.joined) ui.joined.appendChild(room.node);
|
||||
} else if (room.node.parentElement !== ui.app) {
|
||||
@@ -465,20 +425,11 @@ function paintGrouping() {
|
||||
// the whole group is -- and the tint goes on the group's card.
|
||||
// A film running on the Zidoo counts as the AVR playing too, grouped rooms or not.
|
||||
const playing = inside.some((node) => node.classList.contains('playing'))
|
||||
|| avrNowPlaying?.play_state === 'play';
|
||||
|| shownPlayState(rooms[HOST]) === 'play';
|
||||
ui.avr.classList.toggle('playing', playing);
|
||||
placeCards();
|
||||
}
|
||||
|
||||
/* Which card sits at the top of the column is CSS order (see .on-top), and
|
||||
order is not a property that transitions: the cards change places between
|
||||
one frame and the next. A view transition does the sliding instead -- the
|
||||
browser snapshots the cards where they are, runs the swap, and animates
|
||||
them to where they landed. One turn can paint several cards (a poll
|
||||
touches every room and then the group), so the move is collected and run
|
||||
once, and only started when a card really does change places. Without
|
||||
support, or with motion turned down, the cards just change places the way
|
||||
they always did. */
|
||||
let placing = false;
|
||||
function placeCards() {
|
||||
if (placing) return;
|
||||
@@ -488,9 +439,12 @@ function placeCards() {
|
||||
// Grouped, a room's card has left the column and the AVR's is the one
|
||||
// that moves up. The AVR's tint and its position answer the same
|
||||
// question, and paintGrouping() has just settled it.
|
||||
const wanted = Object.values(rooms)
|
||||
.map((room) => [room.node, room.playing() && !room.grouped]);
|
||||
wanted.push([ui.avr, ui.avr.classList.contains('playing')]);
|
||||
const wanted = Object.values(rooms).map((room) => [
|
||||
room.node,
|
||||
// The AVR's tint already answers this: paintGrouping has just settled
|
||||
// it, over the whole group rather than the host's own stream alone.
|
||||
room.avr ? room.node.classList.contains('playing') : room.playing() && !room.grouped,
|
||||
]);
|
||||
|
||||
const move = () => wanted.forEach(([node, top]) => node.classList.toggle('on-top', top));
|
||||
const moves = wanted.some(([node, top]) => node.classList.contains('on-top') !== top);
|
||||
@@ -520,10 +474,6 @@ function holdable(node, action) {
|
||||
['pointerup', 'pointercancel', 'pointerleave'].forEach((type) => node.addEventListener(type, stop));
|
||||
}
|
||||
|
||||
/* A tap lands on the next multiple of STEP rather than adding STEP, so a
|
||||
level of 23 goes to 25 on the way up and 20 on the way down. Mirrors
|
||||
stepped_level() in controller.py -- the panel guesses with it, the
|
||||
speakers are the ones that decide. */
|
||||
function nextLevel(current, direction) {
|
||||
if (direction > 0) return Math.min(100, (Math.floor(current / STEP) + 1) * STEP);
|
||||
const aligned = Math.floor(current / STEP) * STEP;
|
||||
@@ -573,11 +523,6 @@ async function flushVolume(key) {
|
||||
}
|
||||
}
|
||||
|
||||
/* Drag the knob to set the level outright, with the speakers following as
|
||||
it goes -- a drag collapses into one call at a time, the same as a burst
|
||||
of taps. It moves by how far the finger travels rather than jumping to
|
||||
where it lands, so grabbing the knob off-centre, or brushing it while
|
||||
scrolling past, never lurches the volume. */
|
||||
function draggable(room) {
|
||||
const knob = room.level;
|
||||
let startX = 0;
|
||||
@@ -633,23 +578,46 @@ async function setGrouped(key, joined) {
|
||||
}
|
||||
}
|
||||
|
||||
/* Sends the state it wants rather than "toggle", so a stale idea of what
|
||||
the room is doing cannot flip it the wrong way. */
|
||||
function shownPlayState(room) {
|
||||
const reported = room.playState;
|
||||
if (!room.wantedPlay) return reported;
|
||||
// Nothing loaded any more -- the film ended, or the AVR changed input --
|
||||
// leaves nothing to hold; so does the player reporting the state asked for.
|
||||
if (!reported || reported === room.wantedPlay.state
|
||||
|| Date.now() - room.wantedPlay.sentAt > PLAY_HOLD_MS) {
|
||||
room.wantedPlay = null;
|
||||
}
|
||||
return room.wantedPlay ? room.wantedPlay.state : reported;
|
||||
}
|
||||
|
||||
/* Sends the state it wants rather than "toggle", so a stale idea of what the
|
||||
room is doing cannot flip it the wrong way. For the AVR this reaches the
|
||||
Zidoo rather than HEOS, which can no more pause a device on one of the
|
||||
AVR's inputs than it can seek in it -- app.py routes it there. */
|
||||
async function togglePlay(key) {
|
||||
const room = rooms[key];
|
||||
if (!room.available || room.playBusy) return;
|
||||
const wanted = room.playState === 'play' ? 'pause' : 'play';
|
||||
const state = shownPlayState(room);
|
||||
if (!room.available || !state || room.playBusy) return;
|
||||
const wanted = state === 'play' ? 'pause' : 'play';
|
||||
room.playBusy = true;
|
||||
room.playState = wanted;
|
||||
room.wantedPlay = { state: wanted, sentAt: Date.now() };
|
||||
paintRoom(room);
|
||||
paintGrouping();
|
||||
try {
|
||||
const data = await api('/api/playback', { target: key, state: wanted });
|
||||
room.playState = data.state;
|
||||
// HEOS answers with the state the room is now in, which lets the hold go
|
||||
// at once. The Zidoo only says the key press was taken, so the AVR's card
|
||||
// keeps holding until a poll sees the film itself move.
|
||||
if (!room.avr) room.playState = data.state;
|
||||
} catch (error) {
|
||||
toast(error.message);
|
||||
room.wantedPlay = null;
|
||||
} finally {
|
||||
room.playBusy = false;
|
||||
paintRoom(room);
|
||||
paintGrouping();
|
||||
// The Zidoo takes a moment to report that it has started or stopped.
|
||||
if (room.avr) setTimeout(refresh, 1500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -792,31 +760,37 @@ async function chooseInput(source) {
|
||||
}
|
||||
|
||||
/* --- state ------------------------------------------------------------- */
|
||||
function applyRoom(room, incoming) {
|
||||
if (!room) return;
|
||||
room.available = Boolean(incoming.available);
|
||||
room.grouped = Boolean(incoming.grouped);
|
||||
// Do not stomp on a volume the user is in the middle of changing.
|
||||
if (!room.taps && !room.inflight && !room.dragging && room.wanted === null) {
|
||||
room.volume = incoming.volume;
|
||||
}
|
||||
if (!room.playBusy) room.playState = incoming.play_state || null;
|
||||
room.track = incoming.now_playing || null;
|
||||
room.onSpotify = Boolean(incoming.spotify);
|
||||
room.spotifyAccount = incoming.spotify_account || null;
|
||||
paintRoom(room);
|
||||
}
|
||||
|
||||
function render(state) {
|
||||
(state.rooms || []).forEach((incoming) => {
|
||||
const room = rooms[incoming.key];
|
||||
if (!room) return;
|
||||
room.available = incoming.available;
|
||||
room.grouped = incoming.grouped;
|
||||
// Do not stomp on a volume the user is in the middle of changing.
|
||||
if (!room.taps && !room.inflight && !room.dragging && room.wanted === null) {
|
||||
room.volume = incoming.volume;
|
||||
}
|
||||
if (!room.playBusy) room.playState = incoming.play_state;
|
||||
room.track = incoming.now_playing || null;
|
||||
room.onSpotify = Boolean(incoming.spotify);
|
||||
room.spotifyAccount = incoming.spotify_account || null;
|
||||
paintRoom(room);
|
||||
});
|
||||
(state.rooms || []).forEach((incoming) => applyRoom(rooms[incoming.key], incoming));
|
||||
|
||||
const avr = state.avr || {};
|
||||
inputs = avr.inputs || [];
|
||||
currentInput = avr.input || null;
|
||||
ui.inputName.textContent = currentInput ? currentInput.name : '—';
|
||||
setInputIcon(currentInput ? currentInput.code : null);
|
||||
avrNowPlaying = avr.now_playing || null;
|
||||
renderTrack(avrNowPlaying, avrTrack);
|
||||
paintGrouping(); // after avrNowPlaying, which decides where the AVR's card sits
|
||||
// Whatever is on one of its inputs is what the AVR is playing, and its own
|
||||
// play state is that device's -- HEOS has none to give for an input.
|
||||
applyRoom(rooms[HOST], {
|
||||
available: avr.connected !== false,
|
||||
now_playing: avr.now_playing || null,
|
||||
play_state: (avr.now_playing || {}).play_state || null,
|
||||
});
|
||||
paintGrouping(); // after the AVR's card, which decides where it sits
|
||||
|
||||
const problems = state.errors || [];
|
||||
ui.foot.textContent = problems.length ? problems[0] : (state.demo ? 'demo mode — no real speakers' : '');
|
||||
@@ -839,7 +813,6 @@ async function refresh() {
|
||||
|
||||
function busy() {
|
||||
return sheetOpen()
|
||||
|| seeking(avrTrack)
|
||||
|| Object.values(rooms).some((r) => r.taps || r.inflight || r.dragging || r.busy || r.playBusy || seeking(r));
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user