/** * useVideoErrorRecovery Hook * * Handles video error recovery strategies: * - Safari decode error retry (error code 3) * - Proxy URL fallback on presigned URL failure */ import { useRef, useCallback, useEffect, type RefObject } from 'react'; import { logger } from '../../lib/logger'; import { isPresignedUrl, buildProxyUrl, markPresignedUrlFailed, } from '../../lib/assetUrl'; import { TRANSITION_CONFIG } from '../../config/transition.config'; import { isSafari } from '../../lib/browserUtils'; export interface UseVideoErrorRecoveryOptions { videoRef: RefObject; enabled?: boolean; /** Storage key for proxy URL fallback */ storageKey?: string; /** Current video source URL */ currentUrl?: string; /** Max decode retry attempts (default from config) */ maxDecodeRetries?: number; /** Callback to reload video with new source */ onRetryWithSource?: (newUrl: string) => void; /** Callback when all recovery attempts fail */ onUnrecoverableError?: (reason: string, error?: MediaError) => void; } export interface UseVideoErrorRecoveryResult { /** Reset error recovery state */ reset: () => void; /** Manually handle an error (returns true if handled, false if unrecoverable) */ handleError: (video: HTMLVideoElement) => boolean; } /** * Hook for handling video error recovery. * * Supports: * - Safari decode error retry (reloading the video) * - Proxy URL fallback when presigned URLs fail * * @example * const { reset, handleError } = useVideoErrorRecovery({ * videoRef, * storageKey: 'assets/video.mp4', * currentUrl: presignedUrl, * onRetryWithSource: (newUrl) => { * video.src = newUrl; * video.load(); * }, * onUnrecoverableError: (reason) => console.error('Video error:', reason), * }); */ export function useVideoErrorRecovery({ videoRef, enabled = true, storageKey, currentUrl, maxDecodeRetries, onRetryWithSource, onUnrecoverableError, }: UseVideoErrorRecoveryOptions): UseVideoErrorRecoveryResult { const decodeRetryCountRef = useRef(0); const didTryProxyRef = useRef(false); const onRetryWithSourceRef = useRef(onRetryWithSource); const onUnrecoverableErrorRef = useRef(onUnrecoverableError); const storageKeyRef = useRef(storageKey); const currentUrlRef = useRef(currentUrl); const actualMaxDecodeRetries = maxDecodeRetries ?? TRANSITION_CONFIG.retry.maxDecodeRetries; useEffect(() => { onRetryWithSourceRef.current = onRetryWithSource; onUnrecoverableErrorRef.current = onUnrecoverableError; storageKeyRef.current = storageKey; currentUrlRef.current = currentUrl; }); const reset = useCallback(() => { decodeRetryCountRef.current = 0; didTryProxyRef.current = false; }, []); const handleError = useCallback( (video: HTMLVideoElement): boolean => { const error = video.error; if (!error) return false; const errorCode = error.code; const errorMessage = (error as MediaError & { message?: string }).message || ''; logger.error('useVideoErrorRecovery: Video error', { code: errorCode, message: errorMessage, currentSrc: video.currentSrc?.slice(-50), readyState: video.readyState, networkState: video.networkState, }); // Safari decode error (error code 3) - try reload if ( isSafari() && errorCode === 3 && decodeRetryCountRef.current < actualMaxDecodeRetries ) { decodeRetryCountRef.current++; logger.info('useVideoErrorRecovery: Safari decode error, attempting reload', { attempt: decodeRetryCountRef.current, maxAttempts: actualMaxDecodeRetries, }); video.load(); video.play().catch(() => { /* ignore play errors during recovery */ }); return true; } // Network error with presigned URL - try proxy const currentStorageKey = storageKeyRef.current; const url = currentUrlRef.current; if ( currentStorageKey && url && isPresignedUrl(url) && !didTryProxyRef.current ) { didTryProxyRef.current = true; logger.info('useVideoErrorRecovery: Presigned URL failed, retrying with proxy', { storageKey: currentStorageKey.slice(-40), }); markPresignedUrlFailed(currentStorageKey); const proxyUrl = buildProxyUrl(currentStorageKey); onRetryWithSourceRef.current?.(proxyUrl); return true; } // Unrecoverable error onUnrecoverableErrorRef.current?.('video-error', error); return false; }, [actualMaxDecodeRetries], ); // Set up error event listener useEffect(() => { const video = videoRef.current; if (!video || !enabled) return; const onError = () => { handleError(video); }; video.addEventListener('error', onError); return () => { video.removeEventListener('error', onError); }; }, [videoRef, enabled, handleError]); return { reset, handleError, }; } export default useVideoErrorRecovery;