39948-vm/frontend/src/hooks/video/useVideoFirstFrame.ts
2026-05-28 07:12:13 +00:00

124 lines
3.8 KiB
TypeScript

/**
* useVideoFirstFrame Hook
*
* Detects when the first video frame is painted using:
* - requestVideoFrameCallback (modern browsers, Safari 15.4+)
* - requestAnimationFrame fallback (older browsers)
*/
import { useState, useRef, useCallback, useEffect, type RefObject } from 'react';
import { logger } from '../../lib/logger';
import { scheduleAfterPaint } from '../../lib/browserUtils';
export interface UseVideoFirstFrameOptions {
videoRef: RefObject<HTMLVideoElement | null>;
enabled?: boolean;
/** Callback when first frame is painted */
onFirstFrame?: () => void;
}
export interface UseVideoFirstFrameResult {
/** True when first frame has been painted */
isFirstFramePainted: boolean;
/** Reset first frame state */
reset: () => void;
}
/**
* Hook for detecting when the first video frame is painted.
*
* Uses requestVideoFrameCallback when available (Safari 15.4+, Chrome, Firefox)
* for precise frame-level detection. Falls back to requestAnimationFrame
* with scheduleAfterPaint for older browsers.
*
* @example
* const { isFirstFramePainted, reset } = useVideoFirstFrame({
* videoRef,
* enabled: true,
* onFirstFrame: () => console.log('First frame painted'),
* });
*/
export function useVideoFirstFrame({
videoRef,
enabled = true,
onFirstFrame,
}: UseVideoFirstFrameOptions): UseVideoFirstFrameResult {
const [isFirstFramePainted, setIsFirstFramePainted] = useState(false);
const callbackIdRef = useRef<number | null>(null);
const onFirstFrameRef = useRef(onFirstFrame);
const didFireRef = useRef(false);
useEffect(() => {
onFirstFrameRef.current = onFirstFrame;
});
const reset = useCallback(() => {
setIsFirstFramePainted(false);
didFireRef.current = false;
// Cancel pending callback
if (callbackIdRef.current !== null) {
const video = videoRef.current;
if (video && 'cancelVideoFrameCallback' in video) {
(video as HTMLVideoElement & { cancelVideoFrameCallback: (id: number) => void })
.cancelVideoFrameCallback(callbackIdRef.current);
}
callbackIdRef.current = null;
}
}, [videoRef]);
// Set up first frame detection when video starts playing
useEffect(() => {
const video = videoRef.current;
if (!video || !enabled) return;
const handlePlaying = () => {
if (didFireRef.current) return;
// Use requestVideoFrameCallback for precise frame-level timing (Safari 15.4+)
if ('requestVideoFrameCallback' in video) {
const rvfc = (video as HTMLVideoElement & {
requestVideoFrameCallback: (
callback: (now: number, metadata: VideoFrameCallbackMetadata) => void
) => number;
}).requestVideoFrameCallback.bind(video);
// First callback: frame is composited, safe to show overlay
callbackIdRef.current = rvfc((_now, _metadata) => {
if (!didFireRef.current) {
didFireRef.current = true;
setIsFirstFramePainted(true);
logger.info('useVideoFirstFrame: First frame painted (rvfc)');
onFirstFrameRef.current?.();
}
callbackIdRef.current = null;
});
} else {
// Fallback for older browsers without requestVideoFrameCallback
scheduleAfterPaint(() => {
if (!didFireRef.current) {
didFireRef.current = true;
setIsFirstFramePainted(true);
logger.info('useVideoFirstFrame: First frame painted (rAF fallback)');
onFirstFrameRef.current?.();
}
});
}
};
video.addEventListener('playing', handlePlaying);
return () => {
video.removeEventListener('playing', handlePlaying);
reset();
};
}, [videoRef, enabled, reset]);
return {
isFirstFramePainted,
reset,
};
}
export default useVideoFirstFrame;