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;