/** * useVideoPlaybackCore Hook * * Composite hook that combines all video primitives into a unified * playback controller. Used as the foundation for both transition * and background video playback. */ import { useCallback, useEffect, useRef, useState, type RefObject, } from 'react'; import { logger } from '../../lib/logger'; import { useVideoBlobUrl, type PreloadCacheProvider } from './useVideoBlobUrl'; import { useVideoBufferingState } from './useVideoBufferingState'; import { useVideoFirstFrame } from './useVideoFirstFrame'; import { useVideoErrorRecovery } from './useVideoErrorRecovery'; import { useVideoTimeouts } from './useVideoTimeouts'; export interface UseVideoPlaybackCoreOptions { videoRef: RefObject; /** Source URL or storage path */ sourceUrl?: string; /** Storage key for cache lookup */ storageKey?: string; /** Preload cache provider */ preloadCache?: PreloadCacheProvider; /** Whether to autoplay when ready */ autoplay?: boolean; /** Whether video is muted */ muted?: boolean; /** External pause control */ paused?: boolean; /** Timeout for playback start (ms) */ playbackStartTimeoutMs?: number; /** Callback when video is ready (first frame painted) */ onReady?: () => void; /** Callback on error */ onError?: (reason: string) => void; /** Callback when buffering state changes */ onBufferingChange?: (isBuffering: boolean) => void; /** Callback when video ends */ onEnded?: () => void; } export interface UseVideoPlaybackCoreResult { /** True when first frame has been painted */ isReady: boolean; /** True during buffering (initial load or mid-playback) */ isBuffering: boolean; /** True specifically when waiting for network data mid-playback */ isWaitingForData: boolean; /** Resolved playable URL */ resolvedUrl: string | null; /** Whether URL resolution is in progress */ isResolving: boolean; /** Start playback */ play: () => Promise; /** Pause playback */ pause: () => void; /** Reset all state */ reset: () => void; } const DEFAULT_PLAYBACK_START_TIMEOUT_MS = 3000; /** * Core video playback hook that composes all video primitives. * * Provides unified handling for: * - Multi-tier URL resolution (blob, cached, streaming, proxy) * - Buffering state detection (initial and mid-playback) * - First frame detection (rvfc with fallback) * - Error recovery (Safari decode error, proxy fallback) * - Timer management * * @example * const { * isReady, * isBuffering, * resolvedUrl, * play, * pause, * } = useVideoPlaybackCore({ * videoRef, * sourceUrl: 'assets/video.mp4', * storageKey: 'assets/video.mp4', * autoplay: true, * onReady: () => console.log('Video ready'), * onError: (reason) => console.error('Error:', reason), * }); */ export function useVideoPlaybackCore({ videoRef, sourceUrl, storageKey, preloadCache, autoplay = false, muted = true, paused = false, playbackStartTimeoutMs = DEFAULT_PLAYBACK_START_TIMEOUT_MS, onReady, onError, onBufferingChange, onEnded, }: UseVideoPlaybackCoreOptions): UseVideoPlaybackCoreResult { const [isSourceLoaded, setIsSourceLoaded] = useState(false); const didStartPlaybackRef = useRef(false); const currentSourceRef = useRef(null); const onReadyRef = useRef(onReady); const onErrorRef = useRef(onError); const onBufferingChangeRef = useRef(onBufferingChange); const onEndedRef = useRef(onEnded); useEffect(() => { onReadyRef.current = onReady; onErrorRef.current = onError; onBufferingChangeRef.current = onBufferingChange; onEndedRef.current = onEnded; }); // Timer management const { setTimer, clearTimer, clearAllTimers } = useVideoTimeouts(); // URL resolution const { resolvedUrl, isResolving, revoke: revokeBlobUrl, } = useVideoBlobUrl({ sourceUrl: sourceUrl || '', storageKey, preloadCache, onError: (error) => { logger.error('useVideoPlaybackCore: URL resolution failed', { error }); onErrorRef.current?.('source-resolution-failed'); }, }); // Buffering state const { isBuffering: isBufferingFromState, isWaitingForData, isReady: isBufferReady, reset: resetBufferingState, startProgressMonitor, } = useVideoBufferingState({ videoRef, enabled: Boolean(sourceUrl), onBufferingChange: (buffering) => { onBufferingChangeRef.current?.(buffering); }, onProgressTimeout: () => { onErrorRef.current?.('no-progress-timeout'); }, }); // First frame detection const { isFirstFramePainted, reset: resetFirstFrame } = useVideoFirstFrame({ videoRef, enabled: Boolean(sourceUrl), onFirstFrame: () => { logger.info('useVideoPlaybackCore: First frame painted'); onReadyRef.current?.(); }, }); // Error recovery const { reset: resetErrorRecovery } = useVideoErrorRecovery({ videoRef, enabled: Boolean(sourceUrl), storageKey, currentUrl: resolvedUrl || undefined, onRetryWithSource: (newUrl) => { const video = videoRef.current; if (video) { video.src = newUrl; video.load(); video.play().catch(() => { /* ignore play errors during recovery */ }); } }, onUnrecoverableError: (reason) => { onErrorRef.current?.(reason); }, }); // Combined ready state const isReady = isFirstFramePainted && isBufferReady; const isBuffering = !isReady || isBufferingFromState || isWaitingForData; // Play function const play = useCallback(async () => { const video = videoRef.current; if (!video) return; try { await video.play(); didStartPlaybackRef.current = true; clearTimer('playbackStart'); } catch (playError) { logger.warn('useVideoPlaybackCore: Play failed', { playError }); } }, [videoRef, clearTimer]); // Pause function const pause = useCallback(() => { const video = videoRef.current; if (video) { video.pause(); } }, [videoRef]); // Reset function const reset = useCallback(() => { clearAllTimers(); resetBufferingState(); resetFirstFrame(); resetErrorRecovery(); revokeBlobUrl(); setIsSourceLoaded(false); didStartPlaybackRef.current = false; currentSourceRef.current = null; }, [ clearAllTimers, resetBufferingState, resetFirstFrame, resetErrorRecovery, revokeBlobUrl, ]); // Load and play when URL is resolved useEffect(() => { const video = videoRef.current; if (!video || !resolvedUrl || isResolving) return; // Skip if already loaded this source if (currentSourceRef.current === resolvedUrl && isSourceLoaded) return; logger.info('useVideoPlaybackCore: Loading video', { url: resolvedUrl.slice(-50), autoplay, paused, }); currentSourceRef.current = resolvedUrl; setIsSourceLoaded(false); didStartPlaybackRef.current = false; // Set video source video.src = resolvedUrl; video.muted = muted; video.load(); // Start progress monitoring for streaming startProgressMonitor(); setIsSourceLoaded(true); // Autoplay if not externally paused if (autoplay && !paused) { play(); // Set playback start watchdog setTimer( 'playbackStart', () => { if (!didStartPlaybackRef.current) { logger.warn( 'useVideoPlaybackCore: Playback start timeout, retrying', ); play(); } }, playbackStartTimeoutMs, ); } }, [ videoRef, resolvedUrl, isResolving, autoplay, paused, muted, play, setTimer, startProgressMonitor, playbackStartTimeoutMs, isSourceLoaded, ]); // Handle external pause control useEffect(() => { const video = videoRef.current; if (!video || !isSourceLoaded) return; if (paused) { video.pause(); } else if (autoplay && !video.paused) { // Already playing, do nothing } else if (autoplay) { play(); } }, [videoRef, paused, autoplay, play, isSourceLoaded]); // Handle video ended useEffect(() => { const video = videoRef.current; if (!video) return; const handleEnded = () => { onEndedRef.current?.(); }; video.addEventListener('ended', handleEnded); return () => { video.removeEventListener('ended', handleEnded); }; }, [videoRef]); // Cleanup on unmount or source change useEffect(() => { return () => { reset(); }; }, [sourceUrl, reset]); return { isReady, isBuffering, isWaitingForData, resolvedUrl, isResolving, play, pause, reset, }; } export default useVideoPlaybackCore;