124 lines
3.8 KiB
TypeScript
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;
|