import { useCallback, useEffect, useMemo, useRef, useState, type RefObject, } from 'react'; import axios from 'axios'; import { logger } from '../lib/logger'; import { markPresignedUrlFailed, isRelativeStoragePath, resolveAssetPlaybackUrl, isPresignedUrl, buildProxyUrl, extractStoragePath, } from '../lib/assetUrl'; import { downloadManager } from '../lib/offline/DownloadManager'; import { useReversePlayback } from './useReversePlayback'; export type ReverseMode = 'none' | 'reverse' | 'separate'; export interface TransitionConfig { videoUrl: string; storageKey?: string; // Raw storage path for cache lookup reverseMode: ReverseMode; reverseVideoUrl?: string; durationSec?: number; targetPageId?: string; displayName?: string; /** Whether this is a back navigation (for history management) */ isBack?: boolean; } export interface UseTransitionPlaybackOptions { videoRef: RefObject; transition: TransitionConfig | null; /** Called when playback completes. isBack indicates if this was a back navigation. */ onComplete: (targetPageId?: string, isBack?: boolean) => 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; getReadyBlobUrl?: (url: string) => 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 { 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 didTryFallbackRef = useRef(false); const currentPlayableUrlRef = 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(); // Seek back slightly to ensure last frame is visible // Some browsers show black after 'ended' event when currentTime === duration if ( video.duration && Number.isFinite(video.duration) && video.currentTime >= video.duration - 0.1 ) { video.currentTime = Math.max(0, video.duration - 0.05); } } 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, currentTransition?.isBack, ); }, [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, storageKey: transition?.storageKey, // Raw storage path for preload detection getCachedBlobUrl: preload?.getCachedBlobUrl, getReadyBlobUrl: preload?.getReadyBlobUrl, // O(1) instant lookup }); 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; } // Include reverseMode in the key so same video can play forward then reverse const sourceKey = `${sourceUrl}|${currentTransition.reverseMode}`; if (activeSourceUrlRef.current === sourceKey) { logger.info('Skipping duplicate effect for same source', { sourceUrl, reverseMode: currentTransition.reverseMode, }); return; } activeSourceUrlRef.current = sourceKey; didFinishRef.current = false; didStartPlaybackRef.current = false; didTryFallbackRef.current = false; currentPlayableUrlRef.current = null; 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; } // Finish slightly BEFORE the video ends to ensure last frame is visible // and prevent browser-specific 'ended' event quirks (black frame) const finishBeforeEndMs = 50; // 50ms before video naturally ends const finishMs = Math.max(100, durationSec * 1000 - finishBeforeEndMs); finishTimerRef.current = setTimeout( () => finishPlayback('duration-timer'), finishMs, ); }; const attemptPlay = () => { video.play().catch((playError) => { if (!isReverseMode) { logIssue('play-failed', playError); } }); }; const resolvePlayableSource = async (): Promise => { // 1. Try storage key lookup first (most reliable for cache hits) const getReadyBlobUrl = preloadRef.current?.getReadyBlobUrl; const storageKey = currentTransition.storageKey; if (getReadyBlobUrl && storageKey) { const readyUrl = getReadyBlobUrl(storageKey); if (readyUrl) { logger.info('Using ready blob URL from storage key', { storageKey: storageKey.slice(-50), }); return readyUrl; } } // 2. Try cached blob URL by storage key (post-refresh scenario) const getCachedBlobUrl = preloadRef.current?.getCachedBlobUrl; if (getCachedBlobUrl && storageKey) { try { const cachedBlobUrl = await getCachedBlobUrl(storageKey); if (cachedBlobUrl) { logger.info('Using cached blob URL from storage key', { storageKey: storageKey.slice(-50), }); lastLoadedBlobUrlRef.current = cachedBlobUrl; lastLoadedSourceUrlRef.current = sourceUrl; return cachedBlobUrl; } } catch { // Fall through } } // 3. Reuse cached blob URL if same source (existing logic) 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; } // 4. Try ready blob URL by resolved URL if (getReadyBlobUrl) { const readyUrl = getReadyBlobUrl(sourceUrl); if (readyUrl) { logger.info('Using ready blob URL from resolved URL', { url: sourceUrl.slice(-50), }); return readyUrl; } } // 5. Try cached blob URL by resolved URL 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, }); } } // 6. Fetch video as blob with presigned URL support // Follows usePageSwitch.loadImageWithFallback pattern: // Try presigned URL first (SW can intercept for caching), fallback to proxy if it fails logger.info('Fetching video as blob for seeking support', { reverseMode: currentTransition.reverseMode, }); // Re-resolve URL to get presigned URL if now available // (may have been cached since transition started) const freshUrl = storageKey ? resolveAssetPlaybackUrl(storageKey) : sourceUrl; const token = typeof window !== 'undefined' ? localStorage.getItem('token') || '' : ''; // Helper: Fetch video and return blob URL, caching for next time const fetchVideoAsBlob = async (url: string): Promise => { logger.info('Fetching video from URL', { url: url.slice(0, 80), isPresigned: isPresignedUrl(url), }); const response = await axios.get(url, { responseType: 'blob', headers: token ? { Authorization: `Bearer ${token}` } : undefined, }); const blob = response.data as Blob; // Cache for next time using existing DownloadManager pattern if (storageKey) { const normalizedKey = extractStoragePath(storageKey); const blobUrl = await downloadManager.cacheBlob(normalizedKey, blob, { assetType: 'transition', }); lastLoadedBlobUrlRef.current = blobUrl; lastLoadedSourceUrlRef.current = sourceUrl; return blobUrl; } // Fallback: create blob URL without caching const blobUrl = URL.createObjectURL(blob); lastLoadedBlobUrlRef.current = blobUrl; lastLoadedSourceUrlRef.current = sourceUrl; logger.info('Created blob URL for video (no caching)', { blobUrl: blobUrl.substring(0, 50), }); return blobUrl; }; try { // Try fetching with potentially presigned URL (SW can intercept if S3) return await fetchVideoAsBlob(freshUrl); } catch (error) { // If presigned URL failed and we have storage key, retry with proxy if (storageKey && isPresignedUrl(freshUrl)) { logger.info('Presigned URL failed, retrying with proxy', { storageKey: storageKey.slice(-40), }); markPresignedUrlFailed(storageKey); const proxyUrl = buildProxyUrl(storageKey); return await fetchVideoAsBlob(proxyUrl); } throw error; } }; 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), }); didStartPlaybackRef.current = true; // Prevent canplaythrough from double-starting setPhase('reversing'); void startReverseRef.current?.(); return; } video.src = playableSourceUrl; // For reverse mode, seek to a large value (browser clamps to duration) // This prevents showing frame 0 while loading video.currentTime = isReverseMode ? 999999 : 0; video.load(); lastLoadedSourceUrlRef.current = playableSourceUrl; currentPlayableUrlRef.current = playableSourceUrl; // Only attempt play for forward playback // For reverse mode, wait for canplaythrough to trigger startReverse() if (!isReverseMode) { 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; // Skip if reverse playback already started (avoid interference from seek events) if (isReverseMode && didStartPlaybackRef.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) return; // Don't play for reverse mode 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 = () => { // For reverse mode, ignore 'ended' event - wait for reverse playback to complete if (isReverseMode) return; finishPlayback('ended'); }; const onVideoError = async () => { if (didFinishRef.current) return; logIssue('video-error'); // Check if this is a presigned URL failure (likely CORS) const currentUrl = currentPlayableUrlRef.current; if ( currentUrl && isPresignedUrl(currentUrl) && !didTryFallbackRef.current ) { logger.info('Presigned URL failed, trying proxy fallback', { url: currentUrl.slice(0, 80), }); // Mark presigned URL as failed so future resolves use proxy // Extract storage key from the original transition videoUrl const originalVideoUrl = currentTransition.videoUrl; if (originalVideoUrl && isRelativeStoragePath(originalVideoUrl)) { markPresignedUrlFailed(originalVideoUrl); } // Get proxy fallback URL using storage key const videoStorageKey = currentTransition.videoUrl; if (videoStorageKey && isRelativeStoragePath(videoStorageKey)) { const fallbackUrl = buildProxyUrl(videoStorageKey); didTryFallbackRef.current = true; video.pause(); video.src = fallbackUrl; currentPlayableUrlRef.current = fallbackUrl; video.currentTime = 0; video.load(); attemptPlay(); return; } } 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, transition?.reverseMode, // Re-run effect when reverseMode changes (same video, different direction) videoRef, playbackStartMs, 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, }; }