39948-vm/frontend/src/hooks/usePreloadOrchestrator.ts

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,
};
}