346 lines
8.6 KiB
TypeScript
346 lines
8.6 KiB
TypeScript
/**
|
|
* 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<HTMLVideoElement | null>;
|
|
/** 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<void>;
|
|
/** 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<string | null>(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;
|