Add touch-friendly stream scrubbing
This commit is contained in:
187
public/app.js
187
public/app.js
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user