/** * usePageSwitch Hook * * Unified page navigation hook that eliminates white/black flashes during page transitions. * Uses preloaded blob URLs when available and keeps previous background visible * until new one is ready to paint. * * Features: * - Blob URL resolution from preload cache (instant display) * - Presigned URL fallback (retries with proxy on CORS failure) * - Previous background overlay for smooth transitions * - Ready state management for Image onLoad coordination */ import { useCallback, useRef, useState } from 'react'; import { resolveAssetPlaybackUrl, markPresignedUrlFailed, isRelativeStoragePath, isPresignedUrl, buildProxyUrl, } from '../lib/assetUrl'; import { logger } from '../lib/logger'; /** * Minimal page interface for page switching */ export interface SwitchablePage { id: string; background_image_url?: string; background_video_url?: string; background_audio_url?: string; // Background video playback settings background_video_autoplay?: boolean; background_video_loop?: boolean; background_video_muted?: boolean; background_video_start_time?: number | null; background_video_end_time?: number | null; } /** * Preload cache provider interface */ export interface PreloadCacheProvider { /** Instant lookup - returns decoded blob URL ready to display (O(1)) */ getReadyBlobUrl?: (url: string) => string | null; /** Fallback: async blob URL from cache (creates new blob URL) */ getCachedBlobUrl?: (url: string) => Promise; preloadedUrls?: Set; } export interface UsePageSwitchOptions { /** Preload cache provider for blob URL resolution */ preloadCache?: PreloadCacheProvider; } export interface UsePageSwitchResult { /** Currently displayed background image URL */ currentBgImageUrl: string; /** Currently displayed background video URL */ currentBgVideoUrl: string; /** Currently displayed background audio URL */ currentBgAudioUrl: string; /** Previous background image URL (for overlay) */ previousBgImageUrl: string; /** Whether we're in the middle of a page switch */ isSwitching: boolean; /** Whether the new background is ready to display */ isNewBgReady: boolean; /** * Switch to a new page with smooth transition. * Resolves blob URLs from cache, shows previous background until new one is ready. */ switchToPage: ( targetPage: SwitchablePage | null, onSwitched?: () => void, ) => Promise; /** * Directly set backgrounds without transition overlay. * Use for initial page load. */ setBackgroundsDirectly: ( imageUrl: string, videoUrl: string, audioUrl: string, ) => void; /** * Mark the new background as ready to display. * Call this from Image onLoad callback. */ markBackgroundReady: () => void; /** * Clear the previous background overlay. * Call after transition completes or when ready to show new background. */ clearPreviousBackground: () => void; } /** * Load and decode an image with presigned URL fallback. * Returns the URL that successfully loaded. */ const loadImageWithFallback = ( url: string, storageKey?: string, ): Promise => { return new Promise((resolve) => { const img = new window.Image(); const tryLoad = (srcUrl: string, isRetry = false) => { img.src = srcUrl; img.onload = () => { if (typeof img.decode === 'function') { img .decode() .then(() => resolve(srcUrl)) .catch(() => resolve(srcUrl)); } else { resolve(srcUrl); } }; img.onerror = () => { // If presigned URL failed and we have storage key, retry with proxy 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 { // Give up but still resolve to not block navigation resolve(srcUrl); } }; }; tryLoad(url); }); }; /** * Hook for smooth page switching without white/black flashes. * * Strategy: * 1. When switching pages, check if target background is in preload cache * 2. If cached, use blob URL (instant local data) * 3. If not cached, load with presigned URL fallback * 4. Keep previous background visible until new one is ready * * @example * const pageSwitch = usePageSwitch({ * preloadCache: { * getCachedBlobUrl: preloadOrchestrator?.getCachedBlobUrl, * preloadedUrls: preloadOrchestrator?.preloadedUrls, * }, * }); * * // Switch to a page (with transition) * await pageSwitch.switchToPage(targetPage, () => { * setActivePageId(targetPage.id); * }); * * // In render, show previous background overlay while switching * {pageSwitch.previousBgImageUrl && !pageSwitch.isNewBgReady && ( *
* )} * * // On Image onLoad, mark background as ready * pageSwitch.markBackgroundReady()} /> */ export function usePageSwitch( options: UsePageSwitchOptions = {}, ): UsePageSwitchResult { const { preloadCache } = options; // Ref to track preload cache (avoids dependency issues with object identity) const preloadCacheRef = useRef(preloadCache); preloadCacheRef.current = preloadCache; // Current backgrounds const [currentBgImageUrl, setCurrentBgImageUrl] = useState(''); const [currentBgVideoUrl, setCurrentBgVideoUrl] = useState(''); const [currentBgAudioUrl, setCurrentBgAudioUrl] = useState(''); // Refs to track current URLs for use in callbacks (avoids dependency issues) const currentBgImageUrlRef = useRef(''); const currentBgVideoUrlRef = useRef(''); const currentBgAudioUrlRef = useRef(''); currentBgImageUrlRef.current = currentBgImageUrl; currentBgVideoUrlRef.current = currentBgVideoUrl; currentBgAudioUrlRef.current = currentBgAudioUrl; // Previous background for overlay const [previousBgImageUrl, setPreviousBgImageUrl] = useState(''); const previousBgImageUrlRef = useRef(''); previousBgImageUrlRef.current = previousBgImageUrl; // Transition state const [isSwitching, setIsSwitching] = useState(false); const [isNewBgReady, setIsNewBgReady] = useState(true); // Track blob URLs we created so we can revoke them const createdBlobUrlsRef = useRef>(new Set()); /** * Revoke blob URLs that we created to prevent memory leaks */ const revokeBlobUrl = useCallback((url: string) => { if (url.startsWith('blob:') && createdBlobUrlsRef.current.has(url)) { URL.revokeObjectURL(url); createdBlobUrlsRef.current.delete(url); } }, []); /** * Resolve a storage path to a displayable URL. * Priority: 1) ready blob URL by storage path, 2) cached blob URL by storage path, * 3) ready blob URL by resolved URL, 4) cached blob URL, 5) presigned URL with fallback */ const resolveToDisplayUrl = useCallback( async (storagePath: string | undefined): Promise => { if (!storagePath) return ''; const cache = preloadCacheRef.current; // 1. Try in-memory storage path lookup first (instant, same session) 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 (survives page refresh) if (cache?.getCachedBlobUrl) { try { const blobUrl = await cache.getCachedBlobUrl(storagePath); if (blobUrl) { createdBlobUrlsRef.current.add(blobUrl); logger.info('Using cached blob URL (storage key)', { storagePath: storagePath.slice(-50), }); return blobUrl; } } catch { // Fall through to URL resolution } } // 3. Resolve to playback URL and try lookup (fallback for resolved URLs) const originalUrl = resolveAssetPlaybackUrl(storagePath); if (!originalUrl) return ''; // Try instant blob URL lookup by resolved URL if (cache?.getReadyBlobUrl) { const readyUrl = cache.getReadyBlobUrl(originalUrl); if (readyUrl) { logger.info('Using ready blob URL', { url: originalUrl.slice(-50) }); return readyUrl; } } // Fallback: try cached blob URL by resolved URL (check Cache API directly) if (cache?.getCachedBlobUrl) { try { const blobUrl = await cache.getCachedBlobUrl(originalUrl); if (blobUrl) { createdBlobUrlsRef.current.add(blobUrl); logger.info('Using cached blob URL for background', { originalUrl: originalUrl.slice(-50), }); return blobUrl; } } catch { // Fall through } } // Load with presigned URL fallback (handles CORS failures) const storageKey = isRelativeStoragePath(storagePath) ? storagePath : undefined; return loadImageWithFallback(originalUrl, storageKey); }, [], ); /** * Resolve video/audio URL. * Priority: 1) ready blob URL by storage path, 2) cached blob URL by storage path, * 3) ready blob URL by resolved URL, 4) cached blob URL, 5) resolved URL */ const resolveMediaUrl = useCallback( async (storagePath: string | undefined): Promise => { if (!storagePath) return ''; const cache = preloadCacheRef.current; // 1. Try in-memory storage path lookup first (instant, same session) if (cache?.getReadyBlobUrl) { const readyUrl = cache.getReadyBlobUrl(storagePath); if (readyUrl) { return readyUrl; } } // 2. Try persistent cache by storage path (survives page refresh) if (cache?.getCachedBlobUrl) { try { const blobUrl = await cache.getCachedBlobUrl(storagePath); if (blobUrl) { createdBlobUrlsRef.current.add(blobUrl); return blobUrl; } } catch { // Fall through } } // 3. Resolve URL and try lookup by resolved URL const originalUrl = resolveAssetPlaybackUrl(storagePath); if (!originalUrl) return ''; if (cache?.getReadyBlobUrl) { const readyUrl = cache.getReadyBlobUrl(originalUrl); if (readyUrl) { return readyUrl; } } // Try cached blob URL by resolved URL (check Cache API directly) if (cache?.getCachedBlobUrl) { try { const blobUrl = await cache.getCachedBlobUrl(originalUrl); if (blobUrl) { createdBlobUrlsRef.current.add(blobUrl); return blobUrl; } } catch { // Fall through } } return originalUrl; }, [], ); /** * Switch to a new page with smooth transition */ const switchToPage = useCallback( async (targetPage: SwitchablePage | null, onSwitched?: () => void) => { if (!targetPage) { setCurrentBgImageUrl(''); setCurrentBgVideoUrl(''); setCurrentBgAudioUrl(''); setPreviousBgImageUrl(''); setIsSwitching(false); setIsNewBgReady(true); onSwitched?.(); return; } // Save current image as previous for overlay (use ref to avoid dependency) if (currentBgImageUrlRef.current) { setPreviousBgImageUrl(currentBgImageUrlRef.current); } setIsSwitching(true); setIsNewBgReady(false); // Resolve URLs in parallel, preferring cached blob URLs const [imageUrl, videoUrl, audioUrl] = await Promise.all([ resolveToDisplayUrl(targetPage.background_image_url), resolveMediaUrl(targetPage.background_video_url), resolveMediaUrl(targetPage.background_audio_url), ]); // Set new backgrounds setCurrentBgImageUrl(imageUrl); setCurrentBgVideoUrl(videoUrl); setCurrentBgAudioUrl(audioUrl); // Notify caller that backgrounds are set onSwitched?.(); // For blob URLs, mark ready immediately (local data) if (imageUrl.startsWith('blob:') || !imageUrl) { requestAnimationFrame(() => { requestAnimationFrame(() => { setIsNewBgReady(true); }); }); } // For remote images, wait for Image onLoad (caller should use markBackgroundReady) }, [resolveToDisplayUrl, resolveMediaUrl], ); /** * Directly set backgrounds without transition overlay */ const setBackgroundsDirectly = useCallback( (imageUrl: string, videoUrl: string, audioUrl: string) => { // Revoke old blob URLs (use refs to avoid dependency) revokeBlobUrl(currentBgImageUrlRef.current); revokeBlobUrl(currentBgVideoUrlRef.current); revokeBlobUrl(currentBgAudioUrlRef.current); revokeBlobUrl(previousBgImageUrlRef.current); setCurrentBgImageUrl(imageUrl); setCurrentBgVideoUrl(videoUrl); setCurrentBgAudioUrl(audioUrl); setPreviousBgImageUrl(''); setIsSwitching(false); setIsNewBgReady(true); }, [revokeBlobUrl], ); /** * Mark background as ready (call from Image onLoad) */ const markBackgroundReady = useCallback(() => { setIsNewBgReady(true); }, []); /** * Clear the previous background overlay */ const clearPreviousBackground = useCallback(() => { const prevUrl = previousBgImageUrlRef.current; setPreviousBgImageUrl(''); setIsSwitching(false); // Revoke the previous blob URL after clearing if (prevUrl) { revokeBlobUrl(prevUrl); } }, [revokeBlobUrl]); return { currentBgImageUrl, currentBgVideoUrl, currentBgAudioUrl, previousBgImageUrl, isSwitching, isNewBgReady, switchToPage, setBackgroundsDirectly, markBackgroundReady, clearPreviousBackground, }; }