39948-vm/frontend/src/hooks/usePreloadOrchestrator.ts
2026-05-28 07:19:36 +00:00

667 lines
19 KiB
TypeScript

/**
* 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<string>;
queueLength: number;
readyUrlsVersion: number;
preloadAsset: (url: string, priority?: number) => void;
clearQueue: () => void;
getCachedBlobUrl: (url: string) => Promise<string | null>;
isUrlPreloaded: (url: string) => Promise<boolean>;
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<string>());
const [queueLength, setQueueLength] = useState(0);
const [readyUrlsVersion, setReadyUrlsVersion] = useState(0);
const [currentPhase, setCurrentPhase] = useState<PreloadPhase>('idle');
const [phaseProgress, setPhaseProgress] = useState(0);
const queueRef = useRef<PreloadQueueItem[]>([]);
const isProcessingRef = useRef(false);
const lastPreloadedPageRef = useRef<string | null>(null);
const lastPreloadedLinksCountRef = useRef<number>(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<string | null> => {
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<boolean> => {
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<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
}
});
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, string>,
): string => {
if (presignedUrls[storageKey]) {
return presignedUrls[storageKey];
}
return resolveAssetPlaybackUrl(storageKey);
};
const addAssetsToQueue = async (
presignedUrls: Record<string, string> = {},
) => {
const createDownloadJob = (
id: string,
storageKey: string,
priority: number,
assetType: 'image' | 'video' | 'audio' | 'transition' | 'other',
): Promise<void> | 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<void>[] = [];
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<void>[] = [];
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,
};
}