175 lines
5.0 KiB
TypeScript
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;
|