perf: smooth playhead + live scrubbing preview

Drive the timeline progress fill and playhead directly via a
requestAnimationFrame loop (bypassing React state) so the playhead glides
at the display refresh rate during playback instead of stepping ~4x/sec.

Scrubbing now previews frames live like an editor: while dragging, the
video is seeked with coalescing (one seek in flight, chasing the latest
target) so HLS stays responsive without stale-seek pileup. Playback pauses
during a scrub and resumes on release. Dragging tracks the cursor anywhere
on the page via window listeners.
This commit is contained in:
yusufipk
2026-07-10 21:31:36 +07:00
parent 8d7d064647
commit bede216081
3 changed files with 195 additions and 36 deletions
+7 -7
View File
@@ -88,6 +88,8 @@ export function VideoPageContent({
const hlsRef = useRef<Hls | null>(null); const hlsRef = useRef<Hls | null>(null);
const playerRef = useRef<YT.Player | PlayerAdapter | null>(null); const playerRef = useRef<YT.Player | PlayerAdapter | null>(null);
const timelineRef = useRef<HTMLDivElement>(null); const timelineRef = useRef<HTMLDivElement>(null);
const progressRef = useRef<HTMLDivElement>(null);
const playheadRef = useRef<HTMLDivElement>(null);
const videoContainerRef = useRef<HTMLDivElement>(null); const videoContainerRef = useRef<HTMLDivElement>(null);
const pathname = usePathname(); const pathname = usePathname();
const scheduleWatchProgressSaveRef = useRef< const scheduleWatchProgressSaveRef = useRef<
@@ -320,7 +322,6 @@ export function VideoPageContent({
isMuted, isMuted,
isFrameMode, isFrameMode,
frameStepLabel, frameStepLabel,
isDragging,
playbackSpeed, playbackSpeed,
qualityOptions, qualityOptions,
selectedQualityLevel, selectedQualityLevel,
@@ -343,7 +344,6 @@ export function VideoPageContent({
handleQualityChange, handleQualityChange,
handleTimelineMouseDown, handleTimelineMouseDown,
handleTimelineMouseMove, handleTimelineMouseMove,
handleTimelineMouseUp,
toggleFullscreen, toggleFullscreen,
} = useVideoPlayer({ } = useVideoPlayer({
activeVersion, activeVersion,
@@ -355,6 +355,8 @@ export function VideoPageContent({
videoRef, videoRef,
bunnyViewportRef, bunnyViewportRef,
timelineRef, timelineRef,
progressRef,
playheadRef,
hlsRef, hlsRef,
playerRef, playerRef,
formatBunnyQualityLabel, formatBunnyQualityLabel,
@@ -720,11 +722,7 @@ export function VideoPageContent({
} }
return ( return (
<div <div className={cn(containerHeight, 'flex flex-col bg-background overflow-hidden')}>
className={cn(containerHeight, 'flex flex-col bg-background overflow-hidden')}
onMouseUp={handleTimelineMouseUp}
onMouseLeave={() => isDragging && handleTimelineMouseUp()}
>
<div className="flex-1 flex flex-col lg:flex-row overflow-y-auto lg:overflow-hidden min-h-0"> <div className="flex-1 flex flex-col lg:flex-row overflow-y-auto lg:overflow-hidden min-h-0">
<div className={cn('flex-1 w-full flex flex-col min-h-0', isFullscreenMode && 'relative')}> <div className={cn('flex-1 w-full flex flex-col min-h-0', isFullscreenMode && 'relative')}>
<VideoPageHeader <VideoPageHeader
@@ -783,6 +781,8 @@ export function VideoPageContent({
iframeRef={iframeRef} iframeRef={iframeRef}
bunnyViewportRef={bunnyViewportRef} bunnyViewportRef={bunnyViewportRef}
timelineRef={timelineRef} timelineRef={timelineRef}
progressRef={progressRef}
playheadRef={playheadRef}
videoContainerRef={videoContainerRef} videoContainerRef={videoContainerRef}
isFullscreenMode={isFullscreenMode} isFullscreenMode={isFullscreenMode}
cursorIdle={cursorIdle} cursorIdle={cursorIdle}
+177 -25
View File
@@ -1,7 +1,15 @@
'use client'; 'use client';
/* eslint-disable react-hooks/set-state-in-effect */ /* eslint-disable react-hooks/set-state-in-effect */
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react'; import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type RefObject,
} from 'react';
import Hls, { type Level } from 'hls.js'; import Hls, { type Level } from 'hls.js';
import { toast } from 'sonner'; import { toast } from 'sonner';
import type { AnnotationStroke } from '@/components/annotation-canvas'; import type { AnnotationStroke } from '@/components/annotation-canvas';
@@ -23,6 +31,8 @@ interface UseVideoPlayerParams {
videoRef: RefObject<HTMLVideoElement | null>; videoRef: RefObject<HTMLVideoElement | null>;
bunnyViewportRef: RefObject<HTMLDivElement | null>; bunnyViewportRef: RefObject<HTMLDivElement | null>;
timelineRef: RefObject<HTMLDivElement | null>; timelineRef: RefObject<HTMLDivElement | null>;
progressRef: RefObject<HTMLDivElement | null>;
playheadRef: RefObject<HTMLDivElement | null>;
hlsRef: RefObject<Hls | null>; hlsRef: RefObject<Hls | null>;
playerRef: RefObject<YT.Player | PlayerAdapter | null>; playerRef: RefObject<YT.Player | PlayerAdapter | null>;
formatBunnyQualityLabel: (level: { height?: number; bitrate?: number }, index: number) => string; formatBunnyQualityLabel: (level: { height?: number; bitrate?: number }, index: number) => string;
@@ -43,6 +53,8 @@ export function useVideoPlayer({
videoRef, videoRef,
bunnyViewportRef, bunnyViewportRef,
timelineRef, timelineRef,
progressRef,
playheadRef,
hlsRef, hlsRef,
playerRef, playerRef,
formatBunnyQualityLabel, formatBunnyQualityLabel,
@@ -61,6 +73,17 @@ export function useVideoPlayer({
const [estimatedFrameRate, setEstimatedFrameRate] = useState<number | null>(null); const [estimatedFrameRate, setEstimatedFrameRate] = useState<number | null>(null);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const isDraggingRef = useRef(false); const isDraggingRef = useRef(false);
// Scrubbing: the playhead position is driven directly via DOM (rAF) to avoid
// per-frame React re-renders. These refs feed that loop.
const dragTimeRef = useRef(0);
const dragRectRef = useRef<DOMRect | null>(null);
const durationRef = useRef(0);
// Live scrubbing: coalesce seeks so we never queue stale ones (keeps HLS
// responsive). scrubTargetRef is the latest desired time; isSeekingRef is true
// while a seek is in flight; wasPlayingBeforeScrubRef restores play on release.
const scrubTargetRef = useRef<number | null>(null);
const isSeekingRef = useRef(false);
const wasPlayingBeforeScrubRef = useRef(false);
const [playbackSpeed, setPlaybackSpeed] = useState(1); const [playbackSpeed, setPlaybackSpeed] = useState(1);
const [qualityOptions, setQualityOptions] = useState<BunnyQualityOption[]>([]); const [qualityOptions, setQualityOptions] = useState<BunnyQualityOption[]>([]);
const [selectedQualityLevel, setSelectedQualityLevel] = useState<number>(-1); const [selectedQualityLevel, setSelectedQualityLevel] = useState<number>(-1);
@@ -867,6 +890,91 @@ export function useVideoPlayer({
return videoDuration || activeVersion?.duration || 0; return videoDuration || activeVersion?.duration || 0;
}, [videoDuration, activeVersion?.duration]); }, [videoDuration, activeVersion?.duration]);
useEffect(() => {
durationRef.current = duration;
}, [duration]);
// Position the progress fill + playhead directly on the DOM (no React state /
// re-render) so scrubbing and playback stay smooth at the display's refresh
// rate instead of stepping ~4x/sec.
const applyPlayhead = useCallback(
(time: number) => {
const d = durationRef.current;
const percent = d > 0 ? Math.max(0, Math.min(100, (time / d) * 100)) : 0;
if (progressRef.current) progressRef.current.style.width = `${percent}%`;
if (playheadRef.current) playheadRef.current.style.left = `calc(${percent}% - 2px)`;
},
[progressRef, playheadRef]
);
// Live-preview seek for the HTML5 video element (Bunny/R2/direct). Coalesced:
// only one seek is in flight at a time; the newest target is chased on
// 'seeked' so we stay responsive without flooding hls.js with stale seeks.
const requestScrubSeek = useCallback(
(time: number) => {
const videoEl = videoRef.current;
if (!videoEl) return; // YouTube (iframe) keeps seek-on-release only
scrubTargetRef.current = time;
if (isSeekingRef.current) return;
isSeekingRef.current = true;
try {
videoEl.currentTime = time;
} catch {
isSeekingRef.current = false;
}
},
[videoRef]
);
useEffect(() => {
const videoEl = videoRef.current;
if (!videoEl) return;
const onSeeked = () => {
const target = scrubTargetRef.current;
if (
isDraggingRef.current &&
target !== null &&
Math.abs(videoEl.currentTime - target) > 0.04
) {
try {
videoEl.currentTime = target; // chase the latest scrub position
} catch {
isSeekingRef.current = false;
}
} else {
isSeekingRef.current = false;
}
};
videoEl.addEventListener('seeked', onSeeked);
return () => videoEl.removeEventListener('seeked', onSeeked);
}, [videoRef, isReady, activeProviderId]);
// While playing (live time) or dragging (cursor position), drive the playhead
// from a requestAnimationFrame loop for 60fps-smooth motion. During a drag we
// also request a (coalesced) seek so the frame previews live like an editor.
useEffect(() => {
if (!isPlaying && !isDragging) return;
let raf = 0;
const tick = () => {
if (isDraggingRef.current) {
applyPlayhead(dragTimeRef.current);
requestScrubSeek(dragTimeRef.current);
} else if (playerRef.current?.getCurrentTime) {
applyPlayhead(playerRef.current.getCurrentTime());
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [isPlaying, isDragging, applyPlayhead, requestScrubSeek, playerRef]);
// When idle (paused, not dragging), keep the playhead in sync with seeks and
// comment jumps. useLayoutEffect avoids a one-frame flash on mount/seek.
useLayoutEffect(() => {
if (isPlaying || isDragging) return;
applyPlayhead(currentTime);
}, [currentTime, isPlaying, isDragging, applyPlayhead]);
const resolveSkipAmount = useCallback( const resolveSkipAmount = useCallback(
(seconds: number) => { (seconds: number) => {
if (!isFrameMode) return seconds; if (!isFrameMode) return seconds;
@@ -1126,43 +1234,87 @@ export function useVideoPlayer({
[activeProviderId, bunnySourcePreference, hlsRef, isPlaying, playerRef, videoRef] [activeProviderId, bunnySourcePreference, hlsRef, isPlaying, playerRef, videoRef]
); );
const handleTimelineClick = useCallback( // Convert a clientX into a time using the timeline rect captured at drag start
(e: React.MouseEvent<HTMLDivElement>) => { // (avoids a layout read on every move).
if (!timelineRef.current) return; const timeFromClientX = useCallback((clientX: number) => {
const rect = timelineRef.current.getBoundingClientRect(); const rect = dragRectRef.current;
const x = e.clientX - rect.left; if (!rect || rect.width === 0) return 0;
const percentage = Math.max(0, Math.min(1, x / rect.width)); const percentage = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
const newTime = percentage * duration; return percentage * durationRef.current;
handleSeekToTimestamp(newTime); }, []);
},
[duration, handleSeekToTimestamp, timelineRef]
);
const handleTimelineMouseDown = useCallback( const handleTimelineMouseDown = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => { (e: React.MouseEvent<HTMLDivElement>) => {
if (!timelineRef.current) return;
// Cache the rect once for the whole drag; the rAF loop reads dragTimeRef.
dragRectRef.current = timelineRef.current.getBoundingClientRect();
const newTime = timeFromClientX(e.clientX);
dragTimeRef.current = newTime;
// Freeze playback while scrubbing so the previewed frames don't fight the
// player; resume on release if it was playing.
wasPlayingBeforeScrubRef.current = isPlaying;
if (isPlaying) playerRef.current?.pauseVideo?.();
setIsDragging(true); setIsDragging(true);
handleTimelineClick(e); applyPlayhead(newTime);
setCurrentTime(newTime);
requestScrubSeek(newTime);
}, },
[handleTimelineClick] [applyPlayhead, requestScrubSeek, timeFromClientX, timelineRef, isPlaying, playerRef]
); );
const handleTimelineMouseMove = useCallback( const handleTimelineMouseMove = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => { (e: React.MouseEvent<HTMLDivElement>) => {
if (!isDragging || !timelineRef.current) return; if (!isDraggingRef.current) return;
const rect = timelineRef.current.getBoundingClientRect(); const newTime = timeFromClientX(e.clientX);
const x = e.clientX - rect.left; dragTimeRef.current = newTime;
const percentage = Math.max(0, Math.min(1, x / rect.width)); setCurrentTime(newTime);
setCurrentTime(percentage * duration);
}, },
[isDragging, duration, timelineRef] [timeFromClientX]
); );
const handleTimelineMouseUp = useCallback(() => { // Commit the final scrub position and restore playback if needed.
if (isDragging) { const endScrub = useCallback(() => {
handleSeekToTimestamp(currentTime); if (!isDraggingRef.current) return;
setIsDragging(false); setIsDragging(false);
const finalTime = dragTimeRef.current;
setCurrentTime(finalTime);
const videoEl = videoRef.current;
if (videoEl) {
try {
videoEl.currentTime = finalTime;
} catch {
// ignore
}
} else {
playerRef.current?.seekTo?.(finalTime, true);
} }
}, [isDragging, currentTime, handleSeekToTimestamp]); if (wasPlayingBeforeScrubRef.current) {
playerRef.current?.playVideo?.();
wasPlayingBeforeScrubRef.current = false;
}
}, [playerRef, videoRef]);
const handleTimelineMouseUp = useCallback(() => {
endScrub();
}, [endScrub]);
// While dragging, track the cursor anywhere on the page (not just over the
// timeline) so a fast or off-bar drag keeps scrubbing smoothly, and release
// anywhere to commit the seek.
useEffect(() => {
if (!isDragging) return;
const onMove = (e: MouseEvent) => {
const newTime = timeFromClientX(e.clientX);
dragTimeRef.current = newTime;
setCurrentTime(newTime);
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', endScrub);
return () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', endScrub);
};
}, [isDragging, timeFromClientX, endScrub]);
return { return {
isReady, isReady,
+11 -4
View File
@@ -41,6 +41,8 @@ interface PlayerCoreProps {
iframeRef: RefObject<HTMLIFrameElement | null>; iframeRef: RefObject<HTMLIFrameElement | null>;
bunnyViewportRef: RefObject<HTMLDivElement | null>; bunnyViewportRef: RefObject<HTMLDivElement | null>;
timelineRef: RefObject<HTMLDivElement | null>; timelineRef: RefObject<HTMLDivElement | null>;
progressRef: RefObject<HTMLDivElement | null>;
playheadRef: RefObject<HTMLDivElement | null>;
videoContainerRef: RefObject<HTMLDivElement | null>; videoContainerRef: RefObject<HTMLDivElement | null>;
isFullscreenMode: boolean; isFullscreenMode: boolean;
cursorIdle: boolean; cursorIdle: boolean;
@@ -105,6 +107,8 @@ export const PlayerCore = memo(function PlayerCore({
iframeRef, iframeRef,
bunnyViewportRef, bunnyViewportRef,
timelineRef, timelineRef,
progressRef,
playheadRef,
videoContainerRef, videoContainerRef,
isFullscreenMode, isFullscreenMode,
cursorIdle, cursorIdle,
@@ -501,14 +505,17 @@ export const PlayerCore = memo(function PlayerCore({
onMouseDown={handleTimelineMouseDown} onMouseDown={handleTimelineMouseDown}
onMouseMove={handleTimelineMouseMove} onMouseMove={handleTimelineMouseMove}
> >
{/* Position (width/left) is driven directly on the DOM via a rAF loop
in use-video-player for smooth scrubbing/playback; see progressRef
and playheadRef. Do not bind it to React state here. */}
<div <div
className="absolute left-0 top-0 h-full bg-primary/30 rounded pointer-events-none" ref={progressRef}
style={{ width: `${duration > 0 ? (currentTime / duration) * 100 : 0}%` }} className="absolute left-0 top-0 h-full w-0 bg-primary/30 rounded pointer-events-none"
/> />
<div <div
className="absolute top-0 h-full w-1 bg-primary rounded pointer-events-none" ref={playheadRef}
style={{ left: `calc(${duration > 0 ? (currentTime / duration) * 100 : 0}% - 2px)` }} className="absolute top-0 left-0 h-full w-1 bg-primary rounded pointer-events-none will-change-[left]"
/> />
{commentMarkers.map((comment) => { {commentMarkers.map((comment) => {