mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
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:
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user