39948-vm/frontend/src/hooks/video/useVideoErrorRecovery.ts

175 lines
5.0 KiB
TypeScript

/**
* 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<HTMLVideoElement | null>;
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;