/** * usePageNavigationState Hook * * Unified state machine for page navigation, replacing 6+ fragmented hooks. * Uses useReducer for atomic state transitions, preventing race conditions. * * Consolidates: * - usePageSwitch: URL resolution and switching * - useBackgroundState: Background ready tracking * - useBackgroundTransition: Fade-from-black effects * - useTransitionCleanup: Video cleanup coordination * - useBackgroundUrls: URL resolution for display * - pageLoadingUtils: Loading state computation * * State Machine Phases: * - idle: No navigation in progress, elements visible * - preparing: Navigation triggered, saving previous URLs, resolving new URLs * - transitioning: Video transition playing * - transition_done: Video finished, waiting for background to load * - loading_bg: Direct navigation (no video), waiting for background to load * - fading_in: Black overlay fading out to reveal new page */ import { useReducer, useCallback, useRef, useEffect, useMemo } from 'react'; import { resolveAssetPlaybackUrl, markPresignedUrlFailed, isRelativeStoragePath, isPresignedUrl, buildProxyUrl, } from '../lib/assetUrl'; import { scheduleAfterPaint, scheduleAfterPaintSafari, isSafari, getCrossfadeDuration, } from '../lib/browserUtils'; import { logger } from '../lib/logger'; import type { ResolvedTransitionSettings } from '../types/transition'; // ============================================================================ // Types // ============================================================================ /** * Navigation phases as a finite state machine */ export type NavigationPhase = | 'idle' // No navigation in progress | 'preparing' // Resolving URLs, saving previous state | 'transitioning' // Video transition playing | 'transition_done' // Video finished, waiting for background | 'loading_bg' // Direct navigation, waiting for background | 'fading_in'; // Black overlay fading out /** * Minimal page interface for navigation */ export interface NavigablePage { id: string; background_image_url?: string; background_video_url?: string; background_embed_url?: string; background_audio_url?: string; } /** * Preload cache provider interface */ export interface PreloadCacheProvider { getReadyBlobUrl?: (url: string) => string | null; getCachedBlobUrl?: (url: string) => Promise; preloadedUrls?: Set; } /** * Internal state structure */ interface NavigationState { phase: NavigationPhase; // Current page URLs (resolved for display) currentImageUrl: string; currentVideoUrl: string; currentEmbedUrl: string; currentAudioUrl: string; // Previous page URLs (for overlay during transition) previousImageUrl: string; previousVideoUrl: string; // Target page ID (during navigation) targetPageId: string | null; // Whether current navigation is a back navigation isBackNavigation: boolean; // Safari black flash prevention lastKnownBgUrl: string; // Video buffering state isVideoBuffering: boolean; } /** * Actions for the reducer */ type NavigationAction = | { type: 'START_NAVIGATION'; payload: { hasTransition: boolean; targetPageId: string | null; isBack: boolean; }; } | { type: 'START_TRANSITION'; payload: { targetPageId: string | null; isBack: boolean; }; } | { type: 'URLS_RESOLVED'; payload: { imageUrl: string; videoUrl: string; embedUrl: string; audioUrl: string; }; } | { type: 'TRANSITION_STARTED' } | { type: 'TRANSITION_ENDED' } | { type: 'BACKGROUND_READY' } | { type: 'FADE_STARTED' } | { type: 'FADE_COMPLETED' } | { type: 'SET_BACKGROUND_DIRECTLY'; payload: { imageUrl: string; videoUrl: string; embedUrl: string; audioUrl: string; }; } | { type: 'RESET_TO_IDLE' } | { type: 'SET_VIDEO_BUFFERING'; payload: boolean } | { type: 'UPDATE_LAST_KNOWN_BG'; payload: string } | { type: 'CLEAR_PREVIOUS_BACKGROUND' }; // ============================================================================ // Reducer // ============================================================================ const initialState: NavigationState = { phase: 'idle', currentImageUrl: '', currentVideoUrl: '', currentEmbedUrl: '', currentAudioUrl: '', previousImageUrl: '', previousVideoUrl: '', targetPageId: null, isBackNavigation: false, lastKnownBgUrl: '', isVideoBuffering: false, }; function navigationReducer( state: NavigationState, action: NavigationAction, ): NavigationState { // DevTools logging in development if (process.env.NODE_ENV === 'development') { logger.info('[NavigationState] Action:', { type: action.type, currentPhase: state.phase, payload: 'payload' in action ? action.payload : undefined, }); } switch (action.type) { case 'START_NAVIGATION': // ATOMIC: Save previous URLs + set phase in one update return { ...state, phase: action.payload.hasTransition ? 'transitioning' : 'loading_bg', previousImageUrl: state.currentImageUrl, previousVideoUrl: state.currentVideoUrl, targetPageId: action.payload.targetPageId, isBackNavigation: action.payload.isBack, }; case 'START_TRANSITION': // Video transition started (before video plays) // Sets phase to 'transitioning' and saves previous URLs atomically return { ...state, phase: 'transitioning', previousImageUrl: state.currentImageUrl, previousVideoUrl: state.currentVideoUrl, targetPageId: action.payload.targetPageId, isBackNavigation: action.payload.isBack, }; case 'URLS_RESOLVED': // URLs resolved, update current URLs return { ...state, currentImageUrl: action.payload.imageUrl, currentVideoUrl: action.payload.videoUrl, currentEmbedUrl: action.payload.embedUrl, currentAudioUrl: action.payload.audioUrl, }; case 'TRANSITION_STARTED': // Video transition has started playing if (state.phase !== 'transitioning') return state; return state; // Phase already correct, no change needed case 'TRANSITION_ENDED': // Video transition ended, wait for background if (state.phase !== 'transitioning') return state; return { ...state, phase: 'transition_done', }; case 'BACKGROUND_READY': // Background loaded, start fade-in (only from certain phases) if ( state.phase !== 'transition_done' && state.phase !== 'loading_bg' && state.phase !== 'preparing' ) { return state; } return { ...state, phase: 'fading_in', }; case 'FADE_STARTED': // Fade animation started if (state.phase !== 'fading_in') return state; return state; case 'FADE_COMPLETED': // Fade animation completed, return to idle return { ...state, phase: 'idle', previousImageUrl: '', previousVideoUrl: '', targetPageId: null, isBackNavigation: false, }; case 'SET_BACKGROUND_DIRECTLY': // Direct background update (edit mode) - bypasses navigation flow return { ...state, phase: 'idle', currentImageUrl: action.payload.imageUrl, currentVideoUrl: action.payload.videoUrl, currentEmbedUrl: action.payload.embedUrl, currentAudioUrl: action.payload.audioUrl, previousImageUrl: '', previousVideoUrl: '', }; case 'RESET_TO_IDLE': // Force reset to idle state return { ...state, phase: 'idle', previousImageUrl: '', previousVideoUrl: '', targetPageId: null, isBackNavigation: false, }; case 'SET_VIDEO_BUFFERING': return { ...state, isVideoBuffering: action.payload, }; case 'UPDATE_LAST_KNOWN_BG': // Update Safari black flash prevention snapshot return { ...state, lastKnownBgUrl: action.payload, }; case 'CLEAR_PREVIOUS_BACKGROUND': return { ...state, previousImageUrl: '', previousVideoUrl: '', }; default: return state; } } // ============================================================================ // Hook Options & Result // ============================================================================ export interface UsePageNavigationStateOptions { /** Preload cache provider for blob URL resolution */ preloadCache?: PreloadCacheProvider; /** Fade duration in milliseconds (default: 700) */ fadeDurationMs?: number; /** Transition settings for dynamic duration/easing */ transitionSettings?: ResolvedTransitionSettings | null; } export interface UsePageNavigationStateResult { // Current state phase: NavigationPhase; state: NavigationState; // Current page URLs (for display) currentImageUrl: string; currentVideoUrl: string; currentEmbedUrl: string; currentAudioUrl: string; // Previous page URLs (for overlay) previousImageUrl: string; previousVideoUrl: string; // Safari black flash prevention lastKnownBgUrl: string; // Derived states (computed from phase) isLoading: boolean; showSpinner: boolean; showElements: boolean; showPreviousOverlay: boolean; showTransitionVideo: boolean; isFadingIn: boolean; isSwitching: boolean; isNewBgReady: boolean; isBackgroundReady: boolean; pendingTransitionComplete: boolean; // Video buffering state isVideoBuffering: boolean; // Transition style for CSS transitionStyle: React.CSSProperties; // Actions /** Start navigation to a new page */ navigateToPage: ( targetPage: NavigablePage | null, options?: { hasTransition?: boolean; isBack?: boolean; onSwitched?: () => void; }, ) => Promise; /** Signal that background media is ready (call from CanvasBackground.onLoad) */ onBackgroundReady: () => void; /** Signal that transition video has ended */ onTransitionEnded: () => void; /** Reset background ready state (call before navigation) */ resetBackgroundReady: () => void; /** Clear previous background overlay */ clearPreviousBackground: () => void; /** Direct background update for edit mode (bypasses navigation flow) */ setBackgroundDirectly: ( imageUrl: string, videoUrl: string, embedUrl: string, audioUrl: string, ) => void; /** Reset to idle state */ resetToIdle: () => void; /** Video buffering state callback */ onVideoBufferStateChange: (isBuffering: boolean) => void; /** Mark new background as ready (for compatibility with usePageSwitch) */ markBackgroundReady: () => void; /** Start a video transition (sets phase to 'transitioning') */ startTransition: (targetPageId: string | null, isBack?: boolean) => void; } // ============================================================================ // Helper Functions // ============================================================================ /** * Decode an image from URL to ensure it's ready for display. * Safari-specific: waits extra frame after decode to ensure pixels are painted. */ const decodeImage = (url: string): Promise => { return new Promise((resolve) => { if (!url) { resolve(); return; } const img = new window.Image(); const safariMode = isSafari(); const onReady = () => { if (safariMode) { scheduleAfterPaintSafari(() => resolve()); } else { scheduleAfterPaint(() => resolve()); } }; img.onload = () => { if (typeof img.decode === 'function') { img.decode().then(onReady).catch(onReady); } else { onReady(); } }; img.onerror = () => onReady(); img.src = url; }); }; /** * Load and decode an image with presigned URL fallback. */ const loadImageWithFallback = ( url: string, storageKey?: string, ): Promise => { return new Promise((resolve) => { const img = new window.Image(); const safariMode = isSafari(); const onImageReady = (srcUrl: string) => { if (safariMode) { scheduleAfterPaintSafari(() => resolve(srcUrl)); } else { resolve(srcUrl); } }; const tryLoad = (srcUrl: string, isRetry = false) => { img.src = srcUrl; img.onload = () => { if (typeof img.decode === 'function') { img .decode() .then(() => onImageReady(srcUrl)) .catch(() => onImageReady(srcUrl)); } else { onImageReady(srcUrl); } }; img.onerror = () => { if (!isRetry && isPresignedUrl(srcUrl) && storageKey) { logger.info('Image presigned URL failed, retrying with proxy', { storageKey: storageKey.slice(-50), }); markPresignedUrlFailed(storageKey); const proxyUrl = buildProxyUrl(storageKey); tryLoad(proxyUrl, true); } else { onImageReady(srcUrl); } }; }; tryLoad(url); }); }; // ============================================================================ // Main Hook // ============================================================================ export function usePageNavigationState( options: UsePageNavigationStateOptions = {}, ): UsePageNavigationStateResult { const { preloadCache, transitionSettings } = options; const fadeDurationMs = options.fadeDurationMs ?? transitionSettings?.durationMs ?? 700; const [state, dispatch] = useReducer(navigationReducer, initialState); // Refs for stable callbacks const preloadCacheRef = useRef(preloadCache); preloadCacheRef.current = preloadCache; const transitionSettingsRef = useRef(transitionSettings); transitionSettingsRef.current = transitionSettings; // Track created blob URLs for cleanup const createdBlobUrlsRef = useRef>(new Set()); // Fade timer ref const fadeTimerRef = useRef | null>(null); // ============================================================================ // URL Resolution // ============================================================================ /** * Resolve a storage path to a displayable URL. */ const resolveToDisplayUrl = useCallback( async (storagePath: string | undefined): Promise => { if (!storagePath) return ''; const cache = preloadCacheRef.current; // 1. Try in-memory blob URL lookup (instant) if (cache?.getReadyBlobUrl) { const readyUrl = cache.getReadyBlobUrl(storagePath); if (readyUrl) { logger.info('Using ready blob URL (storage key)', { storagePath: storagePath.slice(-50), }); return readyUrl; } } // 2. Try persistent cache by storage path if (cache?.getCachedBlobUrl) { try { const blobUrl = await cache.getCachedBlobUrl(storagePath); if (blobUrl) { createdBlobUrlsRef.current.add(blobUrl); return blobUrl; } } catch { // Fall through } } // 3. Resolve to playback URL const originalUrl = resolveAssetPlaybackUrl(storagePath); if (!originalUrl) return ''; // Try blob URL lookup by resolved URL if (cache?.getReadyBlobUrl) { const readyUrl = cache.getReadyBlobUrl(originalUrl); if (readyUrl) { return readyUrl; } } // Try cached blob URL by resolved URL if (cache?.getCachedBlobUrl) { try { const blobUrl = await cache.getCachedBlobUrl(originalUrl); if (blobUrl) { createdBlobUrlsRef.current.add(blobUrl); return blobUrl; } } catch { // Fall through } } // Load with presigned URL fallback const storageKey = isRelativeStoragePath(storagePath) ? storagePath : undefined; return loadImageWithFallback(originalUrl, storageKey); }, [], ); /** * Resolve video/audio URL. */ const resolveMediaUrl = useCallback( async (storagePath: string | undefined): Promise => { if (!storagePath) return ''; const cache = preloadCacheRef.current; // 1. Try in-memory blob URL lookup if (cache?.getReadyBlobUrl) { const readyUrl = cache.getReadyBlobUrl(storagePath); if (readyUrl) return readyUrl; } // 2. Try persistent cache if (cache?.getCachedBlobUrl) { try { const blobUrl = await cache.getCachedBlobUrl(storagePath); if (blobUrl) { createdBlobUrlsRef.current.add(blobUrl); return blobUrl; } } catch { // Fall through } } // 3. Resolve URL const originalUrl = resolveAssetPlaybackUrl(storagePath); if (!originalUrl) return ''; if (cache?.getReadyBlobUrl) { const readyUrl = cache.getReadyBlobUrl(originalUrl); if (readyUrl) return readyUrl; } if (cache?.getCachedBlobUrl) { try { const blobUrl = await cache.getCachedBlobUrl(originalUrl); if (blobUrl) { createdBlobUrlsRef.current.add(blobUrl); return blobUrl; } } catch { // Fall through } } return originalUrl; }, [], ); // ============================================================================ // Actions // ============================================================================ const navigateToPage = useCallback( async ( targetPage: NavigablePage | null, options: { hasTransition?: boolean; isBack?: boolean; onSwitched?: () => void; } = {}, ) => { const { hasTransition = false, isBack = false, onSwitched } = options; if (!targetPage) { dispatch({ type: 'RESET_TO_IDLE' }); onSwitched?.(); return; } // Start navigation atomically dispatch({ type: 'START_NAVIGATION', payload: { hasTransition, targetPageId: targetPage.id, isBack, }, }); // Resolve URLs (may be async) const [imageUrl, videoUrl, embedUrl, audioUrl] = await Promise.all([ resolveToDisplayUrl(targetPage.background_image_url), resolveMediaUrl(targetPage.background_video_url), resolveMediaUrl(targetPage.background_embed_url), resolveMediaUrl(targetPage.background_audio_url), ]); // Update current URLs dispatch({ type: 'URLS_RESOLVED', payload: { imageUrl, videoUrl, embedUrl, audioUrl }, }); // Notify caller onSwitched?.(); // For blob URLs, decode image before marking ready if ( !hasTransition && (embedUrl || imageUrl.startsWith('blob:') || !imageUrl) ) { decodeImage(imageUrl).then(() => { dispatch({ type: 'BACKGROUND_READY' }); }); } }, [resolveToDisplayUrl, resolveMediaUrl], ); const onBackgroundReady = useCallback(() => { dispatch({ type: 'BACKGROUND_READY' }); }, []); const onTransitionEnded = useCallback(() => { dispatch({ type: 'TRANSITION_ENDED' }); }, []); const resetBackgroundReady = useCallback(() => { // This is called before navigation to reset state // The actual reset happens in START_NAVIGATION }, []); const clearPreviousBackground = useCallback(() => { dispatch({ type: 'CLEAR_PREVIOUS_BACKGROUND' }); }, []); const setBackgroundDirectly = useCallback( ( imageUrl: string, videoUrl: string, embedUrl: string, audioUrl: string, ) => { dispatch({ type: 'SET_BACKGROUND_DIRECTLY', payload: { imageUrl, videoUrl, embedUrl, audioUrl }, }); }, [], ); const resetToIdle = useCallback(() => { dispatch({ type: 'RESET_TO_IDLE' }); }, []); const onVideoBufferStateChange = useCallback((isBuffering: boolean) => { dispatch({ type: 'SET_VIDEO_BUFFERING', payload: isBuffering }); }, []); const markBackgroundReady = useCallback(() => { dispatch({ type: 'BACKGROUND_READY' }); }, []); const startTransition = useCallback( (targetPageId: string | null, isBack = false) => { dispatch({ type: 'START_TRANSITION', payload: { targetPageId, isBack }, }); }, [], ); // ============================================================================ // Effects // ============================================================================ // Update lastKnownBgUrl for Safari black flash prevention useEffect(() => { if (state.currentImageUrl) { dispatch({ type: 'UPDATE_LAST_KNOWN_BG', payload: state.currentImageUrl, }); } }, [state.currentImageUrl]); // Fade completion timer useEffect(() => { if (state.phase === 'fading_in') { const duration = getCrossfadeDuration( transitionSettingsRef.current?.durationMs, ); const bufferMs = isSafari() ? 100 : 50; fadeTimerRef.current = setTimeout(() => { fadeTimerRef.current = null; dispatch({ type: 'FADE_COMPLETED' }); }, duration + bufferMs); } return () => { if (fadeTimerRef.current) { clearTimeout(fadeTimerRef.current); fadeTimerRef.current = null; } }; }, [state.phase]); // ============================================================================ // Derived State // ============================================================================ const derived = useMemo( () => ({ isLoading: state.phase === 'preparing' || state.phase === 'loading_bg', // Show spinner when: // - Preparing navigation (resolving URLs) // - Loading background (no video transition) // - Transition video ended, waiting for background // - Video transition active but buffering (video not playing yet) showSpinner: state.phase === 'preparing' || state.phase === 'loading_bg' || state.phase === 'transition_done' || (state.phase === 'transitioning' && state.isVideoBuffering), showElements: state.phase === 'idle' || state.phase === 'fading_in', showPreviousOverlay: state.phase === 'loading_bg' || state.phase === 'transition_done', // Keep transition video overlay visible through entire video transition flow: // transitioning → transition_done → loading_bg → fading_in // The overlay is only rendered when transitionPreview is set, so this won't // affect direct navigation (no video transition). showTransitionVideo: state.phase === 'transitioning' || state.phase === 'transition_done' || state.phase === 'loading_bg' || state.phase === 'fading_in', isFadingIn: state.phase === 'fading_in', // Compatibility flags for existing components isSwitching: state.phase !== 'idle' && state.phase !== 'fading_in', isNewBgReady: state.phase === 'fading_in' || state.phase === 'idle', isBackgroundReady: state.phase === 'idle' || state.phase === 'fading_in', pendingTransitionComplete: state.phase === 'transition_done', }), [state.phase, state.isVideoBuffering], ); // Transition style const transitionStyle: React.CSSProperties = useMemo( () => ({ '--transition-duration': `${transitionSettings?.durationMs ?? 700}ms`, '--transition-easing': transitionSettings?.easing ?? 'ease-in-out', '--overlay-color': transitionSettings?.overlayColor ?? '#000000', }) as React.CSSProperties, [ transitionSettings?.durationMs, transitionSettings?.easing, transitionSettings?.overlayColor, ], ); return { // Current state phase: state.phase, state, // Current page URLs currentImageUrl: state.currentImageUrl, currentVideoUrl: state.currentVideoUrl, currentEmbedUrl: state.currentEmbedUrl, currentAudioUrl: state.currentAudioUrl, // Previous page URLs previousImageUrl: state.previousImageUrl, previousVideoUrl: state.previousVideoUrl, // Safari black flash prevention lastKnownBgUrl: state.lastKnownBgUrl, // Derived states ...derived, // Video buffering isVideoBuffering: state.isVideoBuffering, // Transition style transitionStyle, // Actions navigateToPage, onBackgroundReady, onTransitionEnded, resetBackgroundReady, clearPreviousBackground, setBackgroundDirectly, resetToIdle, onVideoBufferStateChange, markBackgroundReady, startTransition, }; }