Make progress bar cursor draggable
Deploy HEOS panel / deploy (push) Successful in 26s

This commit is contained in:
2026-09-16 23:19:08 +02:00
parent 51dff45b54
commit ec684b7803
12 changed files with 319 additions and 29 deletions
+149 -5
View File
@@ -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', () => {
+38 -4
View File
@@ -105,7 +105,7 @@ svg { width: 22px; height: 22px; fill: currentColor; }
margin: 0; min-width: 0;
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; }
.pill {
@@ -138,7 +138,8 @@ svg { width: 22px; height: 22px; fill: currentColor; }
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
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 {
display: block; height: 100%; width: calc(var(--progress, 0) * 1%);
border-radius: 999px; background: var(--muted);
@@ -153,7 +154,40 @@ svg { width: 22px; height: 22px; fill: currentColor; }
transform: translateY(-50%);
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 { display: flex; align-items: center; gap: 14px; }
@@ -262,7 +296,7 @@ svg { width: 22px; height: 22px; fill: currentColor; }
/* --- source card ------------------------------------------------------ */
.source-button {
display: flex; align-items: center; gap: 12px;
width: 100%; min-height: 64px;
width: 100%; min-height: 50px;
padding: 10px 14px;
border-radius: 16px;
background: var(--raised);