+119
-25
@@ -59,20 +59,29 @@ els('.room').forEach((node) => {
|
||||
node,
|
||||
slot: el(`.room-slot[data-slot="${key}"]`),
|
||||
level: el('[data-role="level"]', node),
|
||||
bar: el('[data-role="bar"]', node),
|
||||
meter: el('[data-role="meter"]', node),
|
||||
actions: el('[data-role="actions"]', node),
|
||||
toggle: el('[data-role="group"]', node),
|
||||
toggleLabel: el('[data-role="group-label"]', node),
|
||||
prev: el('[data-role="prev"]', node),
|
||||
play: el('[data-role="play"]', node),
|
||||
next: el('[data-role="next"]', node),
|
||||
spotify: el('.spotify-row', node), // absent with no Spotify account set up
|
||||
nowPlaying: el('[data-role="now-playing"]', node),
|
||||
cover: el('[data-role="cover"]', node),
|
||||
song: el('[data-role="song"]', node),
|
||||
artist: el('[data-role="artist"]', node),
|
||||
steps: els('.step', node),
|
||||
volume: null,
|
||||
playState: null,
|
||||
track: null, // {song, artist, image} while something is loaded
|
||||
onSpotify: false,
|
||||
spotifyAccount: null, // the account playing here, whose button gets a border
|
||||
grouped: false,
|
||||
available: false,
|
||||
taps: 0, // button presses not yet sent
|
||||
wanted: null, // a level dragged to, not yet sent
|
||||
dragging: false,
|
||||
inflight: false,
|
||||
busy: false, // a grouping change is in flight
|
||||
playBusy: false,
|
||||
@@ -83,32 +92,42 @@ els('.room').forEach((node) => {
|
||||
const direction = Number(button.dataset.delta);
|
||||
holdable(button, () => nudge(key, direction));
|
||||
});
|
||||
draggable(room);
|
||||
|
||||
// A cover that will not load (gone, or plain http on an https page) is
|
||||
// dropped rather than left as a broken-image box. Its src stays put, so
|
||||
// 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'));
|
||||
room.play.addEventListener('click', () => togglePlay(key));
|
||||
room.next.addEventListener('click', () => skipTrack(key));
|
||||
room.next.addEventListener('click', () => skipTrack(key, 'next'));
|
||||
});
|
||||
|
||||
function paintRoom(room) {
|
||||
const known = room.volume !== null && room.volume !== undefined;
|
||||
room.level.textContent = known ? room.volume + '%' : '—';
|
||||
room.bar.style.width = `${known ? room.volume : 0}%`;
|
||||
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; });
|
||||
paintTrack(room);
|
||||
const playing = room.playState === 'play';
|
||||
room.play.classList.toggle('playing', playing);
|
||||
room.play.disabled = !room.available || room.playState === null;
|
||||
room.play.setAttribute(
|
||||
'aria-label', `${room.node.querySelector('h2').textContent}: ${playing ? 'pause' : 'play'}`);
|
||||
// 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 either here would still work (it shares the
|
||||
// group's transport) but only invites confusion about which card is
|
||||
// actually in charge of it.
|
||||
const transport = room.onSpotify && !room.grouped;
|
||||
room.play.hidden = !transport;
|
||||
room.next.hidden = !transport || !playing;
|
||||
// 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;
|
||||
// Resuming stays on offer while grouped: only the speakers have Spotify
|
||||
// buttons, so hiding them here would leave none at all.
|
||||
@@ -128,6 +147,24 @@ function paintRoom(room) {
|
||||
placeRoom(room);
|
||||
}
|
||||
|
||||
/* Song, artist and cover, each only when HEOS has one. The cover's src is
|
||||
only touched when the track changes, so a poll never makes it flicker. */
|
||||
function paintTrack(room) {
|
||||
const track = room.available ? room.track : null;
|
||||
room.nowPlaying.hidden = !track;
|
||||
if (!track) return;
|
||||
room.song.textContent = track.song;
|
||||
room.artist.textContent = track.artist || '';
|
||||
room.artist.hidden = !track.artist;
|
||||
if (!track.image) {
|
||||
room.cover.hidden = true;
|
||||
room.cover.removeAttribute('src');
|
||||
} else if (room.cover.getAttribute('src') !== track.image) {
|
||||
room.cover.hidden = false;
|
||||
room.cover.src = track.image;
|
||||
}
|
||||
}
|
||||
|
||||
/* A merged room moves into the host's card, because that is what merging
|
||||
means: one group, playing one thing. Leaving puts the card back in its
|
||||
own slot, which is why the slots exist. */
|
||||
@@ -193,13 +230,23 @@ function nudge(key, direction) {
|
||||
thirty requests the speakers then have to chew through. */
|
||||
async function flushVolume(key) {
|
||||
const room = rooms[key];
|
||||
if (room.inflight || !room.taps) return;
|
||||
const steps = room.taps;
|
||||
room.taps = 0;
|
||||
if (room.inflight) return;
|
||||
// A dragged level goes first: any taps still waiting were pressed after it,
|
||||
// so they are meant to move on from it.
|
||||
let body;
|
||||
if (room.wanted !== null) {
|
||||
body = { target: key, level: room.wanted };
|
||||
room.wanted = null;
|
||||
} else if (room.taps) {
|
||||
body = { target: key, steps: room.taps };
|
||||
room.taps = 0;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
room.inflight = true;
|
||||
try {
|
||||
const data = await api('/api/volume', { target: key, steps });
|
||||
if (!room.taps) {
|
||||
const data = await api('/api/volume', body);
|
||||
if (!room.taps && room.wanted === null && !room.dragging) {
|
||||
room.volume = data.level;
|
||||
paintRoom(room);
|
||||
}
|
||||
@@ -208,10 +255,52 @@ async function flushVolume(key) {
|
||||
refresh();
|
||||
} finally {
|
||||
room.inflight = false;
|
||||
if (room.taps) flushVolume(key);
|
||||
if (room.taps || room.wanted !== null) 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;
|
||||
let startLevel = 0;
|
||||
let travel = 0;
|
||||
|
||||
knob.addEventListener('pointerdown', (event) => {
|
||||
if (event.button > 0 || !room.available) return;
|
||||
event.preventDefault();
|
||||
knob.setPointerCapture(event.pointerId);
|
||||
startX = event.clientX;
|
||||
startLevel = room.volume ?? 0;
|
||||
travel = room.meter.clientWidth - knob.offsetWidth; // how far the knob itself can go
|
||||
room.dragging = true;
|
||||
room.meter.classList.add('dragging');
|
||||
});
|
||||
|
||||
knob.addEventListener('pointermove', (event) => {
|
||||
if (!room.dragging || travel <= 0) return;
|
||||
const moved = ((event.clientX - startX) / travel) * 100;
|
||||
const level = Math.round(Math.max(0, Math.min(100, startLevel + moved)));
|
||||
if (level === room.volume) return;
|
||||
room.volume = level;
|
||||
room.wanted = level;
|
||||
room.taps = 0; // an absolute level supersedes any taps not yet sent
|
||||
paintRoom(room);
|
||||
flushVolume(room.key);
|
||||
});
|
||||
|
||||
const stop = () => {
|
||||
room.dragging = false;
|
||||
room.meter.classList.remove('dragging');
|
||||
};
|
||||
knob.addEventListener('pointerup', stop);
|
||||
knob.addEventListener('pointercancel', stop);
|
||||
}
|
||||
|
||||
async function setGrouped(key, joined) {
|
||||
const room = rooms[key];
|
||||
if (room.busy || !room.available) return;
|
||||
@@ -250,13 +339,15 @@ async function togglePlay(key) {
|
||||
}
|
||||
}
|
||||
|
||||
/* Fire-and-forget: the queue's next track has no local state to reconcile,
|
||||
so there is nothing to optimistically flip the way play/pause does. */
|
||||
async function skipTrack(key) {
|
||||
/* Nothing to optimistically flip the way play/pause does -- the panel
|
||||
cannot guess which song comes up -- so it looks again once HEOS has
|
||||
moved on, rather than showing the old song until the next poll. */
|
||||
async function skipTrack(key, direction) {
|
||||
const room = rooms[key];
|
||||
if (!room.available) return;
|
||||
try {
|
||||
await api('/api/skip', { target: key, direction: 'next' });
|
||||
await api('/api/skip', { target: key, direction });
|
||||
setTimeout(refresh, 1000);
|
||||
} catch (error) {
|
||||
toast(error.message);
|
||||
}
|
||||
@@ -359,8 +450,11 @@ function render(state) {
|
||||
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.volume = incoming.volume;
|
||||
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);
|
||||
@@ -395,7 +489,7 @@ async function refresh() {
|
||||
|
||||
function busy() {
|
||||
return sheetOpen()
|
||||
|| Object.values(rooms).some((r) => r.taps || r.inflight || r.busy || r.playBusy);
|
||||
|| Object.values(rooms).some((r) => r.taps || r.inflight || r.dragging || r.busy || r.playBusy);
|
||||
}
|
||||
|
||||
ui.refresh.addEventListener('click', () => {
|
||||
|
||||
Reference in New Issue
Block a user