39948-vm/frontend/src/hooks/useTransitionPlayback.ts
2026-03-24 15:40:35 +04:00

636 lines
18 KiB
TypeScript

import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type RefObject,
} from 'react';
import axios from 'axios';
import { logger } from '../lib/logger';
import { useReversePlayback } from './useReversePlayback';
export type ReverseMode = 'none' | 'reverse' | 'separate';
export interface TransitionConfig {
videoUrl: string;
reverseMode: ReverseMode;
reverseVideoUrl?: string;
durationSec?: number;
targetPageId?: string;
displayName?: string;
}
export interface UseTransitionPlaybackOptions {
videoRef: RefObject<HTMLVideoElement | null>;
transition: TransitionConfig | null;
onComplete: (targetPageId?: string) => void;
onError?: (reason: string) => void;
timeouts?: {
playbackStartMs?: number;
durationBufferMs?: number;
hardTimeoutMs?: number;
};
features?: {
useBlobUrl?: boolean;
preDecodeImages?: boolean;
getTargetPageImages?: () => string[];
};
preload?: {
preloadedUrls?: Set<string>;
getCachedBlobUrl?: (url: string) => Promise<string | null>;
};
}
export type PlaybackPhase =
| 'idle'
| 'preparing'
| 'playing'
| 'reversing'
| 'finishing'
| 'completed';
export interface UseTransitionPlaybackResult {
phase: PlaybackPhase;
isBuffering: boolean;
isReversing: boolean;
cancel: () => void;
forceComplete: () => void;
}
const DEFAULT_TIMEOUTS = {
playbackStartMs: 3000,
durationBufferMs: 200,
hardTimeoutMs: 45000,
};
function getBufferedEnd(video: HTMLVideoElement): number {
return video.buffered.length > 0
? video.buffered.end(video.buffered.length - 1)
: 0;
}
function shouldLoadViaBlob(
url: string,
reverseMode: ReverseMode,
useBlobUrlOption?: boolean,
): boolean {
if (useBlobUrlOption === false) return false;
if (reverseMode === 'reverse') return true;
if (useBlobUrlOption === true) return true;
try {
const parsedUrl = new URL(url, window.location.origin);
const isSameOrigin = parsedUrl.origin === window.location.origin;
if (!isSameOrigin) return false;
return (
parsedUrl.pathname === '/api/file/download' ||
parsedUrl.pathname === '/file/download'
);
} catch {
return false;
}
}
function buildBlobRequestUrl(url: string): string {
if (url.startsWith('/api/')) {
return url.replace(/^\/api(?=\/)/, '');
}
return url;
}
async function waitForImages(urls: string[], timeoutMs = 2000): Promise<void> {
if (urls.length === 0) return;
const decodePromises = urls.map(
(url) =>
new Promise<void>((resolve) => {
const img = new Image();
img.src = url;
if (typeof img.decode === 'function') {
img
.decode()
.then(() => resolve())
.catch(() => resolve());
} else {
img.onload = () => resolve();
img.onerror = () => resolve();
}
}),
);
await Promise.race([
Promise.all(decodePromises),
new Promise<void>((resolve) => setTimeout(resolve, timeoutMs)),
]);
}
export function useTransitionPlayback(
options: UseTransitionPlaybackOptions,
): UseTransitionPlaybackResult {
const {
videoRef,
transition,
onComplete,
onError,
timeouts: customTimeouts,
features,
preload,
} = options;
const playbackStartMs =
customTimeouts?.playbackStartMs ?? DEFAULT_TIMEOUTS.playbackStartMs;
const durationBufferMs =
customTimeouts?.durationBufferMs ?? DEFAULT_TIMEOUTS.durationBufferMs;
const hardTimeoutMs =
customTimeouts?.hardTimeoutMs ?? DEFAULT_TIMEOUTS.hardTimeoutMs;
const [phase, setPhase] = useState<PlaybackPhase>('idle');
const [isReverseBufferingLocal, setIsReverseBufferingLocal] = useState(false);
const didFinishRef = useRef(false);
const didStartPlaybackRef = useRef(false);
const activeSourceUrlRef = useRef<string | null>(null);
const lastLoadedBlobUrlRef = useRef<string | null>(null);
const lastLoadedSourceUrlRef = useRef<string | null>(null);
const startWatchdogTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const finishTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hardTimeoutTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const onCompleteRef = useRef(onComplete);
const onErrorRef = useRef(onError);
const transitionRef = useRef(transition);
const featuresRef = useRef(features);
const preloadRef = useRef(preload);
const startReverseRef = useRef<(() => Promise<void>) | null>(null);
const stopReverseRef = useRef<(() => void) | null>(null);
const sourceUrl = useMemo(() => {
if (!transition) return '';
return transition.reverseMode === 'separate' && transition.reverseVideoUrl
? transition.reverseVideoUrl
: transition.videoUrl;
}, [transition]);
const clearTimers = useCallback(() => {
if (startWatchdogTimerRef.current) {
clearTimeout(startWatchdogTimerRef.current);
startWatchdogTimerRef.current = null;
}
if (finishTimerRef.current) {
clearTimeout(finishTimerRef.current);
finishTimerRef.current = null;
}
if (hardTimeoutTimerRef.current) {
clearTimeout(hardTimeoutTimerRef.current);
hardTimeoutTimerRef.current = null;
}
}, []);
const revokeBlobUrl = useCallback((force = false) => {
if (!force || !lastLoadedBlobUrlRef.current) return;
URL.revokeObjectURL(lastLoadedBlobUrlRef.current);
lastLoadedBlobUrlRef.current = null;
}, []);
const finishPlayback = useCallback(
async (reason: string) => {
if (didFinishRef.current) return;
didFinishRef.current = true;
activeSourceUrlRef.current = null;
clearTimers();
const video = videoRef.current;
if (video) {
video.pause();
}
const currentTransition = transitionRef.current;
const currentFeatures = featuresRef.current;
logger.info('Transition playback finished', {
reason,
displayName: currentTransition?.displayName,
targetPageId: currentTransition?.targetPageId,
});
setPhase('finishing');
if (
currentFeatures?.preDecodeImages &&
currentFeatures.getTargetPageImages &&
currentTransition?.targetPageId
) {
try {
const imageUrls = currentFeatures.getTargetPageImages();
await waitForImages(imageUrls);
} catch {
// Ignore pre-decode errors
}
}
setPhase('completed');
onCompleteRef.current(currentTransition?.targetPageId);
},
[clearTimers, videoRef],
);
const handleError = useCallback(
(reason: string) => {
if (didFinishRef.current) return;
logger.error('Transition playback error', { reason });
onErrorRef.current?.(reason);
finishPlayback(reason);
},
[finishPlayback],
);
const handleReverseComplete = useCallback(() => {
finishPlayback('reverse-complete');
}, [finishPlayback]);
const {
startReverse,
stopReverse,
isReversing,
isBuffering: isReverseBuffering,
} = useReversePlayback({
videoRef,
onComplete: handleReverseComplete,
preloadedUrls: preload?.preloadedUrls,
videoUrl: sourceUrl,
getCachedBlobUrl: preload?.getCachedBlobUrl,
});
useEffect(() => {
onCompleteRef.current = onComplete;
onErrorRef.current = onError;
transitionRef.current = transition;
featuresRef.current = features;
preloadRef.current = preload;
startReverseRef.current = startReverse;
stopReverseRef.current = stopReverse;
});
useEffect(() => {
setIsReverseBufferingLocal(isReverseBuffering);
}, [isReverseBuffering]);
const cancel = useCallback(() => {
if (phase === 'idle') return;
clearTimers();
stopReverseRef.current?.();
didFinishRef.current = true;
setPhase('idle');
const video = videoRef.current;
if (video) {
video.pause();
video.removeAttribute('src');
video.load();
}
revokeBlobUrl(true);
}, [phase, clearTimers, videoRef, revokeBlobUrl]);
const forceComplete = useCallback(() => {
finishPlayback('forced');
}, [finishPlayback]);
useEffect(() => {
const video = videoRef.current;
const currentTransition = transitionRef.current;
if (!currentTransition || !video || !sourceUrl) {
return;
}
if (activeSourceUrlRef.current === sourceUrl) {
logger.info('Skipping duplicate effect for same source', { sourceUrl });
return;
}
activeSourceUrlRef.current = sourceUrl;
didFinishRef.current = false;
didStartPlaybackRef.current = false;
setPhase('preparing');
const isReverseMode = currentTransition.reverseMode === 'reverse';
const configuredDurationSec = Number(currentTransition.durationSec);
const getMediaErrorDetails = () => {
if (!video.error) return null;
const mediaError = video.error as MediaError & { message?: string };
return {
code: mediaError.code,
message: mediaError.message || '',
};
};
const logIssue = (reason: string, error?: unknown) => {
logger.error('Transition playback issue:', {
reason,
src: video.currentSrc || sourceUrl,
readyState: video.readyState,
networkState: video.networkState,
duration: video.duration,
configuredDurationSec,
reverseMode: currentTransition.reverseMode,
mediaError: getMediaErrorDetails(),
error: error instanceof Error ? error : { error },
});
};
const scheduleFinishByDuration = (durationSec: number) => {
if (
!Number.isFinite(durationSec) ||
durationSec <= 0 ||
finishTimerRef.current
) {
return;
}
finishTimerRef.current = setTimeout(
() => finishPlayback('duration-timer'),
durationSec * 1000 + durationBufferMs,
);
};
const attemptPlay = () => {
video.play().catch((playError) => {
if (!isReverseMode) {
logIssue('play-failed', playError);
}
});
};
const resolvePlayableSource = async (): Promise<string> => {
if (
lastLoadedBlobUrlRef.current &&
lastLoadedSourceUrlRef.current === sourceUrl
) {
logger.info('Reusing cached blob URL');
return lastLoadedBlobUrlRef.current;
}
if (
lastLoadedBlobUrlRef.current &&
lastLoadedSourceUrlRef.current !== sourceUrl
) {
revokeBlobUrl(true);
}
const needsBlobUrl = shouldLoadViaBlob(
sourceUrl,
currentTransition.reverseMode,
featuresRef.current?.useBlobUrl,
);
if (!needsBlobUrl) {
return sourceUrl;
}
const getCachedBlobUrl = preloadRef.current?.getCachedBlobUrl;
if (getCachedBlobUrl) {
try {
const cachedBlobUrl = await getCachedBlobUrl(sourceUrl);
if (cachedBlobUrl) {
logger.info('Using preloaded blob URL from cache', {
reverseMode: currentTransition.reverseMode,
});
lastLoadedBlobUrlRef.current = cachedBlobUrl;
lastLoadedSourceUrlRef.current = sourceUrl;
return cachedBlobUrl;
}
} catch (cacheError) {
logger.warn('Cache lookup failed, falling back to fetch', {
cacheError,
});
}
}
logger.info('Fetching video as blob for seeking support', {
reverseMode: currentTransition.reverseMode,
});
const token =
typeof window !== 'undefined'
? localStorage.getItem('token') || ''
: '';
const requestUrl = buildBlobRequestUrl(sourceUrl);
const response = await axios.get(requestUrl, {
responseType: 'blob',
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
});
const blobUrl = URL.createObjectURL(response.data);
lastLoadedBlobUrlRef.current = blobUrl;
lastLoadedSourceUrlRef.current = sourceUrl;
logger.info('Created blob URL for video', {
blobUrl: blobUrl.substring(0, 50),
});
return blobUrl;
};
const loadAndPlay = async () => {
logger.info('loadAndPlay called', {
reverseMode: currentTransition.reverseMode,
sourceUrl,
});
didStartPlaybackRef.current = false;
if (startWatchdogTimerRef.current) {
clearTimeout(startWatchdogTimerRef.current);
}
try {
const playableSourceUrl = await resolvePlayableSource();
if (didFinishRef.current) return;
video.pause();
stopReverseRef.current?.();
const isSameSource =
lastLoadedSourceUrlRef.current === playableSourceUrl;
if (isReverseMode && isSameSource && video.readyState >= 2) {
logger.info('Reusing buffered video for reverse', {
readyState: video.readyState,
duration: video.duration,
bufferedEnd: getBufferedEnd(video),
});
setPhase('reversing');
void startReverseRef.current?.();
return;
}
video.src = playableSourceUrl;
video.currentTime = 0;
video.load();
lastLoadedSourceUrlRef.current = playableSourceUrl;
attemptPlay();
startWatchdogTimerRef.current = setTimeout(() => {
if (didStartPlaybackRef.current || didFinishRef.current) return;
logIssue('playback-start-slow');
if (isReverseMode) {
didStartPlaybackRef.current = true;
setPhase('reversing');
void startReverseRef.current?.();
} else {
attemptPlay();
}
}, playbackStartMs);
} catch (error) {
logIssue('source-prepare-failed', error);
handleError('source-prepare-failed');
}
};
const onLoadedMetadata = () => {
if (didFinishRef.current) return;
if (!isReverseMode) {
video.currentTime = 0;
attemptPlay();
}
};
const onCanPlayThrough = () => {
if (didFinishRef.current) return;
logger.info('canplaythrough fired', {
reverseMode: currentTransition.reverseMode,
didStartPlayback: didStartPlaybackRef.current,
});
if (isReverseMode && !didStartPlaybackRef.current) {
didStartPlaybackRef.current = true;
if (startWatchdogTimerRef.current) {
clearTimeout(startWatchdogTimerRef.current);
startWatchdogTimerRef.current = null;
}
video.pause();
setPhase('reversing');
void startReverseRef.current?.();
}
};
const onCanPlay = () => {
if (didFinishRef.current) return;
if (isReverseMode && didStartPlaybackRef.current) return;
attemptPlay();
};
const onPlaying = () => {
logger.info('onPlaying fired', {
reverseMode: currentTransition.reverseMode,
didStartPlayback: didStartPlaybackRef.current,
didFinish: didFinishRef.current,
});
if (didFinishRef.current) return;
if (isReverseMode && !didStartPlaybackRef.current) {
logger.info('Triggering reverse from onPlaying');
didStartPlaybackRef.current = true;
if (startWatchdogTimerRef.current) {
clearTimeout(startWatchdogTimerRef.current);
startWatchdogTimerRef.current = null;
}
video.pause();
setPhase('reversing');
void startReverseRef.current?.();
return;
}
didStartPlaybackRef.current = true;
setPhase('playing');
if (startWatchdogTimerRef.current) {
clearTimeout(startWatchdogTimerRef.current);
startWatchdogTimerRef.current = null;
}
if (!isReverseMode) {
const mediaDurationSec = Number(video.duration);
const durationSec =
Number.isFinite(configuredDurationSec) && configuredDurationSec > 0
? configuredDurationSec
: Number.isFinite(mediaDurationSec) && mediaDurationSec > 0
? mediaDurationSec
: NaN;
if (Number.isFinite(durationSec) && durationSec > 0) {
scheduleFinishByDuration(durationSec);
}
}
};
const onEnded = () => finishPlayback('ended');
const onVideoError = () => {
if (didFinishRef.current) return;
logIssue('video-error');
handleError('video-error');
};
const onAbort = () => {
if (didFinishRef.current) return;
logIssue('video-abort');
handleError('video-abort');
};
const onStalled = () => {
if (didFinishRef.current) return;
logIssue('video-stalled');
};
video.addEventListener('loadedmetadata', onLoadedMetadata);
video.addEventListener('canplaythrough', onCanPlayThrough);
video.addEventListener('canplay', onCanPlay);
video.addEventListener('playing', onPlaying);
video.addEventListener('ended', onEnded);
video.addEventListener('error', onVideoError);
video.addEventListener('abort', onAbort);
video.addEventListener('stalled', onStalled);
hardTimeoutTimerRef.current = setTimeout(() => {
if (didFinishRef.current) return;
logIssue('hard-timeout');
handleError('hard-timeout');
}, hardTimeoutMs);
void loadAndPlay();
return () => {
video.removeEventListener('loadedmetadata', onLoadedMetadata);
video.removeEventListener('canplaythrough', onCanPlayThrough);
video.removeEventListener('canplay', onCanPlay);
video.removeEventListener('playing', onPlaying);
video.removeEventListener('ended', onEnded);
video.removeEventListener('error', onVideoError);
video.removeEventListener('abort', onAbort);
video.removeEventListener('stalled', onStalled);
clearTimers();
stopReverseRef.current?.();
};
}, [sourceUrl, videoRef, playbackStartMs, durationBufferMs, hardTimeoutMs, clearTimers, revokeBlobUrl, finishPlayback, handleError]);
useEffect(() => {
if (!transition) {
setPhase('idle');
activeSourceUrlRef.current = null;
didFinishRef.current = false;
didStartPlaybackRef.current = false;
}
}, [transition]);
return {
phase,
isBuffering: isReverseBufferingLocal,
isReversing,
cancel,
forceComplete,
};
}