793 lines
26 KiB
TypeScript
793 lines
26 KiB
TypeScript
/**
|
|
* 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<string>;
|
|
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<string | null>;
|
|
isUrlPreloaded: (url: string) => Promise<boolean>;
|
|
/** 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<string>());
|
|
const [queueLength, setQueueLength] = useState(0);
|
|
// Version counter to trigger re-renders when blob URLs become ready
|
|
const [readyUrlsVersion, setReadyUrlsVersion] = useState(0);
|
|
|
|
const queueRef = useRef<PreloadQueueItem[]>([]);
|
|
const isProcessingRef = useRef(false);
|
|
const lastPreloadedPageRef = useRef<string | null>(null);
|
|
const lastPreloadedLinksCountRef = useRef<number>(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<string | null> => {
|
|
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<boolean> => {
|
|
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<string, unknown>) => {
|
|
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<string, unknown>);
|
|
}
|
|
}
|
|
};
|
|
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, string>,
|
|
): 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<string, string> = {},
|
|
) => {
|
|
// 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<void> | 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<void>[] = [];
|
|
|
|
// 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,
|
|
};
|
|
}
|