From b23f3de66602c23066623f30d129f25e30da6abb Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sat, 25 Jul 2026 17:07:34 +0700 Subject: [PATCH] feat(player): add frame counter when scrubbing and seeking Show a timecode + frame readout above the timeline while dragging the playhead, and flash it for a moment on keyboard/button seeks so frame stepping is visible too. Position and text are written from the existing rAF/DOM path that drives the playhead, so the readout stays smooth without extra React renders. Two supporting fixes the count depends on: - Seed the frame rate from the HLS manifest FRAME-RATE attribute so a frame number is available before playback ever starts; previously the rate was only ever measured from requestVideoFrameCallback and stayed null until the video had played. - Snap the measured rate to the nearest broadcast standard and skip samples taken mid-seek. A drifting float slid the count by whole frames late in a long video, and re-publishing a slightly different float on every presented frame forced a re-render per video frame. --- components/video-page-content.tsx | 6 ++ .../video-page/hooks/use-video-player.ts | 88 +++++++++++++++++-- components/video-page/player-core.tsx | 17 ++++ 3 files changed, 103 insertions(+), 8 deletions(-) diff --git a/components/video-page-content.tsx b/components/video-page-content.tsx index 77c2428..57d967e 100644 --- a/components/video-page-content.tsx +++ b/components/video-page-content.tsx @@ -90,6 +90,7 @@ export function VideoPageContent({ const timelineRef = useRef(null); const progressRef = useRef(null); const playheadRef = useRef(null); + const scrubReadoutRef = useRef(null); const videoContainerRef = useRef(null); const pathname = usePathname(); const scheduleWatchProgressSaveRef = useRef< @@ -322,6 +323,7 @@ export function VideoPageContent({ isMuted, isFrameMode, frameStepLabel, + showScrubReadout, playbackSpeed, qualityOptions, selectedQualityLevel, @@ -357,8 +359,10 @@ export function VideoPageContent({ timelineRef, progressRef, playheadRef, + scrubReadoutRef, hlsRef, playerRef, + formatTime, formatBunnyQualityLabel, speedOptions: SPEED_OPTIONS, scheduleWatchProgressSaveRef, @@ -783,7 +787,9 @@ export function VideoPageContent({ timelineRef={timelineRef} progressRef={progressRef} playheadRef={playheadRef} + scrubReadoutRef={scrubReadoutRef} videoContainerRef={videoContainerRef} + showScrubReadout={showScrubReadout} isFullscreenMode={isFullscreenMode} cursorIdle={cursorIdle} isPlaying={isPlaying} diff --git a/components/video-page/hooks/use-video-player.ts b/components/video-page/hooks/use-video-player.ts index c4aab7d..0128d7a 100644 --- a/components/video-page/hooks/use-video-player.ts +++ b/components/video-page/hooks/use-video-player.ts @@ -21,6 +21,17 @@ import type { } from '@/components/video-page/types'; import { validateAnnotationStrokes } from '@/lib/validation'; +// A frame number is only meaningful against a stable rate: a raw measurement +// drifts (29.94, 30.07, ...) and would slide the count by whole frames late in a +// long video. Snap to the nearest broadcast standard when we are close enough. +const STANDARD_FRAME_RATES = [23.976, 24, 25, 29.97, 30, 48, 50, 59.94, 60, 120]; + +function normalizeFrameRate(rate: number | undefined): number | null { + if (typeof rate !== 'number' || !Number.isFinite(rate) || rate < 12 || rate > 120) return null; + const standard = STANDARD_FRAME_RATES.find((value) => Math.abs(rate - value) / value < 0.015); + return standard ?? rate; +} + interface UseVideoPlayerParams { activeVersion: Version | undefined; activeVersionId: string | null; @@ -33,8 +44,10 @@ interface UseVideoPlayerParams { timelineRef: RefObject; progressRef: RefObject; playheadRef: RefObject; + scrubReadoutRef: RefObject; hlsRef: RefObject; playerRef: RefObject; + formatTime: (seconds: number) => string; formatBunnyQualityLabel: (level: { height?: number; bitrate?: number }, index: number) => string; speedOptions: number[]; scheduleWatchProgressSaveRef: RefObject< @@ -55,8 +68,10 @@ export function useVideoPlayer({ timelineRef, progressRef, playheadRef, + scrubReadoutRef, hlsRef, playerRef, + formatTime, formatBunnyQualityLabel, speedOptions, scheduleWatchProgressSaveRef, @@ -101,6 +116,22 @@ export function useVideoPlayer({ const [isFullscreenMode, setIsFullscreenMode] = useState(false); const [showComments, setShowComments] = useState(true); const [isMobileCommentsOpen, setIsMobileCommentsOpen] = useState(false); + // Keyboard/button seeks have no drag to key the readout off, so flash it for a + // moment instead — stepping frame by frame is exactly when the count matters. + const [isSeekReadoutVisible, setIsSeekReadoutVisible] = useState(false); + const seekReadoutTimerRef = useRef | null>(null); + + const flashSeekReadout = useCallback(() => { + setIsSeekReadoutVisible(true); + if (seekReadoutTimerRef.current) clearTimeout(seekReadoutTimerRef.current); + seekReadoutTimerRef.current = setTimeout(() => setIsSeekReadoutVisible(false), 1200); + }, []); + + useEffect(() => { + return () => { + if (seekReadoutTimerRef.current) clearTimeout(seekReadoutTimerRef.current); + }; + }, []); const frameStepSeconds = useMemo(() => { if (estimatedFrameRate && Number.isFinite(estimatedFrameRate) && estimatedFrameRate > 0) { @@ -142,12 +173,14 @@ export function useVideoPlayer({ presentedFrames: metadata.presentedFrames, }; - if (previousSample) { + // Samples that straddle a seek compare frames from two different points in + // the media timeline, so the ratio is meaningless — skip them. + if (previousSample && !videoEl.seeking) { const deltaFrames = metadata.presentedFrames - previousSample.presentedFrames; const deltaTime = metadata.mediaTime - previousSample.mediaTime; if (deltaFrames > 0 && deltaTime > 0) { - const nextFrameRate = deltaFrames / deltaTime; - if (Number.isFinite(nextFrameRate) && nextFrameRate >= 12 && nextFrameRate <= 120) { + const nextFrameRate = normalizeFrameRate(deltaFrames / deltaTime); + if (nextFrameRate !== null) { setEstimatedFrameRate(nextFrameRate); } } @@ -496,6 +529,16 @@ export function useVideoPlayer({ videoEl.addEventListener('error', onVideoError); const configureHlsLevels = (levels: Level[]) => { + // The manifest usually declares FRAME-RATE, which gives us a frame + // count before playback ever starts; measurement refines it later. + for (const level of levels) { + const manifestFrameRate = normalizeFrameRate(level.frameRate); + if (manifestFrameRate !== null) { + setEstimatedFrameRate(manifestFrameRate); + break; + } + } + setQualityOptions( levels.map((level, index) => ({ level: index, @@ -894,17 +937,41 @@ export function useVideoPlayer({ 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. + // Read through a ref so the rAF loop below is not torn down and rebuilt every + // time the measured frame rate is re-published. + const frameRateRef = useRef(null); + useEffect(() => { + frameRateRef.current = estimatedFrameRate; + }, [estimatedFrameRate]); + + // Position the progress fill + playhead + scrub readout 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)`; + + const readoutEl = scrubReadoutRef.current; + if (readoutEl) { + // Clamp in CSS rather than JS so the badge stays inside the timeline at + // either end without measuring it on every frame. + readoutEl.style.left = `clamp(3rem, ${percent}%, calc(100% - 3rem))`; + const rate = frameRateRef.current; + if (rate === null) { + readoutEl.textContent = formatTime(time); + } else { + // Frame N covers [N/rate, (N+1)/rate); the epsilon keeps a time that + // lands exactly on a boundary from floating-point-ing down to N-1. + const lastFrame = d > 0 ? Math.max(0, Math.ceil(d * rate) - 1) : 0; + const frame = Math.min(Math.floor(time * rate + 1e-6), lastFrame); + readoutEl.textContent = `${formatTime(time)} · f${frame}`; + } + } }, - [progressRef, playheadRef] + [progressRef, playheadRef, scrubReadoutRef, formatTime] ); // Live-preview seek for the HTML5 video element (Bunny/R2/direct). Coalesced: @@ -1053,8 +1120,9 @@ export function useVideoPlayer({ (seconds: number) => { const newTime = Math.max(0, Math.min(duration, currentTime + resolveSkipAmount(seconds))); handleSeekToTimestamp(newTime); + flashSeekReadout(); }, - [currentTime, duration, handleSeekToTimestamp, resolveSkipAmount] + [currentTime, duration, flashSeekReadout, handleSeekToTimestamp, resolveSkipAmount] ); useEffect(() => { @@ -1149,6 +1217,7 @@ export function useVideoPlayer({ const newTime = Math.max(0, currentTime - 10); playerRef.current.seekTo(newTime, true); setCurrentTime(newTime); + flashSeekReadout(); } break; case 'KeyL': @@ -1157,6 +1226,7 @@ export function useVideoPlayer({ const newTime = Math.min(duration, currentTime + 10); playerRef.current.seekTo(newTime, true); setCurrentTime(newTime); + flashSeekReadout(); } break; case 'KeyF': @@ -1175,6 +1245,7 @@ export function useVideoPlayer({ isMuted, playbackSpeed, speedOptions, + flashSeekReadout, handleSkip, toggleFullscreen, playerRef, @@ -1329,6 +1400,7 @@ export function useVideoPlayer({ frameStepSeconds, frameStepLabel, isDragging, + showScrubReadout: isDragging || isSeekReadoutVisible, playbackSpeed, qualityOptions, selectedQualityLevel, diff --git a/components/video-page/player-core.tsx b/components/video-page/player-core.tsx index efc987c..5d0895c 100644 --- a/components/video-page/player-core.tsx +++ b/components/video-page/player-core.tsx @@ -43,7 +43,9 @@ interface PlayerCoreProps { timelineRef: RefObject; progressRef: RefObject; playheadRef: RefObject; + scrubReadoutRef: RefObject; videoContainerRef: RefObject; + showScrubReadout: boolean; isFullscreenMode: boolean; cursorIdle: boolean; isPlaying: boolean; @@ -109,7 +111,9 @@ export const PlayerCore = memo(function PlayerCore({ timelineRef, progressRef, playheadRef, + scrubReadoutRef, videoContainerRef, + showScrubReadout, isFullscreenMode, cursorIdle, isPlaying, @@ -518,6 +522,19 @@ export const PlayerCore = memo(function PlayerCore({ className="absolute top-0 left-0 h-full w-1 bg-primary rounded pointer-events-none will-change-[left]" /> + {/* Timecode + frame counter, shown while scrubbing and flashed on + keyboard/button seeks. Kept mounted (only faded) so it already + holds the right text the instant it appears; its position and + content come from the same rAF loop that drives the playhead. */} +
+ {commentMarkers.map((comment) => { const startPercent = duration > 0 ? (comment.timestamp / duration) * 100 : 0; const hasRange =