667 lines
19 KiB
TypeScript
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,
|
|
};
|
|
}
|