39948-vm/frontend/src/hooks/video/useVideoPlaybackCore.ts
2026-05-28 07:19:36 +00:00

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;