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, duration: null,
seekable: false, seekable: false,
isSeeking: false, isSeeking: false,
seekPointerId: null,
seekPreviewTime: 0, seekPreviewTime: 0,
lastSeekCommitTime: null,
lastSeekCommitAt: 0,
frameWatchdogTimer: 0, frameWatchdogTimer: 0,
lastFramePacketAt: 0, lastFramePacketAt: 0,
lastFramePaintedAt: 0, lastFramePaintedAt: 0,
@@ -285,33 +288,50 @@ elements.mute.addEventListener('click', () => {
syncControlLabels(); syncControlLabels();
}); });
elements.seek.addEventListener('pointerdown', () => { elements.seek.addEventListener('pointerdown', (event) => {
if (!state.seekable) { beginSeekScrub(event);
return;
}
state.isSeeking = true;
state.seekPreviewTime = Number(elements.seek.value);
setControlsVisible(true);
syncPlayhead();
}); });
elements.seek.addEventListener('input', () => { elements.seek.addEventListener('input', () => {
if (!state.seekable) { updateSeekScrubPreview();
return; });
}
state.isSeeking = true; elements.seek.addEventListener('pointermove', (event) => {
state.seekPreviewTime = Number(elements.seek.value); if (state.isSeeking && state.seekPointerId === null) {
syncPlayhead(); updateSeekScrubPreview(event);
}
});
elements.seek.addEventListener('pointerup', (event) => {
updateSeekScrubPreview(event);
commitSeekScrub(event);
});
elements.seek.addEventListener('pointercancel', () => {
cancelSeekScrub();
}); });
elements.seek.addEventListener('change', () => { elements.seek.addEventListener('change', () => {
if (!state.seekable) { commitSeekScrub({ force: true });
return; });
}
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', () => { elements.audio.addEventListener('play', () => {
@@ -969,7 +989,10 @@ function stopSession({ showEntry: shouldShowEntry = true } = {}) {
state.duration = null; state.duration = null;
state.seekable = false; state.seekable = false;
state.isSeeking = false; state.isSeeking = false;
state.seekPointerId = null;
state.seekPreviewTime = 0; state.seekPreviewTime = 0;
state.lastSeekCommitTime = null;
state.lastSeekCommitAt = 0;
state.lastFramePacketAt = 0; state.lastFramePacketAt = 0;
state.lastFramePaintedAt = 0; state.lastFramePaintedAt = 0;
state.lastFrameClockSentAt = 0; state.lastFrameClockSentAt = 0;
@@ -1091,6 +1114,8 @@ function logRenderError(error) {
async function seekTo(value) { async function seekTo(value) {
if (!state.session || !state.seekable) { if (!state.session || !state.seekable) {
state.isSeeking = false; state.isSeeking = false;
state.seekPointerId = null;
elements.playhead.classList.remove('scrubbing');
syncPlayhead(); syncPlayhead();
return; return;
} }
@@ -1099,6 +1124,8 @@ async function seekTo(value) {
if (!Number.isFinite(duration) || duration <= 0) { if (!Number.isFinite(duration) || duration <= 0) {
state.isSeeking = false; state.isSeeking = false;
state.seekPointerId = null;
elements.playhead.classList.remove('scrubbing');
syncPlayhead(); syncPlayhead();
return; return;
} }
@@ -1112,9 +1139,11 @@ async function seekTo(value) {
state.playbackOffset = targetTime; state.playbackOffset = targetTime;
state.seekPreviewTime = targetTime; state.seekPreviewTime = targetTime;
state.isSeeking = false; state.isSeeking = false;
state.seekPointerId = null;
state.frameCount = 0; state.frameCount = 0;
state.lastFramePacketAt = 0; state.lastFramePacketAt = 0;
state.lastFramePaintedAt = 0; state.lastFramePaintedAt = 0;
elements.playhead.classList.remove('scrubbing');
elements.loader.hidden = false; elements.loader.hidden = false;
clearPlayerMessage(); clearPlayerMessage();
clearFrameQueue(); clearFrameQueue();
@@ -1215,21 +1244,139 @@ function syncPlayhead() {
const currentTime = getVisiblePlaybackTime(); const currentTime = getVisiblePlaybackTime();
const duration = state.duration; const duration = state.duration;
const hasDuration = Number.isFinite(duration) && duration > 0; const hasDuration = Number.isFinite(duration) && duration > 0;
const canScrub = state.seekable && hasDuration;
const max = hasDuration ? duration : 1; const max = hasDuration ? duration : 1;
const value = hasDuration ? clampNumber(currentTime, 0, max) : 0; const value = hasDuration ? clampNumber(currentTime, 0, max) : 0;
const progress = hasDuration && max > 0 ? (value / max) * 100 : 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.currentTime.textContent = formatTime(currentTime);
elements.totalTime.textContent = formatTime(duration); elements.totalTime.textContent = formatTime(duration);
elements.seek.max = String(max); elements.seek.max = String(max);
elements.seek.value = String(value); elements.seek.value = String(value);
elements.seek.disabled = !state.seekable; elements.seek.disabled = !canScrub;
elements.seek.setAttribute('aria-valuemin', '0'); elements.seek.setAttribute('aria-valuemin', '0');
elements.seek.setAttribute('aria-valuemax', String(Math.round(max))); elements.seek.setAttribute('aria-valuemax', String(Math.round(max)));
elements.seek.setAttribute('aria-valuenow', String(Math.round(value))); 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}%`); 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() { function getVisiblePlaybackTime() {
if (state.isSeeking) { if (state.isSeeking) {
return Number.isFinite(state.seekPreviewTime) ? state.seekPreviewTime : 0; return Number.isFinite(state.seekPreviewTime) ? state.seekPreviewTime : 0;

View File

@@ -6,6 +6,9 @@
--glass-strong: rgba(10, 10, 10, 0.82); --glass-strong: rgba(10, 10, 10, 0.82);
--line: rgba(246, 241, 232, 0.18); --line: rgba(246, 241, 232, 0.18);
--accent: #e8a84f; --accent: #e8a84f;
--seek-track-height: 0.5rem;
--seek-thumb-size: 2.65rem;
--seek-hit-height: 3.25rem;
} }
* { * {
@@ -288,10 +291,10 @@ canvas {
.playhead { .playhead {
position: absolute; position: absolute;
top: -1.75rem; top: -3.2rem;
right: 1.1rem; right: 1.1rem;
left: 1.1rem; left: 1.1rem;
height: 2.45rem; height: 3.85rem;
} }
.time-badge { .time-badge {
@@ -327,11 +330,12 @@ canvas {
bottom: 0; bottom: 0;
left: 0; left: 0;
width: 100%; width: 100%;
height: 1.35rem; height: var(--seek-hit-height);
margin: 0; margin: 0;
appearance: none; appearance: none;
background: transparent; background: transparent;
cursor: pointer; cursor: pointer;
touch-action: none;
--progress: 0%; --progress: 0%;
} }
@@ -340,7 +344,7 @@ canvas {
} }
.seek-slider::-webkit-slider-runnable-track { .seek-slider::-webkit-slider-runnable-track {
height: 0.28rem; height: var(--seek-track-height);
border-radius: 999px; border-radius: 999px;
background: background:
linear-gradient( linear-gradient(
@@ -353,35 +357,45 @@ canvas {
} }
.seek-slider::-moz-range-track { .seek-slider::-moz-range-track {
height: 0.28rem; height: var(--seek-track-height);
border-radius: 999px; border-radius: 999px;
background: rgba(246, 241, 232, 0.28); background: rgba(246, 241, 232, 0.28);
} }
.seek-slider::-moz-range-progress { .seek-slider::-moz-range-progress {
height: 0.28rem; height: var(--seek-track-height);
border-radius: 999px; border-radius: 999px;
background: var(--accent); background: var(--accent);
} }
.seek-slider::-webkit-slider-thumb { .seek-slider::-webkit-slider-thumb {
width: 1rem; width: var(--seek-thumb-size);
height: 1rem; height: var(--seek-thumb-size);
margin-top: -0.36rem; margin-top: calc((var(--seek-track-height) - var(--seek-thumb-size)) / 2);
appearance: none; appearance: none;
border: 2px solid rgba(7, 7, 7, 0.75); border: 3px solid rgba(7, 7, 7, 0.78);
border-radius: 50%; border-radius: 50%;
background: var(--fg); 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 { .seek-slider::-moz-range-thumb {
width: 0.86rem; width: var(--seek-thumb-size);
height: 0.86rem; height: var(--seek-thumb-size);
border: 2px solid rgba(7, 7, 7, 0.75); border: 3px solid rgba(7, 7, 7, 0.78);
border-radius: 50%; border-radius: 50%;
background: var(--fg); 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 { .seek-slider:disabled::-webkit-slider-thumb {