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; 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; getCachedBlobUrl?: (url: string) => Promise; }; } 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 { if (urls.length === 0) return; const decodePromises = urls.map( (url) => new Promise((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((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('idle'); const [isReverseBufferingLocal, setIsReverseBufferingLocal] = useState(false); const didFinishRef = useRef(false); const didStartPlaybackRef = useRef(false); const activeSourceUrlRef = useRef(null); const lastLoadedBlobUrlRef = useRef(null); const lastLoadedSourceUrlRef = useRef(null); const startWatchdogTimerRef = useRef | null>( null, ); const finishTimerRef = useRef | null>(null); const hardTimeoutTimerRef = useRef | 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) | 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 => { 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, }; }