From c4fb23d9716efa33ed37a5fe820339232f923bfe Mon Sep 17 00:00:00 2001 From: James Magahern Date: Fri, 12 Jun 2026 19:46:45 -0700 Subject: [PATCH] Add touch-friendly stream scrubbing --- public/app.js | 187 +++++++++++++++++++++++++++++++++++++++++----- public/styles.css | 44 +++++++---- 2 files changed, 196 insertions(+), 35 deletions(-) diff --git a/public/app.js b/public/app.js index 3307545..f685136 100644 --- a/public/app.js +++ b/public/app.js @@ -81,7 +81,10 @@ const state = { duration: null, seekable: false, isSeeking: false, + seekPointerId: null, seekPreviewTime: 0, + lastSeekCommitTime: null, + lastSeekCommitAt: 0, frameWatchdogTimer: 0, lastFramePacketAt: 0, lastFramePaintedAt: 0, @@ -285,33 +288,50 @@ elements.mute.addEventListener('click', () => { syncControlLabels(); }); -elements.seek.addEventListener('pointerdown', () => { - if (!state.seekable) { - return; - } - - state.isSeeking = true; - state.seekPreviewTime = Number(elements.seek.value); - setControlsVisible(true); - syncPlayhead(); +elements.seek.addEventListener('pointerdown', (event) => { + beginSeekScrub(event); }); elements.seek.addEventListener('input', () => { - if (!state.seekable) { - return; - } + updateSeekScrubPreview(); +}); - state.isSeeking = true; - state.seekPreviewTime = Number(elements.seek.value); - syncPlayhead(); +elements.seek.addEventListener('pointermove', (event) => { + if (state.isSeeking && state.seekPointerId === null) { + updateSeekScrubPreview(event); + } +}); + +elements.seek.addEventListener('pointerup', (event) => { + updateSeekScrubPreview(event); + commitSeekScrub(event); +}); + +elements.seek.addEventListener('pointercancel', () => { + cancelSeekScrub(); }); elements.seek.addEventListener('change', () => { - if (!state.seekable) { - return; - } + commitSeekScrub({ force: true }); +}); - void seekTo(Number(elements.seek.value)); +window.addEventListener('pointerup', (event) => { + if (state.seekPointerId === event.pointerId) { + updateSeekScrubPreview(event); + commitSeekScrub(event); + } +}); + +window.addEventListener('pointermove', (event) => { + if (state.seekPointerId === event.pointerId) { + updateSeekScrubPreview(event); + } +}); + +window.addEventListener('pointercancel', (event) => { + if (state.seekPointerId === event.pointerId) { + cancelSeekScrub(); + } }); elements.audio.addEventListener('play', () => { @@ -969,7 +989,10 @@ function stopSession({ showEntry: shouldShowEntry = true } = {}) { state.duration = null; state.seekable = false; state.isSeeking = false; + state.seekPointerId = null; state.seekPreviewTime = 0; + state.lastSeekCommitTime = null; + state.lastSeekCommitAt = 0; state.lastFramePacketAt = 0; state.lastFramePaintedAt = 0; state.lastFrameClockSentAt = 0; @@ -1091,6 +1114,8 @@ function logRenderError(error) { async function seekTo(value) { if (!state.session || !state.seekable) { state.isSeeking = false; + state.seekPointerId = null; + elements.playhead.classList.remove('scrubbing'); syncPlayhead(); return; } @@ -1099,6 +1124,8 @@ async function seekTo(value) { if (!Number.isFinite(duration) || duration <= 0) { state.isSeeking = false; + state.seekPointerId = null; + elements.playhead.classList.remove('scrubbing'); syncPlayhead(); return; } @@ -1112,9 +1139,11 @@ async function seekTo(value) { state.playbackOffset = targetTime; state.seekPreviewTime = targetTime; state.isSeeking = false; + state.seekPointerId = null; state.frameCount = 0; state.lastFramePacketAt = 0; state.lastFramePaintedAt = 0; + elements.playhead.classList.remove('scrubbing'); elements.loader.hidden = false; clearPlayerMessage(); clearFrameQueue(); @@ -1215,21 +1244,139 @@ function syncPlayhead() { const currentTime = getVisiblePlaybackTime(); const duration = state.duration; const hasDuration = Number.isFinite(duration) && duration > 0; + const canScrub = state.seekable && hasDuration; const max = hasDuration ? duration : 1; const value = hasDuration ? clampNumber(currentTime, 0, max) : 0; const progress = hasDuration && max > 0 ? (value / max) * 100 : 0; + elements.playhead.hidden = !canScrub; + elements.playhead.setAttribute('aria-hidden', String(!canScrub)); elements.currentTime.textContent = formatTime(currentTime); elements.totalTime.textContent = formatTime(duration); elements.seek.max = String(max); elements.seek.value = String(value); - elements.seek.disabled = !state.seekable; + elements.seek.disabled = !canScrub; elements.seek.setAttribute('aria-valuemin', '0'); elements.seek.setAttribute('aria-valuemax', String(Math.round(max))); elements.seek.setAttribute('aria-valuenow', String(Math.round(value))); + elements.seek.setAttribute('aria-valuetext', `${formatTime(value)} of ${formatTime(duration)}`); elements.seek.style.setProperty('--progress', `${progress}%`); } +function beginSeekScrub(event) { + if (!canSeekScrub()) { + return; + } + + event?.preventDefault(); + state.isSeeking = true; + state.seekPointerId = event?.pointerId ?? null; + state.seekPreviewTime = getSeekTimeFromPointer(event) ?? getSeekSliderValue(); + elements.playhead.classList.add('scrubbing'); + setControlsVisible(true); + clearHideControlsTimer(); + + if (event?.pointerId !== undefined && typeof elements.seek.setPointerCapture === 'function') { + try { + elements.seek.setPointerCapture(event.pointerId); + } catch { + state.seekPointerId = null; + } + } + + syncPlayhead(); +} + +function updateSeekScrubPreview(event) { + if (!canSeekScrub()) { + return; + } + + event?.preventDefault?.(); + state.isSeeking = true; + state.seekPreviewTime = getSeekTimeFromPointer(event) ?? getSeekSliderValue(); + elements.playhead.classList.add('scrubbing'); + syncPlayhead(); +} + +function commitSeekScrub(event) { + const force = event?.force === true; + + if (state.seekPointerId !== null && event?.pointerId !== undefined && state.seekPointerId !== event.pointerId) { + return; + } + + if (!canSeekScrub()) { + cancelSeekScrub(); + return; + } + + if (!force && !state.isSeeking) { + return; + } + + const targetTime = getSeekSliderValue(); + const now = Date.now(); + + if ( + Number.isFinite(state.lastSeekCommitTime) && + Math.abs(targetTime - state.lastSeekCommitTime) < 0.05 && + now - state.lastSeekCommitAt < 1000 + ) { + state.isSeeking = false; + state.seekPointerId = null; + elements.playhead.classList.remove('scrubbing'); + syncPlayhead(); + return; + } + + state.lastSeekCommitTime = targetTime; + state.lastSeekCommitAt = now; + void seekTo(targetTime); +} + +function cancelSeekScrub() { + state.isSeeking = false; + state.seekPointerId = null; + elements.playhead.classList.remove('scrubbing'); + syncPlayhead(); +} + +function canSeekScrub() { + return Boolean(state.session && state.seekable && Number.isFinite(state.duration) && state.duration > 0); +} + +function getSeekSliderValue() { + const duration = state.duration; + const value = Number(elements.seek.value); + + if (!Number.isFinite(duration) || duration <= 0) { + return 0; + } + + if (state.isSeeking && Number.isFinite(state.seekPreviewTime)) { + return clampNumber(state.seekPreviewTime, 0, duration); + } + + return clampNumber(Number.isFinite(value) ? value : 0, 0, duration); +} + +function getSeekTimeFromPointer(event) { + const duration = state.duration; + + if (!event || !Number.isFinite(event.clientX) || !Number.isFinite(duration) || duration <= 0) { + return null; + } + + const rect = elements.seek.getBoundingClientRect(); + + if (rect.width <= 0) { + return null; + } + + return clampNumber(((event.clientX - rect.left) / rect.width) * duration, 0, duration); +} + function getVisiblePlaybackTime() { if (state.isSeeking) { return Number.isFinite(state.seekPreviewTime) ? state.seekPreviewTime : 0; diff --git a/public/styles.css b/public/styles.css index ead759d..7218f3b 100644 --- a/public/styles.css +++ b/public/styles.css @@ -6,6 +6,9 @@ --glass-strong: rgba(10, 10, 10, 0.82); --line: rgba(246, 241, 232, 0.18); --accent: #e8a84f; + --seek-track-height: 0.5rem; + --seek-thumb-size: 2.65rem; + --seek-hit-height: 3.25rem; } * { @@ -288,10 +291,10 @@ canvas { .playhead { position: absolute; - top: -1.75rem; + top: -3.2rem; right: 1.1rem; left: 1.1rem; - height: 2.45rem; + height: 3.85rem; } .time-badge { @@ -327,11 +330,12 @@ canvas { bottom: 0; left: 0; width: 100%; - height: 1.35rem; + height: var(--seek-hit-height); margin: 0; appearance: none; background: transparent; cursor: pointer; + touch-action: none; --progress: 0%; } @@ -340,7 +344,7 @@ canvas { } .seek-slider::-webkit-slider-runnable-track { - height: 0.28rem; + height: var(--seek-track-height); border-radius: 999px; background: linear-gradient( @@ -353,35 +357,45 @@ canvas { } .seek-slider::-moz-range-track { - height: 0.28rem; + height: var(--seek-track-height); border-radius: 999px; background: rgba(246, 241, 232, 0.28); } .seek-slider::-moz-range-progress { - height: 0.28rem; + height: var(--seek-track-height); border-radius: 999px; background: var(--accent); } .seek-slider::-webkit-slider-thumb { - width: 1rem; - height: 1rem; - margin-top: -0.36rem; + width: var(--seek-thumb-size); + height: var(--seek-thumb-size); + margin-top: calc((var(--seek-track-height) - var(--seek-thumb-size)) / 2); appearance: none; - border: 2px solid rgba(7, 7, 7, 0.75); + border: 3px solid rgba(7, 7, 7, 0.78); border-radius: 50%; background: var(--fg); - box-shadow: 0 8px 22px rgba(0, 0, 0, 0.48); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.58); } .seek-slider::-moz-range-thumb { - width: 0.86rem; - height: 0.86rem; - border: 2px solid rgba(7, 7, 7, 0.75); + width: var(--seek-thumb-size); + height: var(--seek-thumb-size); + border: 3px solid rgba(7, 7, 7, 0.78); border-radius: 50%; background: var(--fg); - box-shadow: 0 8px 22px rgba(0, 0, 0, 0.48); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.58); +} + +.seek-slider:focus-visible::-webkit-slider-thumb, +.playhead.scrubbing .seek-slider::-webkit-slider-thumb { + box-shadow: 0 0 0 0.35rem rgba(232, 168, 79, 0.32), 0 10px 30px rgba(0, 0, 0, 0.58); +} + +.seek-slider:focus-visible::-moz-range-thumb, +.playhead.scrubbing .seek-slider::-moz-range-thumb { + box-shadow: 0 0 0 0.35rem rgba(232, 168, 79, 0.32), 0 10px 30px rgba(0, 0, 0, 0.58); } .seek-slider:disabled::-webkit-slider-thumb {