/** * usePreloadOrchestrator Hook * * Coordinates asset preloading based on navigation. * Preloads current page assets and outgoing transition videos. */ import { useEffect, useRef, useCallback, useState } from 'react'; import { useNetworkAware } from './useNetworkAware'; import { extractElementAssets } from '../lib/assetCache'; 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; } interface PreloadQueueItem { id: string; url: string; storageKey?: string; priority: number; assetType: 'image' | 'video' | 'audio' | 'transition' | 'other'; pageId: string; } export type PreloadPhase = | 'idle' | 'phase1_current_page' | 'phase2_transitions' | 'complete'; interface UsePreloadOrchestratorResult { isPreloading: boolean; preloadedUrls: Set; queueLength: number; readyUrlsVersion: number; preloadAsset: (url: string, priority?: number) => void; clearQueue: () => void; getCachedBlobUrl: (url: string) => Promise; isUrlPreloaded: (url: string) => Promise; getReadyBlobUrl: (url: string) => string | null; getReadyBlob: (url: string) => Blob | null; currentPhase: PreloadPhase; phaseProgress: number; isCurrentPageReady: boolean; areTransitionsReady: boolean; } const generateJobId = (): string => { return `preload-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; }; 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 } = options; const [isPreloading, setIsPreloading] = useState(false); const [preloadedUrls] = useState(() => new Set()); const [queueLength, setQueueLength] = useState(0); const [readyUrlsVersion, setReadyUrlsVersion] = useState(0); const [currentPhase, setCurrentPhase] = useState('idle'); const [phaseProgress, setPhaseProgress] = useState(0); const queueRef = useRef([]); const isProcessingRef = useRef(false); const lastPreloadedPageRef = useRef(null); const lastPreloadedLinksCountRef = useRef(0); const { networkInfo } = useNetworkAware(); useEffect(() => { const unsubscribe = downloadEventBus.on( OFFLINE_CONFIG.events.blobUrlReady as Parameters< typeof downloadEventBus.on >[0], (data: BlobUrlReadyEvent) => { preloadedUrls.add(data.storageKey); setReadyUrlsVersion((v) => v + 1); }, ); return unsubscribe; }, [preloadedUrls]); useEffect(() => { return () => { downloadManager.clearBlobUrls(); }; }, []); 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); while (queueRef.current.length > 0) { const item = queueRef.current.shift(); if (!item) break; setQueueLength(queueRef.current.length); const storageKey = item.storageKey || extractStoragePath(item.url); if (preloadedUrls.has(storageKey)) { continue; } downloadManager .addJob({ assetId: item.id, projectId: '', url: item.url, filename: item.url.split('/').pop() || 'asset', variantType: 'original', assetType: mapAssetType(item.assetType), priority: item.priority, storageKey, persist: false, }) .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]); const addToQueue = useCallback( (item: PreloadQueueItem) => { const storageKey = item.storageKey || extractStoragePath(item.url); if ( preloadedUrls.has(storageKey) || queueRef.current.some( (q) => (q.storageKey || extractStoragePath(q.url)) === storageKey, ) ) { return; } 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], ); const preloadAsset = useCallback( (url: string, priority = 100) => { addToQueue({ id: generateJobId(), url, priority, assetType: 'other', pageId: currentPageId || '', }); }, [addToQueue, currentPageId], ); const clearQueue = useCallback(() => { queueRef.current = []; setQueueLength(0); }, []); 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; } }, [], ); const isUrlPreloaded = useCallback( async (url: string): Promise => { const storageKey = extractStoragePath(url); if (preloadedUrls.has(storageKey)) return true; return StorageManager.hasAsset(storageKey); }, [preloadedUrls], ); const getReadyBlobUrl = useCallback((url: string): string | null => { return downloadManager.getReadyBlobUrl(url); }, []); const getReadyBlob = useCallback((url: string): Blob | null => { return downloadManager.getReadyBlob(url); }, []); // Initialize ready blob URLs from cache for current page's assets useEffect(() => { if (!currentPageId) return; const currentPage = pages.find((p) => p.id === currentPageId); if (!currentPage) return; const initializeFromCache = async () => { const bgUrls = [ currentPage.background_image_url, currentPage.background_video_url, currentPage.background_audio_url, ].filter(Boolean) as string[]; 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; 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 } }); const allUrls = [...bgUrls, ...elementAssetUrls]; for (const storagePath of allUrls) { const storageKey = extractStoragePath(storagePath); if (downloadManager.getReadyBlobUrl(storageKey)) continue; const fullUrl = resolveAssetPlaybackUrl(storagePath); const hasAsset = await StorageManager.hasAsset(storageKey); if (hasAsset) { await downloadManager.addJob({ assetId: `init-${storageKey}`, projectId: '', url: fullUrl, filename: storageKey.split('/').pop() || 'asset', variantType: 'original', assetType: 'other', storageKey, persist: false, }); } } }; initializeFromCache(); }, [currentPageId, pages, elements]); // React to page changes - preload current page assets and transitions useEffect(() => { if (!enabled || !currentPageId || !networkInfo.isOnline) { return; } const currentLinksCount = pageLinks.length; const samePageAndData = lastPreloadedPageRef.current === currentPageId && lastPreloadedLinksCountRef.current === currentLinksCount; if (samePageAndData) { return; } lastPreloadedPageRef.current = currentPageId; lastPreloadedLinksCountRef.current = currentLinksCount; const currentPage = pages.find((p) => p.id === currentPageId); // Extract current page element assets directly const currentPageElements = elements.filter( (el) => el.pageId === currentPageId, ); const elementAssets = extractElementAssets( currentPageElements, currentPageId, ); // Collect storage paths for presigned URL batch request const storagePaths: string[] = []; 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); } elementAssets.forEach((asset) => { if (isRelativeStoragePath(asset.storageKey)) { storagePaths.push(asset.storageKey); } }); // Add outgoing transition video URLs (forward and reverse) // Reverse videos are preloaded here so they're cached when user navigates and clicks back const outgoingTransitions = pageLinks.filter( (link) => link.from_pageId === currentPageId && (link.transition?.video_url || link.transition?.reverse_video_url), ); outgoingTransitions.forEach((link) => { const forwardVideoUrl = link.transition?.video_url; const reverseVideoUrl = link.transition?.reverse_video_url; if (forwardVideoUrl && isRelativeStoragePath(forwardVideoUrl)) { storagePaths.push(forwardVideoUrl); } if (reverseVideoUrl && isRelativeStoragePath(reverseVideoUrl)) { storagePaths.push(reverseVideoUrl); } }); const resolveUrl = ( storageKey: string, presignedUrls: Record, ): string => { if (presignedUrls[storageKey]) { return presignedUrls[storageKey]; } return resolveAssetPlaybackUrl(storageKey); }; const addAssetsToQueue = async ( presignedUrls: Record = {}, ) => { const createDownloadJob = ( id: string, storageKey: string, priority: number, assetType: 'image' | 'video' | 'audio' | 'transition' | 'other', ): Promise | null => { const resolvedUrl = resolveUrl(storageKey, presignedUrls); if (!resolvedUrl) return null; const normalizedKey = isRelativeStoragePath(storageKey) ? storageKey : extractStoragePath(resolvedUrl); // Check if already downloaded (blob exists) or download in progress if (preloadedUrls.has(normalizedKey)) { // Verify the blob actually exists - if not, allow re-download const existingBlob = downloadManager.getReadyBlob(normalizedKey); if (existingBlob) { return null; // Already cached, skip } // Key was in Set but blob doesn't exist - remove and re-download preloadedUrls.delete(normalizedKey); } // Mark as in-progress to prevent duplicate downloads preloadedUrls.add(normalizedKey); const enableStreaming = PRELOAD_CONFIG.streaming.enabled && (assetType === 'video' || assetType === 'audio' || assetType === 'transition'); return downloadManager .addJob({ assetId: id, projectId: '', url: resolvedUrl, filename: resolvedUrl.split('/').pop() || 'asset', variantType: 'original', assetType: mapAssetType(assetType), priority, storageKey: normalizedKey, persist: false, streamingMode: enableStreaming ? { enabled: true, minBufferBytes: PRELOAD_CONFIG.streaming.minBufferBytes, } : undefined, }) .then(() => { if (isPresignedUrl(resolvedUrl)) { markPresignedUrlsVerified(); } }) .catch((err) => { // Download failed - remove from Set so it can be retried preloadedUrls.delete(normalizedKey); logger.error('[PRELOAD] Download failed', { url: resolvedUrl.slice(-50), error: err?.message, }); }); }; // Phase 1: Current Page Assets (blocking for images only) setCurrentPhase('phase1_current_page'); setPhaseProgress(0); const phase1BlockingJobs: Promise[] = []; let phase1Total = 0; let phase1Completed = 0; if (currentPage?.background_image_url) { phase1Total++; const job = createDownloadJob( `bg-img-${currentPageId}`, currentPage.background_image_url, PRELOAD_CONFIG.priority.currentPage + 200, 'image', ); if (job) { phase1BlockingJobs.push( job.then(() => { phase1Completed++; setPhaseProgress( phase1Total > 0 ? (phase1Completed / phase1Total) * 100 : 100, ); }), ); } } // Current page element images (blocking) const currentPageImageAssets = elementAssets.filter( (asset) => asset.assetType === 'image', ); currentPageImageAssets.forEach((asset) => { phase1Total++; const job = createDownloadJob( `elem-img-${asset.storageKey}`, asset.storageKey, PRELOAD_CONFIG.priority.currentPage + PRELOAD_CONFIG.priority.assetType.image, 'image', ); if (job) { phase1BlockingJobs.push( job.then(() => { phase1Completed++; setPhaseProgress( phase1Total > 0 ? (phase1Completed / phase1Total) * 100 : 100, ); }), ); } }); // Non-blocking: videos and audio start downloading but don't wait if (currentPage?.background_video_url) { createDownloadJob( `bg-vid-${currentPageId}`, currentPage.background_video_url, PRELOAD_CONFIG.priority.currentPage + 150, 'video', ); } if (currentPage?.background_audio_url) { createDownloadJob( `bg-aud-${currentPageId}`, currentPage.background_audio_url, PRELOAD_CONFIG.priority.currentPage + 100, 'audio', ); } if (phase1BlockingJobs.length > 0) { await Promise.all(phase1BlockingJobs); } // Phase 2: Outgoing Transition Videos (preload for instant playback) setCurrentPhase('phase2_transitions'); setPhaseProgress(0); const phase2Jobs: Promise[] = []; let phase2Total = 0; let phase2Completed = 0; // Preload outgoing transition videos (forward + reverse) outgoingTransitions.forEach((link) => { const forwardVideoUrl = link.transition?.video_url; const reverseVideoUrl = link.transition?.reverse_video_url; // Preload forward transition video if (forwardVideoUrl) { phase2Total++; const job = createDownloadJob( `trans-fwd-${link.from_pageId}-${link.to_pageId}`, forwardVideoUrl, PRELOAD_CONFIG.priority.currentPage + PRELOAD_CONFIG.priority.assetType.transition, 'transition', ); if (job) { phase2Jobs.push( job.then(() => { phase2Completed++; setPhaseProgress( phase2Total > 0 ? (phase2Completed / phase2Total) * 100 : 100, ); }), ); } } // Preload reverse transition video (for potential back navigation from target) if (reverseVideoUrl) { phase2Total++; const job = createDownloadJob( `trans-rev-${link.from_pageId}-${link.to_pageId}`, reverseVideoUrl, PRELOAD_CONFIG.priority.currentPage + PRELOAD_CONFIG.priority.assetType.transition - 10, 'transition', ); if (job) { phase2Jobs.push( job.then(() => { phase2Completed++; setPhaseProgress( phase2Total > 0 ? (phase2Completed / phase2Total) * 100 : 100, ); }), ); } } }); if (phase2Jobs.length > 0) { await Promise.all(phase2Jobs); } setCurrentPhase('complete'); setPhaseProgress(100); }; if (storagePaths.length > 0) { queuePresignedUrls(storagePaths) .then(async () => { await addAssetsToQueue(); }) .catch(async () => { await addAssetsToQueue(); }); } else { addAssetsToQueue(); } }, [ enabled, currentPageId, networkInfo.isOnline, elements, pages, pageLinks, ]); const isCurrentPageReady = currentPhase === 'phase2_transitions' || currentPhase === 'complete'; const areTransitionsReady = currentPhase === 'complete'; return { isPreloading, preloadedUrls, queueLength, readyUrlsVersion, preloadAsset, clearQueue, getCachedBlobUrl, isUrlPreloaded, getReadyBlobUrl, getReadyBlob, currentPhase, phaseProgress, isCurrentPageReady, areTransitionsReady, }; }