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.
This commit is contained in:
yusufipk
2026-07-25 17:07:34 +07:00
parent a14eb9fb84
commit b23f3de666
3 changed files with 103 additions and 8 deletions
+6
View File
@@ -90,6 +90,7 @@ export function VideoPageContent({
const timelineRef = useRef<HTMLDivElement>(null); const timelineRef = useRef<HTMLDivElement>(null);
const progressRef = useRef<HTMLDivElement>(null); const progressRef = useRef<HTMLDivElement>(null);
const playheadRef = useRef<HTMLDivElement>(null); const playheadRef = useRef<HTMLDivElement>(null);
const scrubReadoutRef = 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<
@@ -322,6 +323,7 @@ export function VideoPageContent({
isMuted, isMuted,
isFrameMode, isFrameMode,
frameStepLabel, frameStepLabel,
showScrubReadout,
playbackSpeed, playbackSpeed,
qualityOptions, qualityOptions,
selectedQualityLevel, selectedQualityLevel,
@@ -357,8 +359,10 @@ export function VideoPageContent({
timelineRef, timelineRef,
progressRef, progressRef,
playheadRef, playheadRef,
scrubReadoutRef,
hlsRef, hlsRef,
playerRef, playerRef,
formatTime,
formatBunnyQualityLabel, formatBunnyQualityLabel,
speedOptions: SPEED_OPTIONS, speedOptions: SPEED_OPTIONS,
scheduleWatchProgressSaveRef, scheduleWatchProgressSaveRef,
@@ -783,7 +787,9 @@ export function VideoPageContent({
timelineRef={timelineRef} timelineRef={timelineRef}
progressRef={progressRef} progressRef={progressRef}
playheadRef={playheadRef} playheadRef={playheadRef}
scrubReadoutRef={scrubReadoutRef}
videoContainerRef={videoContainerRef} videoContainerRef={videoContainerRef}
showScrubReadout={showScrubReadout}
isFullscreenMode={isFullscreenMode} isFullscreenMode={isFullscreenMode}
cursorIdle={cursorIdle} cursorIdle={cursorIdle}
isPlaying={isPlaying} isPlaying={isPlaying}
@@ -21,6 +21,17 @@ import type {
} from '@/components/video-page/types'; } from '@/components/video-page/types';
import { validateAnnotationStrokes } from '@/lib/validation'; 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 { interface UseVideoPlayerParams {
activeVersion: Version | undefined; activeVersion: Version | undefined;
activeVersionId: string | null; activeVersionId: string | null;
@@ -33,8 +44,10 @@ interface UseVideoPlayerParams {
timelineRef: RefObject<HTMLDivElement | null>; timelineRef: RefObject<HTMLDivElement | null>;
progressRef: RefObject<HTMLDivElement | null>; progressRef: RefObject<HTMLDivElement | null>;
playheadRef: RefObject<HTMLDivElement | null>; playheadRef: RefObject<HTMLDivElement | null>;
scrubReadoutRef: RefObject<HTMLDivElement | null>;
hlsRef: RefObject<Hls | null>; hlsRef: RefObject<Hls | null>;
playerRef: RefObject<YT.Player | PlayerAdapter | null>; playerRef: RefObject<YT.Player | PlayerAdapter | null>;
formatTime: (seconds: number) => string;
formatBunnyQualityLabel: (level: { height?: number; bitrate?: number }, index: number) => string; formatBunnyQualityLabel: (level: { height?: number; bitrate?: number }, index: number) => string;
speedOptions: number[]; speedOptions: number[];
scheduleWatchProgressSaveRef: RefObject< scheduleWatchProgressSaveRef: RefObject<
@@ -55,8 +68,10 @@ export function useVideoPlayer({
timelineRef, timelineRef,
progressRef, progressRef,
playheadRef, playheadRef,
scrubReadoutRef,
hlsRef, hlsRef,
playerRef, playerRef,
formatTime,
formatBunnyQualityLabel, formatBunnyQualityLabel,
speedOptions, speedOptions,
scheduleWatchProgressSaveRef, scheduleWatchProgressSaveRef,
@@ -101,6 +116,22 @@ export function useVideoPlayer({
const [isFullscreenMode, setIsFullscreenMode] = useState(false); const [isFullscreenMode, setIsFullscreenMode] = useState(false);
const [showComments, setShowComments] = useState(true); const [showComments, setShowComments] = useState(true);
const [isMobileCommentsOpen, setIsMobileCommentsOpen] = useState(false); 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<ReturnType<typeof setTimeout> | 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(() => { const frameStepSeconds = useMemo(() => {
if (estimatedFrameRate && Number.isFinite(estimatedFrameRate) && estimatedFrameRate > 0) { if (estimatedFrameRate && Number.isFinite(estimatedFrameRate) && estimatedFrameRate > 0) {
@@ -142,12 +173,14 @@ export function useVideoPlayer({
presentedFrames: metadata.presentedFrames, 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 deltaFrames = metadata.presentedFrames - previousSample.presentedFrames;
const deltaTime = metadata.mediaTime - previousSample.mediaTime; const deltaTime = metadata.mediaTime - previousSample.mediaTime;
if (deltaFrames > 0 && deltaTime > 0) { if (deltaFrames > 0 && deltaTime > 0) {
const nextFrameRate = deltaFrames / deltaTime; const nextFrameRate = normalizeFrameRate(deltaFrames / deltaTime);
if (Number.isFinite(nextFrameRate) && nextFrameRate >= 12 && nextFrameRate <= 120) { if (nextFrameRate !== null) {
setEstimatedFrameRate(nextFrameRate); setEstimatedFrameRate(nextFrameRate);
} }
} }
@@ -496,6 +529,16 @@ export function useVideoPlayer({
videoEl.addEventListener('error', onVideoError); videoEl.addEventListener('error', onVideoError);
const configureHlsLevels = (levels: Level[]) => { 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( setQualityOptions(
levels.map((level, index) => ({ levels.map((level, index) => ({
level: index, level: index,
@@ -894,17 +937,41 @@ export function useVideoPlayer({
durationRef.current = duration; durationRef.current = duration;
}, [duration]); }, [duration]);
// Position the progress fill + playhead directly on the DOM (no React state / // Read through a ref so the rAF loop below is not torn down and rebuilt every
// re-render) so scrubbing and playback stay smooth at the display's refresh // time the measured frame rate is re-published.
// rate instead of stepping ~4x/sec. const frameRateRef = useRef<number | null>(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( const applyPlayhead = useCallback(
(time: number) => { (time: number) => {
const d = durationRef.current; const d = durationRef.current;
const percent = d > 0 ? Math.max(0, Math.min(100, (time / d) * 100)) : 0; const percent = d > 0 ? Math.max(0, Math.min(100, (time / d) * 100)) : 0;
if (progressRef.current) progressRef.current.style.width = `${percent}%`; if (progressRef.current) progressRef.current.style.width = `${percent}%`;
if (playheadRef.current) playheadRef.current.style.left = `calc(${percent}% - 2px)`; 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: // Live-preview seek for the HTML5 video element (Bunny/R2/direct). Coalesced:
@@ -1053,8 +1120,9 @@ export function useVideoPlayer({
(seconds: number) => { (seconds: number) => {
const newTime = Math.max(0, Math.min(duration, currentTime + resolveSkipAmount(seconds))); const newTime = Math.max(0, Math.min(duration, currentTime + resolveSkipAmount(seconds)));
handleSeekToTimestamp(newTime); handleSeekToTimestamp(newTime);
flashSeekReadout();
}, },
[currentTime, duration, handleSeekToTimestamp, resolveSkipAmount] [currentTime, duration, flashSeekReadout, handleSeekToTimestamp, resolveSkipAmount]
); );
useEffect(() => { useEffect(() => {
@@ -1149,6 +1217,7 @@ export function useVideoPlayer({
const newTime = Math.max(0, currentTime - 10); const newTime = Math.max(0, currentTime - 10);
playerRef.current.seekTo(newTime, true); playerRef.current.seekTo(newTime, true);
setCurrentTime(newTime); setCurrentTime(newTime);
flashSeekReadout();
} }
break; break;
case 'KeyL': case 'KeyL':
@@ -1157,6 +1226,7 @@ export function useVideoPlayer({
const newTime = Math.min(duration, currentTime + 10); const newTime = Math.min(duration, currentTime + 10);
playerRef.current.seekTo(newTime, true); playerRef.current.seekTo(newTime, true);
setCurrentTime(newTime); setCurrentTime(newTime);
flashSeekReadout();
} }
break; break;
case 'KeyF': case 'KeyF':
@@ -1175,6 +1245,7 @@ export function useVideoPlayer({
isMuted, isMuted,
playbackSpeed, playbackSpeed,
speedOptions, speedOptions,
flashSeekReadout,
handleSkip, handleSkip,
toggleFullscreen, toggleFullscreen,
playerRef, playerRef,
@@ -1329,6 +1400,7 @@ export function useVideoPlayer({
frameStepSeconds, frameStepSeconds,
frameStepLabel, frameStepLabel,
isDragging, isDragging,
showScrubReadout: isDragging || isSeekReadoutVisible,
playbackSpeed, playbackSpeed,
qualityOptions, qualityOptions,
selectedQualityLevel, selectedQualityLevel,
+17
View File
@@ -43,7 +43,9 @@ interface PlayerCoreProps {
timelineRef: RefObject<HTMLDivElement | null>; timelineRef: RefObject<HTMLDivElement | null>;
progressRef: RefObject<HTMLDivElement | null>; progressRef: RefObject<HTMLDivElement | null>;
playheadRef: RefObject<HTMLDivElement | null>; playheadRef: RefObject<HTMLDivElement | null>;
scrubReadoutRef: RefObject<HTMLDivElement | null>;
videoContainerRef: RefObject<HTMLDivElement | null>; videoContainerRef: RefObject<HTMLDivElement | null>;
showScrubReadout: boolean;
isFullscreenMode: boolean; isFullscreenMode: boolean;
cursorIdle: boolean; cursorIdle: boolean;
isPlaying: boolean; isPlaying: boolean;
@@ -109,7 +111,9 @@ export const PlayerCore = memo(function PlayerCore({
timelineRef, timelineRef,
progressRef, progressRef,
playheadRef, playheadRef,
scrubReadoutRef,
videoContainerRef, videoContainerRef,
showScrubReadout,
isFullscreenMode, isFullscreenMode,
cursorIdle, cursorIdle,
isPlaying, 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]" 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. */}
<div
ref={scrubReadoutRef}
aria-hidden={!showScrubReadout}
className={cn(
'absolute bottom-full left-0 z-20 mb-2 -translate-x-1/2 whitespace-nowrap rounded-md border bg-popover px-2 py-1 text-xs font-medium tabular-nums text-popover-foreground shadow-md pointer-events-none will-change-[left] transition-opacity duration-150',
showScrubReadout ? 'opacity-100' : 'opacity-0'
)}
/>
{commentMarkers.map((comment) => { {commentMarkers.map((comment) => {
const startPercent = duration > 0 ? (comment.timestamp / duration) * 100 : 0; const startPercent = duration > 0 ? (comment.timestamp / duration) * 100 : 0;
const hasRange = const hasRange =