/** * usePreloadOrchestrator Hook * * Main coordinator for online mode asset preloading. * Manages the priority queue and orchestrates downloads based on navigation. */ import { useEffect, useRef, useCallback, useState, useMemo } from 'react'; import { useNeighborGraph } from './useNeighborGraph'; import { useNetworkAware } from './useNetworkAware'; import { downloadEventBus } from '../lib/offline/DownloadEventBus'; import { downloadManager } from '../lib/offline/DownloadManager'; import { StorageManager } from '../lib/offline/StorageManager'; import { PRELOAD_CONFIG } from '../config/preload.config'; import { OFFLINE_CONFIG } from '../config/offline.config'; import { resolveAssetPlaybackUrl, extractStoragePath, queuePresignedUrls, isRelativeStoragePath, markPresignedUrlsVerified, isPresignedUrl, } from '../lib/assetUrl'; import { logger } from '../lib/logger'; import type { BlobUrlReadyEvent } from '../types/offline'; import type { PreloadPage, PreloadPageLink, PreloadElement, } from '../types/preload'; interface UsePreloadOrchestratorOptions { pages: PreloadPage[]; pageLinks: PreloadPageLink[]; elements: PreloadElement[]; currentPageId: string | null; pageHistory?: string[]; enabled?: boolean; maxNeighborDepth?: number; } interface PreloadQueueItem { id: string; url: string; storageKey?: string; // Original storage key for presigned URL cache invalidation priority: number; assetType: 'image' | 'video' | 'audio' | 'transition' | 'other'; pageId: string; } interface UsePreloadOrchestratorResult { isPreloading: boolean; preloadedUrls: Set; queueLength: number; /** Version counter that increments when blob URLs become ready (triggers re-renders) */ readyUrlsVersion: number; preloadAsset: (url: string, priority?: number) => void; clearQueue: () => void; getCachedBlobUrl: (url: string) => Promise; isUrlPreloaded: (url: string) => Promise; /** Instant lookup - returns decoded blob URL or null */ getReadyBlobUrl: (url: string) => string | null; /** Whether all neighbor page backgrounds are ready for instant navigation */ areNeighborBackgroundsReady: boolean; } /** * Generate a unique ID for preload jobs */ const generateJobId = (): string => { return `preload-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; }; /** * Map asset type string to AssetType enum expected by DownloadManager */ const mapAssetType = ( assetType: 'image' | 'video' | 'audio' | 'transition' | 'other', ): 'image' | 'video' | 'audio' | 'transition' | 'other' => { return assetType; }; export function usePreloadOrchestrator( options: UsePreloadOrchestratorOptions, ): UsePreloadOrchestratorResult { const { pages, pageLinks, elements, currentPageId, enabled = true, maxNeighborDepth = 1, // Only preload immediate neighbors by default } = options; const [isPreloading, setIsPreloading] = useState(false); const [preloadedUrls] = useState(() => new Set()); const [queueLength, setQueueLength] = useState(0); // Version counter to trigger re-renders when blob URLs become ready const [readyUrlsVersion, setReadyUrlsVersion] = useState(0); const queueRef = useRef([]); const isProcessingRef = useRef(false); const lastPreloadedPageRef = useRef(null); const lastPreloadedLinksCountRef = useRef(0); // Use neighbor graph for determining what to preload const neighborGraph = useNeighborGraph({ pages, pageLinks, elements, maxDepth: maxNeighborDepth, }); // Use network info for adaptive preloading const { networkInfo } = useNetworkAware(); // Compute whether all neighbor page backgrounds are ready for instant navigation // Uses readyUrlsVersion to trigger re-computation when blob URLs become ready const areNeighborBackgroundsReady = useMemo(() => { if (!currentPageId || !enabled) return true; // Assume ready if disabled // Use existing neighborGraph infrastructure const neighbors = neighborGraph.getNeighbors(currentPageId, 1); if (neighbors.length === 0) return true; // No neighbors = ready // Check if ALL neighbor background images have READY blob URLs // IMPORTANT: Use downloadManager.getReadyBlobUrl() NOT preloadedUrls.has() // preloadedUrls contains URLs that are QUEUED, not URLs that are READY // We need to check if the blob URL is actually available for instant display return neighbors.every(({ pageId }) => { const page = pages.find((p) => p.id === pageId); if (!page) return true; // Page not found = skip // If page has background image, check if blob URL is actually ready if (page.background_image_url) { const imageKey = extractStoragePath(page.background_image_url); // Check if blob URL is ready (not just queued) if (!downloadManager.getReadyBlobUrl(imageKey)) return false; } // If page has only video background (no image), it can stream - consider ready // This allows navigation to video-only pages without blocking return true; }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentPageId, enabled, neighborGraph, pages, readyUrlsVersion]); // Subscribe to blob URL ready events from DownloadManager useEffect(() => { const unsubscribe = downloadEventBus.on( OFFLINE_CONFIG.events.blobUrlReady as Parameters< typeof downloadEventBus.on >[0], (data: BlobUrlReadyEvent) => { logger.info('[PRELOAD] Blob URL ready from DownloadManager', { storageKey: data.storageKey.slice(-50), }); preloadedUrls.add(data.storageKey); setReadyUrlsVersion((v) => v + 1); }, ); return unsubscribe; }, [preloadedUrls]); // Cleanup blob URLs on unmount useEffect(() => { return () => { downloadManager.clearBlobUrls(); }; }, []); // Process the queue using DownloadManager const processQueue = useCallback(async () => { if (isProcessingRef.current) return; if (!networkInfo.isOnline) return; if (queueRef.current.length === 0) { setIsPreloading(false); return; } isProcessingRef.current = true; setIsPreloading(true); // Process all items in queue while (queueRef.current.length > 0) { const item = queueRef.current.shift(); if (!item) break; setQueueLength(queueRef.current.length); // Get canonical storage key const storageKey = item.storageKey || extractStoragePath(item.url); // Skip if already preloaded if (preloadedUrls.has(storageKey)) { continue; } logger.info('[PRELOAD] Queuing with DownloadManager', { url: item.url.slice(-50), storageKey: storageKey.slice(-50), assetType: item.assetType, priority: item.priority, }); // Use DownloadManager for unified download and blob URL creation // DownloadManager automatically handles presigned URL → proxy fallback downloadManager .addJob({ assetId: item.id, projectId: '', // Not needed for online preload url: item.url, filename: item.url.split('/').pop() || 'asset', variantType: 'original', assetType: mapAssetType(item.assetType), priority: item.priority, storageKey, createBlobUrl: true, // Create blob URL for instant display persist: false, // Don't persist for online preload (in-memory only) }) .then(() => { if (isPresignedUrl(item.url)) { markPresignedUrlsVerified(); } }) .catch((err) => { logger.error('[PRELOAD] Download failed', { url: item.url.slice(-50), error: err?.message, }); }); preloadedUrls.add(storageKey); } setIsPreloading(false); isProcessingRef.current = false; }, [networkInfo.isOnline, preloadedUrls]); // Add item to queue with priority sorting const addToQueue = useCallback( (item: PreloadQueueItem) => { const storageKey = item.storageKey || extractStoragePath(item.url); // Skip if already in queue or preloaded if ( preloadedUrls.has(storageKey) || queueRef.current.some( (q) => (q.storageKey || extractStoragePath(q.url)) === storageKey, ) ) { return; } logger.info('[PRELOAD] Adding to queue', { url: item.url.slice(-60), storageKey: storageKey.slice(-50), assetType: item.assetType, priority: item.priority, queueLength: queueRef.current.length + 1, }); // Insert in priority order (higher priority first) const insertIndex = queueRef.current.findIndex( (q) => q.priority < item.priority, ); if (insertIndex === -1) { queueRef.current.push(item); } else { queueRef.current.splice(insertIndex, 0, item); } setQueueLength(queueRef.current.length); processQueue(); }, [preloadedUrls, processQueue], ); // Manual preload function const preloadAsset = useCallback( (url: string, priority = 100) => { addToQueue({ id: generateJobId(), url, priority, assetType: 'other', pageId: currentPageId || '', }); }, [addToQueue, currentPageId], ); // Clear queue const clearQueue = useCallback(() => { queueRef.current = []; setQueueLength(0); }, []); // Get a cached asset as a blob URL (for video playback) // StorageManager.getAsset checks both IndexedDB (large files ≥ 5MB) and Cache API (small files) const getCachedBlobUrl = useCallback( async (url: string): Promise => { try { const blob = await StorageManager.getAsset(url); if (blob) { return URL.createObjectURL(blob); } return null; } catch { return null; } }, [], ); // Check if URL is preloaded (in cache) const isUrlPreloaded = useCallback( async (url: string): Promise => { const storageKey = extractStoragePath(url); // First check in-memory set if (preloadedUrls.has(storageKey)) return true; // Then check via StorageManager return StorageManager.hasAsset(storageKey); }, [preloadedUrls], ); // Instant lookup - returns decoded blob URL or null (O(1) Map lookup) // Uses DownloadManager's unified blob URL cache const getReadyBlobUrl = useCallback((url: string): string | null => { return downloadManager.getReadyBlobUrl(url); }, []); // Initialize ready blob URLs from cache for current page's assets // This ensures getReadyBlobUrl works on the first render for both backgrounds and element icons useEffect(() => { if (!currentPageId) return; const currentPage = pages.find((p) => p.id === currentPageId); if (!currentPage) return; const initializeFromCache = async () => { // Collect background URLs const bgUrls = [ currentPage.background_image_url, currentPage.background_video_url, currentPage.background_audio_url, ].filter(Boolean) as string[]; // Collect element asset URLs (icons, images, etc.) from current page const currentPageElements = elements.filter( (el) => el.pageId === currentPageId, ); const elementAssetUrls: string[] = []; currentPageElements.forEach((element) => { if (!element.content_json) return; try { const content = typeof element.content_json === 'string' ? JSON.parse(element.content_json) : element.content_json; // Extract URLs from known asset fields const urlFields = PRELOAD_CONFIG.assetFields.all as readonly string[]; const checkObject = (obj: Record) => { if (!obj || typeof obj !== 'object') return; for (const [key, value] of Object.entries(obj)) { if ( typeof value === 'string' && value && urlFields.includes(key) ) { elementAssetUrls.push(value); } else if (typeof value === 'object' && value !== null) { checkObject(value as Record); } } }; checkObject(content); } catch { // Ignore parse errors } }); // Initialize all URLs from cache via DownloadManager const allUrls = [...bgUrls, ...elementAssetUrls]; for (const storagePath of allUrls) { const storageKey = extractStoragePath(storagePath); // Skip if already ready if (downloadManager.getReadyBlobUrl(storageKey)) continue; // Check if cached and create blob URL if so const fullUrl = resolveAssetPlaybackUrl(storagePath); const hasAsset = await StorageManager.hasAsset(storageKey); if (hasAsset) { // Use DownloadManager.addJob with createBlobUrl to create the blob URL await downloadManager.addJob({ assetId: `init-${storageKey}`, projectId: '', url: fullUrl, filename: storageKey.split('/').pop() || 'asset', variantType: 'original', assetType: 'other', storageKey, createBlobUrl: true, persist: false, }); } } }; initializeFromCache(); }, [currentPageId, pages, elements]); // React to page changes - preload neighbors useEffect(() => { if (!enabled || !currentPageId || !networkInfo.isOnline) { return; } // Skip if we already preloaded for this page with the same data // Re-preload if pageLinks count changed (data just loaded) const currentLinksCount = pageLinks.length; const samePageAndData = lastPreloadedPageRef.current === currentPageId && lastPreloadedLinksCountRef.current === currentLinksCount; if (samePageAndData) { return; } lastPreloadedPageRef.current = currentPageId; lastPreloadedLinksCountRef.current = currentLinksCount; logger.info('[PRELOAD] Starting preload for page', { currentPageId, maxNeighborDepth, }); // Get prioritized assets based on current page const assets = neighborGraph.getPrioritizedAssets( currentPageId, maxNeighborDepth, ); logger.info('[PRELOAD] Found assets from neighbor graph', { assetCount: assets.length, assets: assets.map((a) => ({ type: a.assetType, url: a.url.slice(-50) })), }); // Collect all raw storage paths that need presigning const storagePaths: string[] = []; const currentPage = pages.find((p) => p.id === currentPageId); if ( currentPage?.background_image_url && isRelativeStoragePath(currentPage.background_image_url) ) { storagePaths.push(currentPage.background_image_url); } if ( currentPage?.background_video_url && isRelativeStoragePath(currentPage.background_video_url) ) { storagePaths.push(currentPage.background_video_url); } if ( currentPage?.background_audio_url && isRelativeStoragePath(currentPage.background_audio_url) ) { storagePaths.push(currentPage.background_audio_url); } assets.forEach((asset) => { if (isRelativeStoragePath(asset.url)) { storagePaths.push(asset.url); } }); // Always collect neighbor background URLs for presigning // This ensures instant navigation to neighbor pages const neighbors = neighborGraph.getNeighbors(currentPageId, 1); neighbors.forEach(({ pageId }) => { const page = pages.find((p) => p.id === pageId); if ( page?.background_image_url && isRelativeStoragePath(page.background_image_url) ) { storagePaths.push(page.background_image_url); } // Always collect neighbor video URLs for smooth transitions if ( page?.background_video_url && isRelativeStoragePath(page.background_video_url) ) { storagePaths.push(page.background_video_url); } // Also collect neighbor audio URLs if ( page?.background_audio_url && isRelativeStoragePath(page.background_audio_url) ) { storagePaths.push(page.background_audio_url); } }); // Batch fetch presigned URLs, then add to queue // Helper to resolve URL - prefer presigned if available, else fallback to proxy const resolveUrl = ( storageKey: string, presignedUrls: Record, ): string => { // Use presigned URL if available (will be tested on actual download) if (presignedUrls[storageKey]) { return presignedUrls[storageKey]; } // Fallback to resolveAssetPlaybackUrl (will use proxy) return resolveAssetPlaybackUrl(storageKey); }; // Two-phase preloading: current page first, then neighbors const addAssetsToQueue = async ( presignedUrls: Record = {}, ) => { // Helper to determine max bytes for partial preload (online mode only) // IMPORTANT: Only applies to NEIGHBOR pages, not the current page // Transitions always use partial preload (regardless of page) const getMaxBytesForAsset = ( assetType: 'image' | 'video' | 'audio' | 'transition' | 'other', isNeighborPage: boolean, ): number | undefined => { if (!PRELOAD_CONFIG.partialPreload.enabled) return undefined; // Transitions always use partial preload - they need just enough to start quickly if (assetType === 'transition') { return PRELOAD_CONFIG.partialPreload.transitionMaxBytes; } // Current page assets should be fully downloaded for best UX if (!isNeighborPage) return undefined; // Neighbor page media uses partial preload switch (assetType) { case 'video': return PRELOAD_CONFIG.partialPreload.videoMaxBytes; case 'audio': return PRELOAD_CONFIG.partialPreload.audioMaxBytes; default: return undefined; // Images need full download for display } }; // Helper to create download job const createDownloadJob = ( id: string, storageKey: string, priority: number, assetType: 'image' | 'video' | 'audio' | 'transition' | 'other', pageId: string, ): Promise | null => { const resolvedUrl = resolveUrl(storageKey, presignedUrls); if (!resolvedUrl) return null; const normalizedKey = isRelativeStoragePath(storageKey) ? storageKey : extractStoragePath(resolvedUrl); // Skip if already preloaded if (preloadedUrls.has(normalizedKey)) return null; preloadedUrls.add(normalizedKey); // Determine if partial preload applies (neighbor pages only, media files only) const isNeighborPage = pageId !== currentPageId; const maxBytes = getMaxBytesForAsset(assetType, isNeighborPage); // Create blob URL for images (instant navigation) and full downloads // Partial downloads (video/audio/transition) use presigned URL directly for playback const createBlobUrl = assetType === 'image' || maxBytes === undefined; // DownloadManager automatically handles presigned URL → proxy fallback return downloadManager .addJob({ assetId: id, projectId: '', url: resolvedUrl, filename: resolvedUrl.split('/').pop() || 'asset', variantType: 'original', assetType: mapAssetType(assetType), priority, storageKey: normalizedKey, createBlobUrl, persist: false, maxBytes, }) .then(() => { if (isPresignedUrl(resolvedUrl)) { markPresignedUrlsVerified(); } }) .catch((err) => { logger.error('[PRELOAD] Download failed', { url: resolvedUrl.slice(-50), error: err?.message, }); }); }; // ============================================ // PHASE 1: Load current page IMAGE backgrounds only and WAIT // Video/audio backgrounds stream on their own - don't block on them // ============================================ logger.info('[PRELOAD] Phase 1: Loading current page backgrounds'); const currentPageImageJobs: Promise[] = []; // Current page IMAGE background - WAIT for this (essential for visual) if (currentPage?.background_image_url) { const job = createDownloadJob( `bg-img-${currentPageId}`, currentPage.background_image_url, PRELOAD_CONFIG.priority.currentPage + 200, 'image', currentPageId, ); if (job) currentPageImageJobs.push(job); } // Current page VIDEO/AUDIO backgrounds - DON'T wait (they can stream) // These are started but not awaited - video player buffers on its own if (currentPage?.background_video_url) { createDownloadJob( `bg-vid-${currentPageId}`, currentPage.background_video_url, PRELOAD_CONFIG.priority.currentPage + 150, 'video', currentPageId, ); // Not pushed to awaited jobs - video streams on its own } if (currentPage?.background_audio_url) { createDownloadJob( `bg-aud-${currentPageId}`, currentPage.background_audio_url, PRELOAD_CONFIG.priority.currentPage + 100, 'audio', currentPageId, ); // Not pushed to awaited jobs - audio streams on its own } // Wait ONLY for IMAGE backgrounds (they're small and essential) // Video/audio can stream - don't block the page const phase1Start = Date.now(); if (currentPageImageJobs.length > 0) { logger.info('[PRELOAD] Waiting for current page image backgrounds', { count: currentPageImageJobs.length, }); await Promise.all(currentPageImageJobs); logger.info('[PRELOAD] Phase 1 complete', { elapsed: `${Date.now() - phase1Start}ms`, }); } else { logger.info('[PRELOAD] Phase 1 complete (no image backgrounds)'); } // ============================================ // PHASE 2: Preload everything else (don't wait) // - Current page element assets (full downloads) // - Neighbor page backgrounds (partial preload for video/audio) // - Neighbor page element assets (partial preload for video/audio) // - Transition videos from page links (partial preload - 3MB) // ============================================ logger.info('[PRELOAD] Phase 2: Preloading neighbors and transitions'); // Current page element assets (moved from Phase 1 for faster startup) const currentPageAssets = assets.filter( (asset) => asset.pageId === currentPageId, ); currentPageAssets.forEach((asset) => { createDownloadJob( generateJobId(), asset.url, asset.priority, asset.assetType, asset.pageId, ); }); // Neighbor page element assets const neighborAssets = assets.filter( (asset) => asset.pageId !== currentPageId, ); neighborAssets.forEach((asset) => { createDownloadJob( generateJobId(), asset.url, asset.priority, asset.assetType, asset.pageId, ); }); // Neighbor background assets const neighbors = neighborGraph.getNeighbors(currentPageId, 1); neighbors.forEach(({ pageId }) => { const page = pages.find((p) => p.id === pageId); if (page?.background_image_url) { createDownloadJob( `bg-img-${pageId}`, page.background_image_url, PRELOAD_CONFIG.priority.neighborBase + 100, 'image', pageId, ); } if (page?.background_video_url) { createDownloadJob( `bg-vid-${pageId}`, page.background_video_url, PRELOAD_CONFIG.priority.neighborBase + 50, 'video', pageId, ); } if (page?.background_audio_url) { createDownloadJob( `bg-aud-${pageId}`, page.background_audio_url, PRELOAD_CONFIG.priority.neighborBase + 30, 'audio', pageId, ); } }); logger.info('[PRELOAD] Phase 2: Neighbor assets queued'); }; // If there are storage paths to presign, fetch them first if (storagePaths.length > 0) { logger.info('[PRELOAD] Fetching presigned URLs', { count: storagePaths.length, }); queuePresignedUrls(storagePaths) .then(async () => { logger.info('[PRELOAD] Presigned URLs fetched, adding to queue'); // Note: Don't call markPresignedUrlsVerified() here - it's called after // first successful download to verify CORS is configured properly await addAssetsToQueue(); }) .catch(async (error) => { logger.error( '[PRELOAD] Failed to fetch presigned URLs, falling back to proxy', { error: error?.message, }, ); // Fallback: add to queue without presigned URLs (will use backend proxy) await addAssetsToQueue(); }); } else { // No storage paths to presign, add directly to queue addAssetsToQueue(); } }, [ enabled, currentPageId, networkInfo.isOnline, neighborGraph, pages, pageLinks, addToQueue, maxNeighborDepth, ]); return { isPreloading, preloadedUrls, queueLength, readyUrlsVersion, preloadAsset, clearQueue, getCachedBlobUrl, isUrlPreloaded, getReadyBlobUrl, areNeighborBackgroundsReady, }; }