mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
Merge pull request #39 from yusufipk/worktree-frame-counter
feat(player): add frame counter when scrubbing and seeking
This commit is contained in:
@@ -91,6 +91,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<
|
||||||
@@ -312,6 +313,7 @@ export function VideoPageContent({
|
|||||||
isMuted,
|
isMuted,
|
||||||
isFrameMode,
|
isFrameMode,
|
||||||
frameStepLabel,
|
frameStepLabel,
|
||||||
|
showScrubReadout,
|
||||||
playbackSpeed,
|
playbackSpeed,
|
||||||
qualityOptions,
|
qualityOptions,
|
||||||
selectedQualityLevel,
|
selectedQualityLevel,
|
||||||
@@ -347,8 +349,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,
|
||||||
@@ -773,7 +777,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,
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
Reference in New Issue
Block a user