217 lines
6.9 KiB
TypeScript
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;
|