/** * 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; 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(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;