This commit is contained in:
+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.
|
||||
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;
|
||||
|
||||
const el = (sel, root = document) => root.querySelector(sel);
|
||||
const els = (sel, root = document) => Array.from(root.querySelectorAll(sel));
|
||||
@@ -77,10 +79,14 @@ const avrTrack = {
|
||||
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,
|
||||
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);
|
||||
|
||||
const rooms = {};
|
||||
let inputs = [];
|
||||
@@ -144,6 +150,10 @@ els('.room').forEach((node) => {
|
||||
inflight: false,
|
||||
busy: false, // a grouping change is in flight
|
||||
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;
|
||||
|
||||
@@ -152,6 +162,7 @@ els('.room').forEach((node) => {
|
||||
holdable(button, () => nudge(key, direction));
|
||||
});
|
||||
draggable(room);
|
||||
seekable(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
|
||||
@@ -213,7 +224,7 @@ function paintRoom(room) {
|
||||
cover's src is only touched when the track changes, so a poll never makes
|
||||
it flicker. */
|
||||
function renderTrack(track, refs) {
|
||||
renderProgress(track, refs.progress);
|
||||
renderProgress(track, refs);
|
||||
refs.nowPlaying.hidden = !track;
|
||||
if (!track) return;
|
||||
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
|
||||
{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. */
|
||||
function renderProgress(track, progress) {
|
||||
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. */
|
||||
function renderProgress(track, refs) {
|
||||
const { progress } = refs;
|
||||
progress.hidden = !track;
|
||||
refs.shown = null;
|
||||
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 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('seekable', known && Boolean(refs.canSeek()));
|
||||
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
|
||||
@@ -572,7 +715,8 @@ async function refresh() {
|
||||
|
||||
function busy() {
|
||||
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', () => {
|
||||
|
||||
Reference in New Issue
Block a user