From bede21608131f4895c7615d4e1cecabf5c641444 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Fri, 10 Jul 2026 21:31:36 +0700 Subject: [PATCH] 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. --- components/video-page-content.tsx | 14 +- .../video-page/hooks/use-video-player.ts | 202 +++++++++++++++--- components/video-page/player-core.tsx | 15 +- 3 files changed, 195 insertions(+), 36 deletions(-) diff --git a/components/video-page-content.tsx b/components/video-page-content.tsx index e116e3a..77c2428 100644 --- a/components/video-page-content.tsx +++ b/components/video-page-content.tsx @@ -88,6 +88,8 @@ export function VideoPageContent({ const hlsRef = useRef(null); const playerRef = useRef(null); const timelineRef = useRef(null); + const progressRef = useRef(null); + const playheadRef = useRef(null); const videoContainerRef = useRef(null); const pathname = usePathname(); const scheduleWatchProgressSaveRef = useRef< @@ -320,7 +322,6 @@ export function VideoPageContent({ isMuted, isFrameMode, frameStepLabel, - isDragging, playbackSpeed, qualityOptions, selectedQualityLevel, @@ -343,7 +344,6 @@ export function VideoPageContent({ handleQualityChange, handleTimelineMouseDown, handleTimelineMouseMove, - handleTimelineMouseUp, toggleFullscreen, } = useVideoPlayer({ activeVersion, @@ -355,6 +355,8 @@ export function VideoPageContent({ videoRef, bunnyViewportRef, timelineRef, + progressRef, + playheadRef, hlsRef, playerRef, formatBunnyQualityLabel, @@ -720,11 +722,7 @@ export function VideoPageContent({ } return ( -
isDragging && handleTimelineMouseUp()} - > +
; bunnyViewportRef: RefObject; timelineRef: RefObject; + progressRef: RefObject; + playheadRef: RefObject; hlsRef: RefObject; playerRef: RefObject; formatBunnyQualityLabel: (level: { height?: number; bitrate?: number }, index: number) => string; @@ -43,6 +53,8 @@ export function useVideoPlayer({ videoRef, bunnyViewportRef, timelineRef, + progressRef, + playheadRef, hlsRef, playerRef, formatBunnyQualityLabel, @@ -61,6 +73,17 @@ export function useVideoPlayer({ const [estimatedFrameRate, setEstimatedFrameRate] = useState(null); const [isDragging, setIsDragging] = useState(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(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(null); + const isSeekingRef = useRef(false); + const wasPlayingBeforeScrubRef = useRef(false); const [playbackSpeed, setPlaybackSpeed] = useState(1); const [qualityOptions, setQualityOptions] = useState([]); const [selectedQualityLevel, setSelectedQualityLevel] = useState(-1); @@ -867,6 +890,91 @@ export function useVideoPlayer({ return videoDuration || activeVersion?.duration || 0; }, [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( (seconds: number) => { if (!isFrameMode) return seconds; @@ -1126,43 +1234,87 @@ export function useVideoPlayer({ [activeProviderId, bunnySourcePreference, hlsRef, isPlaying, playerRef, videoRef] ); - const handleTimelineClick = useCallback( - (e: React.MouseEvent) => { - if (!timelineRef.current) return; - const rect = timelineRef.current.getBoundingClientRect(); - const x = e.clientX - rect.left; - const percentage = Math.max(0, Math.min(1, x / rect.width)); - const newTime = percentage * duration; - handleSeekToTimestamp(newTime); - }, - [duration, handleSeekToTimestamp, timelineRef] - ); + // Convert a clientX into a time using the timeline rect captured at drag start + // (avoids a layout read on every move). + const timeFromClientX = useCallback((clientX: number) => { + const rect = dragRectRef.current; + if (!rect || rect.width === 0) return 0; + const percentage = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); + return percentage * durationRef.current; + }, []); const handleTimelineMouseDown = useCallback( (e: React.MouseEvent) => { + 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); - handleTimelineClick(e); + applyPlayhead(newTime); + setCurrentTime(newTime); + requestScrubSeek(newTime); }, - [handleTimelineClick] + [applyPlayhead, requestScrubSeek, timeFromClientX, timelineRef, isPlaying, playerRef] ); const handleTimelineMouseMove = useCallback( (e: React.MouseEvent) => { - if (!isDragging || !timelineRef.current) return; - const rect = timelineRef.current.getBoundingClientRect(); - const x = e.clientX - rect.left; - const percentage = Math.max(0, Math.min(1, x / rect.width)); - setCurrentTime(percentage * duration); + if (!isDraggingRef.current) return; + const newTime = timeFromClientX(e.clientX); + dragTimeRef.current = newTime; + setCurrentTime(newTime); }, - [isDragging, duration, timelineRef] + [timeFromClientX] ); - const handleTimelineMouseUp = useCallback(() => { - if (isDragging) { - handleSeekToTimestamp(currentTime); - setIsDragging(false); + // Commit the final scrub position and restore playback if needed. + const endScrub = useCallback(() => { + if (!isDraggingRef.current) return; + 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 { isReady, diff --git a/components/video-page/player-core.tsx b/components/video-page/player-core.tsx index 0e94fb2..efc987c 100644 --- a/components/video-page/player-core.tsx +++ b/components/video-page/player-core.tsx @@ -41,6 +41,8 @@ interface PlayerCoreProps { iframeRef: RefObject; bunnyViewportRef: RefObject; timelineRef: RefObject; + progressRef: RefObject; + playheadRef: RefObject; videoContainerRef: RefObject; isFullscreenMode: boolean; cursorIdle: boolean; @@ -105,6 +107,8 @@ export const PlayerCore = memo(function PlayerCore({ iframeRef, bunnyViewportRef, timelineRef, + progressRef, + playheadRef, videoContainerRef, isFullscreenMode, cursorIdle, @@ -501,14 +505,17 @@ export const PlayerCore = memo(function PlayerCore({ onMouseDown={handleTimelineMouseDown} 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. */}
0 ? (currentTime / duration) * 100 : 0}%` }} + ref={progressRef} + className="absolute left-0 top-0 h-full w-0 bg-primary/30 rounded pointer-events-none" />
0 ? (currentTime / duration) * 100 : 0}% - 2px)` }} + ref={playheadRef} + className="absolute top-0 left-0 h-full w-1 bg-primary rounded pointer-events-none will-change-[left]" /> {commentMarkers.map((comment) => {