/** * useTransitionPlayback Hook * * Handles video transition playback between pages. * For back navigation, uses pre-reversed video generated by the backend. * No frame-stepping - all transitions play forward. */ import { useCallback, useEffect, useMemo, useRef, useState, type RefObject, } from 'react'; import axios from 'axios'; import { logger } from '../lib/logger'; import { markPresignedUrlFailed, resolveAssetPlaybackUrl, isPresignedUrl, buildProxyUrl, extractStoragePath, } from '../lib/assetUrl'; import { downloadManager } from '../lib/offline/DownloadManager'; import { isSafari, isFirefox, scheduleAfterPaint } from '../lib/browserUtils'; export type ReverseMode = 'none' | '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' | 'finishing' | 'completed'; export interface UseTransitionPlaybackResult { phase: PlaybackPhase; isBuffering: boolean; isReversing: boolean; cancel: () => void; forceComplete: () => void; } const DEFAULT_TIMEOUTS = { playbackStartMs: 3000, durationBufferMs: 200, hardTimeoutMs: 45000, }; /** * Get browser-specific finish offset for transition videos. * This is a backup timer - requestVideoFrameCallback is primary for modern browsers. * * @returns Finish offset in milliseconds before video end */ const getFinishBeforeEndMs = (): number => { if (isSafari()) { return 350; } if (isFirefox()) { return 300; } return 300; }; function shouldLoadViaBlob(url: string, useBlobUrlOption?: boolean): boolean { if (useBlobUrlOption === false) return false; 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; } } 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 hardTimeoutMs = customTimeouts?.hardTimeoutMs ?? DEFAULT_TIMEOUTS.hardTimeoutMs; const [phase, setPhase] = useState('idle'); const [isVideoReady, setIsVideoReady] = useState(false); const didFinishRef = useRef(false); const didStartPlaybackRef = useRef(false); const activeSourceUrlRef = useRef(null); const lastLoadedBlobUrlRef = useRef(null); const lastLoadedSourceUrlRef = useRef(null); const didTryDecodeRetryRef = 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); // Determine which video URL to use: // For back navigation with a reversed video, use reverseVideoUrl // Otherwise, use the original videoUrl const sourceUrl = useMemo(() => { if (!transition) return ''; if (transition.isBack) { return transition.reverseVideoUrl || ''; } return transition.videoUrl; }, [transition]); // Storage key for cache lookup - use reversed video key for back navigation const storageKey = useMemo(() => { if (!transition) return undefined; if (transition.isBack) { return transition.reverseVideoUrl || undefined; } return transition.storageKey; }, [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) { // Just pause - don't seek. We've already stopped at a safe frame // (triggered by rvfc/timeupdate well before any black frames). // Seeking is async and can cause a flash before the seek completes. 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, 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], ); useEffect(() => { onCompleteRef.current = onComplete; onErrorRef.current = onError; transitionRef.current = transition; featuresRef.current = features; preloadRef.current = preload; }); const cancel = useCallback(() => { if (phase === 'idle') return; clearTimers(); 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) { return; } if (!sourceUrl) { logger.info('No playable transition source, skipping playback', { isBack: currentTransition.isBack, targetPageId: currentTransition.targetPageId, }); void finishPlayback('missing-source'); return; } // Include isBack in the key so same video can play forward or as reversed const sourceKey = `${sourceUrl}|${currentTransition.isBack ? 'back' : 'forward'}`; if (activeSourceUrlRef.current === sourceKey) { logger.info('Skipping duplicate effect for same source', { sourceUrl, isBack: currentTransition.isBack, }); return; } activeSourceUrlRef.current = sourceKey; didFinishRef.current = false; didStartPlaybackRef.current = false; didTryDecodeRetryRef.current = false; currentPlayableUrlRef.current = null; setPhase('preparing'); 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, isBack: currentTransition.isBack, mediaError: getMediaErrorDetails(), error: error instanceof Error ? error : { error }, }); }; const scheduleFinishByDuration = (durationSec: number) => { if ( !Number.isFinite(durationSec) || durationSec <= 0 || finishTimerRef.current ) { return; } // Use browser-specific offset to prevent black flash at video end const finishBeforeEndMs = getFinishBeforeEndMs(); const finishMs = Math.max(100, durationSec * 1000 - finishBeforeEndMs); finishTimerRef.current = setTimeout( () => finishPlayback('duration-timer'), finishMs, ); }; const attemptPlay = () => { video.play().catch((playError) => { logIssue('play-failed', playError); }); }; const resolvePlayableSource = async (): Promise => { const getReadyBlobUrl = preloadRef.current?.getReadyBlobUrl; const currentStorageKey = storageKey; // 1. Try storage key lookup first (most reliable for cache hits) if (getReadyBlobUrl && currentStorageKey) { const readyUrl = getReadyBlobUrl(currentStorageKey); if (readyUrl) { logger.info('Using ready blob URL from storage key', { storageKey: currentStorageKey.slice(-50), }); return readyUrl; } } // 2. Try cached blob URL by storage key const getCachedBlobUrl = preloadRef.current?.getCachedBlobUrl; if (getCachedBlobUrl && currentStorageKey) { try { const cachedBlobUrl = await getCachedBlobUrl(currentStorageKey); if (cachedBlobUrl) { logger.info('Using cached blob URL from storage key', { storageKey: currentStorageKey.slice(-50), }); lastLoadedBlobUrlRef.current = cachedBlobUrl; lastLoadedSourceUrlRef.current = sourceUrl; return cachedBlobUrl; } } catch { // Fall through } } // 3. Reuse cached blob URL if same source 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, 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', { isBack: currentTransition.isBack, }); 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 logger.info('Fetching video as blob', { isBack: currentTransition.isBack, }); const freshUrl = currentStorageKey ? resolveAssetPlaybackUrl(currentStorageKey) : sourceUrl; const token = typeof window !== 'undefined' ? localStorage.getItem('token') || '' : ''; 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, baseURL: '', }); const blob = response.data as Blob; if (currentStorageKey) { const normalizedKey = extractStoragePath(currentStorageKey); const blobUrl = await downloadManager.cacheBlob(normalizedKey, blob, { assetType: 'transition', }); lastLoadedBlobUrlRef.current = blobUrl; lastLoadedSourceUrlRef.current = sourceUrl; return blobUrl; } 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 { return await fetchVideoAsBlob(freshUrl); } catch (error) { if (currentStorageKey && isPresignedUrl(freshUrl)) { logger.info('Presigned URL failed, retrying with proxy', { storageKey: currentStorageKey.slice(-40), }); markPresignedUrlFailed(currentStorageKey); const proxyUrl = buildProxyUrl(currentStorageKey); return await fetchVideoAsBlob(proxyUrl); } throw error; } }; const loadAndPlay = async () => { logger.info('loadAndPlay called', { isBack: currentTransition.isBack, sourceUrl, }); setIsVideoReady(false); // Reset for new playback didStartPlaybackRef.current = false; if (startWatchdogTimerRef.current) { clearTimeout(startWatchdogTimerRef.current); } try { const playableSourceUrl = await resolvePlayableSource(); if (didFinishRef.current) return; video.pause(); video.src = playableSourceUrl; video.currentTime = 0; video.load(); lastLoadedSourceUrlRef.current = playableSourceUrl; currentPlayableUrlRef.current = playableSourceUrl; attemptPlay(); startWatchdogTimerRef.current = setTimeout(() => { if (didStartPlaybackRef.current || didFinishRef.current) return; logIssue('playback-start-slow'); attemptPlay(); }, playbackStartMs); } catch (error) { logIssue('source-prepare-failed', error); handleError('source-prepare-failed'); } }; const onLoadedMetadata = () => { if (didFinishRef.current) return; video.currentTime = 0; attemptPlay(); }; const onCanPlay = () => { if (didFinishRef.current) return; attemptPlay(); }; const onPlaying = () => { logger.info('onPlaying fired', { isBack: currentTransition.isBack, didStartPlayback: didStartPlaybackRef.current, didFinish: didFinishRef.current, }); if (didFinishRef.current) return; didStartPlaybackRef.current = true; setPhase('playing'); if (startWatchdogTimerRef.current) { clearTimeout(startWatchdogTimerRef.current); startWatchdogTimerRef.current = null; } // Use requestVideoFrameCallback for precise frame-level timing (Safari 15.4+) // This fires when a frame is actually sent to the compositor - no guesswork if ('requestVideoFrameCallback' in video) { const rvfc = video.requestVideoFrameCallback.bind(video); // First callback: frame is composited, safe to show overlay rvfc((_now, _metadata) => { if (!didFinishRef.current) { setIsVideoReady(true); } // Monitor video position to finish before end (prevents END flash) // Note: rvfc fires AFTER frame is composited, so we need extra buffer const monitorEnd = ( _now2: number, metadata: VideoFrameCallbackMetadata, ) => { if (didFinishRef.current) return; const duration = video.duration; // Finish 300ms before end - gives margin for black/fade frames // that some videos have in the last 100-200ms if ( Number.isFinite(duration) && metadata.mediaTime >= duration - 0.3 ) { finishPlayback('rvfc-end'); return; } // Continue monitoring each frame rvfc(monitorEnd); }; rvfc(monitorEnd); }); } else { // Fallback for older browsers without requestVideoFrameCallback scheduleAfterPaint(() => { if (!didFinishRef.current) { setIsVideoReady(true); } }); } 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'); }; // Backup handler for browsers without requestVideoFrameCallback // Note: timeupdate fires infrequently (~250ms in Safari), so this is just a fallback const onTimeUpdate = () => { if (didFinishRef.current) return; const duration = video.duration; if (!Number.isFinite(duration)) return; // Large buffer since timeupdate is infrequent // Safari: 600ms, Others: 400ms const safetyBuffer = isSafari() ? 0.6 : 0.4; if (video.currentTime >= duration - safetyBuffer) { finishPlayback('timeupdate-end'); } }; const onVideoError = async () => { if (didFinishRef.current) return; logIssue('video-error'); const errorCode = video.error?.code; if (errorCode === 3 && !didTryDecodeRetryRef.current) { logger.info('Safari video decode error, attempting reload'); didTryDecodeRetryRef.current = true; 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('canplay', onCanPlay); video.addEventListener('playing', onPlaying); video.addEventListener('ended', onEnded); video.addEventListener('timeupdate', onTimeUpdate); 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('canplay', onCanPlay); video.removeEventListener('playing', onPlaying); video.removeEventListener('ended', onEnded); video.removeEventListener('timeupdate', onTimeUpdate); video.removeEventListener('error', onVideoError); video.removeEventListener('abort', onAbort); video.removeEventListener('stalled', onStalled); clearTimers(); }; }, [ sourceUrl, storageKey, transition?.isBack, videoRef, playbackStartMs, hardTimeoutMs, clearTimers, revokeBlobUrl, finishPlayback, handleError, ]); useEffect(() => { if (!transition) { setPhase('idle'); setIsVideoReady(false); activeSourceUrlRef.current = null; didFinishRef.current = false; didStartPlaybackRef.current = false; } }, [transition]); return { phase, // Show buffering until video first frame is painted (prevents START black flash) isBuffering: phase === 'preparing' || (phase === 'playing' && !isVideoReady), isReversing: false, // No longer support frame-stepping reverse cancel, forceComplete, }; }