Add touch-friendly stream scrubbing

This commit is contained in:
2026-06-12 19:46:45 -07:00
parent 562f2b8612
commit c4fb23d971
2 changed files with 196 additions and 35 deletions

View File

@@ -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;

View File

@@ -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 {