39948-vm/frontend/src/hooks/video/useVideoBufferingState.ts
2026-05-28 07:18:04 +00:00

217 lines
6.9 KiB
TypeScript

/**
* useVideoBufferingState Hook
*
* Tracks video buffering state including:
* - Initial buffering (waiting for canplay)
* - Mid-playback buffering (waiting event)
* - Progress-based timeout detection
*/
import { useState, useRef, useCallback, useEffect, type RefObject } from 'react';
import { logger } from '../../lib/logger';
import { TRANSITION_CONFIG } from '../../config/transition.config';
export interface UseVideoBufferingStateOptions {
videoRef: RefObject<HTMLVideoElement | null>;
enabled?: boolean;
/** Custom no-progress timeout in ms (uses config default if not provided) */
noProgressMs?: number;
/** Custom check interval in ms (uses config default if not provided) */
checkIntervalMs?: number;
/** Callback when buffering state changes */
onBufferingChange?: (isBuffering: boolean) => void;
/** Callback when progress timeout occurs */
onProgressTimeout?: () => void;
}
export interface UseVideoBufferingStateResult {
/** True when video is waiting for data (initial load or mid-playback) */
isBuffering: boolean;
/** True specifically when waiting event has fired (mid-playback) */
isWaitingForData: boolean;
/** True when canplay has fired (enough data to start playing) */
isReady: boolean;
/** Reset buffering state (e.g., when loading new source) */
reset: () => void;
/** Start progress monitoring (call after video starts loading) */
startProgressMonitor: () => void;
/** Stop progress monitoring */
stopProgressMonitor: () => void;
/** Update last progress time (call on progress events) */
updateProgressTime: () => void;
}
/**
* Hook for tracking video buffering state.
*
* Handles both initial buffering (before canplay) and mid-playback
* buffering (when video is waiting for more data).
*
* @example
* const { isBuffering, isReady, startProgressMonitor } = useVideoBufferingState({
* videoRef,
* enabled: true,
* onProgressTimeout: () => console.error('Video timed out'),
* });
*/
export function useVideoBufferingState({
videoRef,
enabled = true,
noProgressMs,
checkIntervalMs,
onBufferingChange,
onProgressTimeout,
}: UseVideoBufferingStateOptions): UseVideoBufferingStateResult {
const [isReady, setIsReady] = useState(false);
const [isWaitingForData, setIsWaitingForData] = useState(false);
const isWaitingForDataRef = useRef(false);
const lastProgressTimeRef = useRef<number>(0);
const progressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isMonitoringRef = useRef(false);
const onProgressTimeoutRef = useRef(onProgressTimeout);
const onBufferingChangeRef = useRef(onBufferingChange);
// Update refs on each render
useEffect(() => {
onProgressTimeoutRef.current = onProgressTimeout;
onBufferingChangeRef.current = onBufferingChange;
});
// Calculate actual timeout values
const { progressTimeout } = TRANSITION_CONFIG;
const isMobile =
typeof navigator !== 'undefined' &&
/iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
const actualNoProgressMs =
noProgressMs ??
(isMobile
? progressTimeout.noProgressMs * progressTimeout.mobileMultiplier
: progressTimeout.noProgressMs);
const actualCheckIntervalMs = checkIntervalMs ?? progressTimeout.checkIntervalMs;
const stopProgressMonitor = useCallback(() => {
if (progressTimeoutRef.current) {
clearTimeout(progressTimeoutRef.current);
progressTimeoutRef.current = null;
}
isMonitoringRef.current = false;
}, []);
const updateProgressTime = useCallback(() => {
lastProgressTimeRef.current = Date.now();
}, []);
const startProgressMonitor = useCallback(() => {
if (isMonitoringRef.current) return;
isMonitoringRef.current = true;
lastProgressTimeRef.current = Date.now();
const video = videoRef.current;
const checkProgress = () => {
if (!isMonitoringRef.current) return;
if (!video) return;
const timeSinceProgress = Date.now() - lastProgressTimeRef.current;
// If playing normally (not waiting), continue monitoring
if (!video.paused && !isWaitingForDataRef.current) {
progressTimeoutRef.current = setTimeout(checkProgress, actualCheckIntervalMs);
return;
}
// If waiting but received data recently, keep waiting
if (timeSinceProgress < actualNoProgressMs) {
progressTimeoutRef.current = setTimeout(checkProgress, actualCheckIntervalMs);
return;
}
// No progress for too long while waiting - timeout
logger.error('useVideoBufferingState: No network progress - timing out', {
timeSinceProgress,
currentTime: video.currentTime?.toFixed(2),
isWaiting: isWaitingForDataRef.current,
actualNoProgressMs,
});
onProgressTimeoutRef.current?.();
};
progressTimeoutRef.current = setTimeout(checkProgress, actualCheckIntervalMs);
}, [videoRef, actualNoProgressMs, actualCheckIntervalMs]);
const reset = useCallback(() => {
setIsReady(false);
setIsWaitingForData(false);
isWaitingForDataRef.current = false;
stopProgressMonitor();
}, [stopProgressMonitor]);
// Set up video event listeners
useEffect(() => {
const video = videoRef.current;
if (!video || !enabled) return;
const onCanPlay = () => {
setIsReady(true);
};
const onWaiting = () => {
const bufferedInfo =
video.buffered.length > 0
? `${video.buffered.start(0).toFixed(2)}-${video.buffered.end(video.buffered.length - 1).toFixed(2)}`
: 'none';
logger.info('useVideoBufferingState: Video waiting for data', {
currentTime: video.currentTime.toFixed(2),
buffered: bufferedInfo,
});
setIsWaitingForData(true);
isWaitingForDataRef.current = true;
};
const onPlaying = () => {
// Clear waiting state when playback resumes
if (isWaitingForDataRef.current) {
logger.info('useVideoBufferingState: Resumed playback after buffering');
setIsWaitingForData(false);
isWaitingForDataRef.current = false;
}
};
const onProgress = () => {
updateProgressTime();
};
video.addEventListener('canplay', onCanPlay);
video.addEventListener('waiting', onWaiting);
video.addEventListener('playing', onPlaying);
video.addEventListener('progress', onProgress);
return () => {
video.removeEventListener('canplay', onCanPlay);
video.removeEventListener('waiting', onWaiting);
video.removeEventListener('playing', onPlaying);
video.removeEventListener('progress', onProgress);
stopProgressMonitor();
};
}, [videoRef, enabled, stopProgressMonitor, updateProgressTime]);
// Notify on buffering state change
const isBuffering = !isReady || isWaitingForData;
useEffect(() => {
onBufferingChangeRef.current?.(isBuffering);
}, [isBuffering]);
return {
isBuffering,
isWaitingForData,
isReady,
reset,
startProgressMonitor,
stopProgressMonitor,
updateProgressTime,
};
}
export default useVideoBufferingState;