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
+17
View File
@@ -43,7 +43,9 @@ interface PlayerCoreProps {
timelineRef: RefObject<HTMLDivElement | null>;
progressRef: RefObject<HTMLDivElement | null>;
playheadRef: RefObject<HTMLDivElement | null>;
scrubReadoutRef: RefObject<HTMLDivElement | null>;
videoContainerRef: RefObject<HTMLDivElement | null>;
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. */}
<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) => {
const startPercent = duration > 0 ? (comment.timestamp / duration) * 100 : 0;
const hasRange =