fixed preloading issue
This commit is contained in:
parent
8f1d3699a1
commit
7d251319f2
@ -29,7 +29,10 @@ import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
|
|||||||
import { usePageDataLoader } from '../hooks/usePageDataLoader';
|
import { usePageDataLoader } from '../hooks/usePageDataLoader';
|
||||||
import { useProjectAssets } from '../hooks/useProjectAssets';
|
import { useProjectAssets } from '../hooks/useProjectAssets';
|
||||||
import { usePageNavigation } from '../hooks/usePageNavigation';
|
import { usePageNavigation } from '../hooks/usePageNavigation';
|
||||||
import { extractPageLinksAndElements } from '../lib/extractPageLinks';
|
import {
|
||||||
|
extractPageLinksOnly,
|
||||||
|
extractElementsForPages,
|
||||||
|
} from '../lib/extractPageLinks';
|
||||||
import { usePageSwitch } from '../hooks/usePageSwitch';
|
import { usePageSwitch } from '../hooks/usePageSwitch';
|
||||||
import { useTransitionPlayback } from '../hooks/useTransitionPlayback';
|
import { useTransitionPlayback } from '../hooks/useTransitionPlayback';
|
||||||
import { useBackgroundTransition } from '../hooks/useBackgroundTransition';
|
import { useBackgroundTransition } from '../hooks/useBackgroundTransition';
|
||||||
@ -99,24 +102,47 @@ export default function RuntimePresentation({
|
|||||||
|
|
||||||
// Note: Initial page selection is handled by usePageNavigation hook via defaultPageId
|
// Note: Initial page selection is handled by usePageNavigation hook via defaultPageId
|
||||||
|
|
||||||
// Extract page links and preload elements from ui_schema_json
|
// Phase 1: Extract pageLinks from ALL pages (needed for navigation graph)
|
||||||
// This enables the neighbor graph to find connected pages for preloading
|
// This is lightweight - only extracts navigation structure, not asset URLs
|
||||||
const { pageLinks, preloadElements } = useMemo(() => {
|
const pageLinks = useMemo(() => {
|
||||||
const result = extractPageLinksAndElements(pages);
|
const links = extractPageLinksOnly(pages);
|
||||||
if (result.pageLinks.length > 0 || result.preloadElements.length > 0) {
|
if (links.length > 0) {
|
||||||
logger.info('[PRELOAD] Extracted page links and elements', {
|
logger.info('[PRELOAD] Extracted page links', {
|
||||||
pageLinksCount: result.pageLinks.length,
|
count: links.length,
|
||||||
preloadElementsCount: result.preloadElements.length,
|
links: links.map((link) => ({
|
||||||
pageLinks: result.pageLinks.map((link) => ({
|
|
||||||
from: link.from_pageId?.slice(-8),
|
from: link.from_pageId?.slice(-8),
|
||||||
to: link.to_pageId?.slice(-8),
|
to: link.to_pageId?.slice(-8),
|
||||||
hasTransition: !!link.transition?.video_url,
|
hasTransition: !!link.transition?.video_url,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return result;
|
return links;
|
||||||
}, [pages]);
|
}, [pages]);
|
||||||
|
|
||||||
|
// Phase 2: Extract elements only for current + neighbor pages (progressive)
|
||||||
|
// This avoids parsing ui_schema_json for all pages upfront
|
||||||
|
const preloadElements = useMemo(() => {
|
||||||
|
if (!selectedPageId || pages.length === 0) return [];
|
||||||
|
|
||||||
|
// Build simple neighbor set from pageLinks
|
||||||
|
const neighborIds = new Set<string>();
|
||||||
|
neighborIds.add(selectedPageId); // Current page
|
||||||
|
pageLinks.forEach((link) => {
|
||||||
|
if (link.from_pageId === selectedPageId && link.to_pageId) {
|
||||||
|
neighborIds.add(link.to_pageId); // Direct neighbors
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extract elements only for current + neighbors
|
||||||
|
const elements = extractElementsForPages(pages, Array.from(neighborIds));
|
||||||
|
logger.info('[PRELOAD] Extracted elements for pages', {
|
||||||
|
currentPage: selectedPageId.slice(-8),
|
||||||
|
pageCount: neighborIds.size,
|
||||||
|
elementCount: elements.length,
|
||||||
|
});
|
||||||
|
return elements;
|
||||||
|
}, [pages, pageLinks, selectedPageId]);
|
||||||
|
|
||||||
// Initialize preload orchestrator with transformed data
|
// Initialize preload orchestrator with transformed data
|
||||||
const preloadOrchestrator = usePreloadOrchestrator({
|
const preloadOrchestrator = usePreloadOrchestrator({
|
||||||
pages,
|
pages,
|
||||||
|
|||||||
@ -20,10 +20,10 @@ export const PRELOAD_CONFIG = {
|
|||||||
currentPage: 1000,
|
currentPage: 1000,
|
||||||
neighborBase: 500,
|
neighborBase: 500,
|
||||||
assetType: {
|
assetType: {
|
||||||
transition: 150, // Highest - needed immediately on navigation click
|
image: 100, // Backgrounds load first
|
||||||
image: 100, // Backgrounds load during transition playback
|
|
||||||
audio: 50,
|
audio: 50,
|
||||||
video: 30,
|
video: 30,
|
||||||
|
// Note: transitions are cached on first playback, not preloaded
|
||||||
} as Record<string, number>,
|
} as Record<string, number>,
|
||||||
variant: {
|
variant: {
|
||||||
thumbnail: 50,
|
thumbnail: 50,
|
||||||
@ -65,6 +65,16 @@ export const PRELOAD_CONFIG = {
|
|||||||
slowFrameThreshold: 1.3, // Multiplier of target frame time
|
slowFrameThreshold: 1.3, // Multiplier of target frame time
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Partial preload settings (online mode only)
|
||||||
|
// Download only first N bytes of videos/audio for faster Phase 1 completion
|
||||||
|
// Playback uses presigned URL directly (browser handles remaining buffering)
|
||||||
|
// Note: Transitions are cached on first playback, not preloaded
|
||||||
|
partialPreload: {
|
||||||
|
enabled: true,
|
||||||
|
videoMaxBytes: 5 * 1024 * 1024, // 5MB (~5 seconds of video)
|
||||||
|
audioMaxBytes: 512 * 1024, // 512KB (~5 seconds of audio)
|
||||||
|
},
|
||||||
|
|
||||||
// Asset URL field names in element content_json (camelCase)
|
// Asset URL field names in element content_json (camelCase)
|
||||||
assetFields: {
|
assetFields: {
|
||||||
// All asset URL fields for preloading extraction
|
// All asset URL fields for preloading extraction
|
||||||
|
|||||||
@ -73,12 +73,15 @@ function extractAssetsFromContent(
|
|||||||
|
|
||||||
for (const [key, value] of Object.entries(obj)) {
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
if (typeof value === 'string' && value && urlFields.includes(key)) {
|
if (typeof value === 'string' && value && urlFields.includes(key)) {
|
||||||
// Classify asset type - transition videos get highest priority
|
// Classify asset type based on field name
|
||||||
|
// Skip transition fields - transitions are cached on first playback
|
||||||
const lowerKey = key.toLowerCase();
|
const lowerKey = key.toLowerCase();
|
||||||
let assetType: 'transition' | 'video' | 'audio' | 'image';
|
|
||||||
if (lowerKey.includes('transition')) {
|
if (lowerKey.includes('transition')) {
|
||||||
assetType = 'transition';
|
continue; // Skip transitions
|
||||||
} else if (lowerKey.includes('video')) {
|
}
|
||||||
|
|
||||||
|
let assetType: 'video' | 'audio' | 'image';
|
||||||
|
if (lowerKey.includes('video')) {
|
||||||
assetType = 'video';
|
assetType = 'video';
|
||||||
} else if (lowerKey.includes('audio')) {
|
} else if (lowerKey.includes('audio')) {
|
||||||
assetType = 'audio';
|
assetType = 'audio';
|
||||||
@ -183,6 +186,41 @@ export function useNeighborGraph(
|
|||||||
const seenUrls = new Set<string>();
|
const seenUrls = new Set<string>();
|
||||||
|
|
||||||
pageIds.forEach((pageId) => {
|
pageIds.forEach((pageId) => {
|
||||||
|
// Find the page to get its background assets
|
||||||
|
const page = pages.find((p) => p.id === pageId);
|
||||||
|
if (page) {
|
||||||
|
// Add page background image (highest priority for page display)
|
||||||
|
if (page.background_image_url && !seenUrls.has(page.background_image_url)) {
|
||||||
|
seenUrls.add(page.background_image_url);
|
||||||
|
assets.push({
|
||||||
|
url: page.background_image_url,
|
||||||
|
pageId,
|
||||||
|
assetType: 'image',
|
||||||
|
priority: 0, // Will be calculated later
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Add page background video
|
||||||
|
if (page.background_video_url && !seenUrls.has(page.background_video_url)) {
|
||||||
|
seenUrls.add(page.background_video_url);
|
||||||
|
assets.push({
|
||||||
|
url: page.background_video_url,
|
||||||
|
pageId,
|
||||||
|
assetType: 'video',
|
||||||
|
priority: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Add page background audio
|
||||||
|
if (page.background_audio_url && !seenUrls.has(page.background_audio_url)) {
|
||||||
|
seenUrls.add(page.background_audio_url);
|
||||||
|
assets.push({
|
||||||
|
url: page.background_audio_url,
|
||||||
|
pageId,
|
||||||
|
assetType: 'audio',
|
||||||
|
priority: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Get elements for this page
|
// Get elements for this page
|
||||||
const pageElements = elements.filter((el) => el.pageId === pageId);
|
const pageElements = elements.filter((el) => el.pageId === pageId);
|
||||||
|
|
||||||
@ -207,22 +245,12 @@ export function useNeighborGraph(
|
|||||||
link.is_active !== false && pageIds.includes(link.from_pageId || ''),
|
link.is_active !== false && pageIds.includes(link.from_pageId || ''),
|
||||||
);
|
);
|
||||||
|
|
||||||
matchingLinks.forEach((link) => {
|
// Note: Transition videos are NOT extracted for preloading.
|
||||||
const videoUrl = link.transition?.video_url;
|
// They are cached on first playback via useTransitionPlayback.cacheBlob()
|
||||||
if (videoUrl && !seenUrls.has(videoUrl)) {
|
|
||||||
seenUrls.add(videoUrl);
|
|
||||||
assets.push({
|
|
||||||
url: videoUrl,
|
|
||||||
pageId: link.from_pageId || '',
|
|
||||||
assetType: 'transition',
|
|
||||||
priority: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return assets;
|
return assets;
|
||||||
};
|
};
|
||||||
}, [elements, pageLinks]);
|
}, [pages, elements, pageLinks]);
|
||||||
|
|
||||||
// Get prioritized assets for preloading
|
// Get prioritized assets for preloading
|
||||||
const getPrioritizedAssets = useMemo(() => {
|
const getPrioritizedAssets = useMemo(() => {
|
||||||
|
|||||||
@ -17,8 +17,9 @@ import {
|
|||||||
resolveAssetPlaybackUrl,
|
resolveAssetPlaybackUrl,
|
||||||
markPresignedUrlFailed,
|
markPresignedUrlFailed,
|
||||||
isRelativeStoragePath,
|
isRelativeStoragePath,
|
||||||
|
isPresignedUrl,
|
||||||
|
buildProxyUrl,
|
||||||
} from '../lib/assetUrl';
|
} from '../lib/assetUrl';
|
||||||
import { baseURLApi } from '../config';
|
|
||||||
import { logger } from '../lib/logger';
|
import { logger } from '../lib/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -95,21 +96,6 @@ export interface UsePageSwitchResult {
|
|||||||
clearPreviousBackground: () => void;
|
clearPreviousBackground: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if URL is a presigned S3 URL
|
|
||||||
*/
|
|
||||||
const isPresignedUrl = (url: string): boolean => {
|
|
||||||
return url.includes('X-Amz-Signature=') || url.includes('x-amz-signature=');
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build proxy URL from storage key for fallback
|
|
||||||
*/
|
|
||||||
const buildProxyUrl = (storageKey: string): string => {
|
|
||||||
const normalizedPath = storageKey.replace(/^\/+/, '');
|
|
||||||
return `${baseURLApi}/file/download?privateUrl=${encodeURIComponent(normalizedPath)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load and decode an image with presigned URL fallback.
|
* Load and decode an image with presigned URL fallback.
|
||||||
* Returns the URL that successfully loaded.
|
* Returns the URL that successfully loaded.
|
||||||
|
|||||||
@ -20,8 +20,9 @@ import {
|
|||||||
isRelativeStoragePath,
|
isRelativeStoragePath,
|
||||||
markPresignedUrlFailed,
|
markPresignedUrlFailed,
|
||||||
markPresignedUrlsVerified,
|
markPresignedUrlsVerified,
|
||||||
|
isPresignedUrl,
|
||||||
|
buildProxyUrl,
|
||||||
} from '../lib/assetUrl';
|
} from '../lib/assetUrl';
|
||||||
import { baseURLApi } from '../config';
|
|
||||||
import { logger } from '../lib/logger';
|
import { logger } from '../lib/logger';
|
||||||
import type { BlobUrlReadyEvent } from '../types/offline';
|
import type { BlobUrlReadyEvent } from '../types/offline';
|
||||||
import type {
|
import type {
|
||||||
@ -30,21 +31,6 @@ import type {
|
|||||||
PreloadElement,
|
PreloadElement,
|
||||||
} from '../types/preload';
|
} from '../types/preload';
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if URL is a presigned S3 URL
|
|
||||||
*/
|
|
||||||
const isPresignedUrl = (url: string): boolean => {
|
|
||||||
return url.includes('X-Amz-Signature=') || url.includes('x-amz-signature=');
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build proxy URL from storage key
|
|
||||||
*/
|
|
||||||
const buildProxyUrl = (storageKey: string): string => {
|
|
||||||
const normalizedPath = storageKey.replace(/^\/+/, '');
|
|
||||||
return `${baseURLApi}/file/download?privateUrl=${encodeURIComponent(normalizedPath)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface UsePreloadOrchestratorOptions {
|
interface UsePreloadOrchestratorOptions {
|
||||||
pages: PreloadPage[];
|
pages: PreloadPage[];
|
||||||
pageLinks: PreloadPageLink[];
|
pageLinks: PreloadPageLink[];
|
||||||
@ -541,6 +527,28 @@ export function usePreloadOrchestrator(
|
|||||||
const addAssetsToQueue = async (
|
const addAssetsToQueue = async (
|
||||||
presignedUrls: Record<string, string> = {},
|
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
|
||||||
|
const getMaxBytesForAsset = (
|
||||||
|
assetType: 'image' | 'video' | 'audio' | 'transition' | 'other',
|
||||||
|
isNeighborPage: boolean,
|
||||||
|
): number | undefined => {
|
||||||
|
if (!PRELOAD_CONFIG.partialPreload.enabled) return undefined;
|
||||||
|
|
||||||
|
// 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
|
// Helper to create download job
|
||||||
const createDownloadJob = (
|
const createDownloadJob = (
|
||||||
id: string,
|
id: string,
|
||||||
@ -549,6 +557,11 @@ export function usePreloadOrchestrator(
|
|||||||
assetType: 'image' | 'video' | 'audio' | 'transition' | 'other',
|
assetType: 'image' | 'video' | 'audio' | 'transition' | 'other',
|
||||||
pageId: string,
|
pageId: string,
|
||||||
): Promise<void> | null => {
|
): Promise<void> | null => {
|
||||||
|
// Skip transitions - they're cached on first playback via useTransitionPlayback
|
||||||
|
if (assetType === 'transition') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const resolvedUrl = resolveUrl(storageKey, presignedUrls);
|
const resolvedUrl = resolveUrl(storageKey, presignedUrls);
|
||||||
if (!resolvedUrl) return null;
|
if (!resolvedUrl) return null;
|
||||||
|
|
||||||
@ -561,6 +574,12 @@ export function usePreloadOrchestrator(
|
|||||||
|
|
||||||
preloadedUrls.add(normalizedKey);
|
preloadedUrls.add(normalizedKey);
|
||||||
|
|
||||||
|
// Determine if partial preload applies (neighbor pages only, media files only)
|
||||||
|
const isNeighborPage = pageId !== currentPageId;
|
||||||
|
const maxBytes = getMaxBytesForAsset(assetType, isNeighborPage);
|
||||||
|
// For partial downloads, don't create blob URL - playback uses presigned URL
|
||||||
|
const createBlobUrl = maxBytes === undefined;
|
||||||
|
|
||||||
return downloadManager
|
return downloadManager
|
||||||
.addJob({
|
.addJob({
|
||||||
assetId: id,
|
assetId: id,
|
||||||
@ -571,8 +590,9 @@ export function usePreloadOrchestrator(
|
|||||||
assetType: mapAssetType(assetType),
|
assetType: mapAssetType(assetType),
|
||||||
priority,
|
priority,
|
||||||
storageKey: normalizedKey,
|
storageKey: normalizedKey,
|
||||||
createBlobUrl: true,
|
createBlobUrl,
|
||||||
persist: false,
|
persist: false,
|
||||||
|
maxBytes,
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (isPresignedUrl(resolvedUrl)) {
|
if (isPresignedUrl(resolvedUrl)) {
|
||||||
@ -598,8 +618,9 @@ export function usePreloadOrchestrator(
|
|||||||
assetType: mapAssetType(assetType),
|
assetType: mapAssetType(assetType),
|
||||||
priority,
|
priority,
|
||||||
storageKey: normalizedKey,
|
storageKey: normalizedKey,
|
||||||
createBlobUrl: true,
|
createBlobUrl,
|
||||||
persist: false,
|
persist: false,
|
||||||
|
maxBytes, // Preserve partial preload behavior for retry
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore retry failures
|
// Ignore retry failures
|
||||||
@ -609,13 +630,14 @@ export function usePreloadOrchestrator(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// PHASE 1: Load current page assets and WAIT
|
// 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 assets');
|
logger.info('[PRELOAD] Phase 1: Loading current page backgrounds');
|
||||||
|
|
||||||
const currentPageJobs: Promise<void>[] = [];
|
const currentPageImageJobs: Promise<void>[] = [];
|
||||||
|
|
||||||
// Current page background assets
|
// Current page IMAGE background - WAIT for this (essential for visual)
|
||||||
if (currentPage?.background_image_url) {
|
if (currentPage?.background_image_url) {
|
||||||
const job = createDownloadJob(
|
const job = createDownloadJob(
|
||||||
`bg-img-${currentPageId}`,
|
`bg-img-${currentPageId}`,
|
||||||
@ -624,58 +646,70 @@ export function usePreloadOrchestrator(
|
|||||||
'image',
|
'image',
|
||||||
currentPageId,
|
currentPageId,
|
||||||
);
|
);
|
||||||
if (job) currentPageJobs.push(job);
|
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) {
|
if (currentPage?.background_video_url) {
|
||||||
const job = createDownloadJob(
|
createDownloadJob(
|
||||||
`bg-vid-${currentPageId}`,
|
`bg-vid-${currentPageId}`,
|
||||||
currentPage.background_video_url,
|
currentPage.background_video_url,
|
||||||
PRELOAD_CONFIG.priority.currentPage + 150,
|
PRELOAD_CONFIG.priority.currentPage + 150,
|
||||||
'video',
|
'video',
|
||||||
currentPageId,
|
currentPageId,
|
||||||
);
|
);
|
||||||
if (job) currentPageJobs.push(job);
|
// Not pushed to awaited jobs - video streams on its own
|
||||||
}
|
}
|
||||||
if (currentPage?.background_audio_url) {
|
if (currentPage?.background_audio_url) {
|
||||||
const job = createDownloadJob(
|
createDownloadJob(
|
||||||
`bg-aud-${currentPageId}`,
|
`bg-aud-${currentPageId}`,
|
||||||
currentPage.background_audio_url,
|
currentPage.background_audio_url,
|
||||||
PRELOAD_CONFIG.priority.currentPage + 100,
|
PRELOAD_CONFIG.priority.currentPage + 100,
|
||||||
'audio',
|
'audio',
|
||||||
currentPageId,
|
currentPageId,
|
||||||
);
|
);
|
||||||
if (job) currentPageJobs.push(job);
|
// Not pushed to awaited jobs - audio streams on its own
|
||||||
}
|
}
|
||||||
|
|
||||||
// Current page element assets (from neighbor graph with pageId === currentPageId)
|
// 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)
|
||||||
|
// - Transition videos (partial preload - 3MB)
|
||||||
|
// - Neighbor page backgrounds (partial preload for video/audio)
|
||||||
|
// - Neighbor page element assets (partial preload for video/audio)
|
||||||
|
// ============================================
|
||||||
|
logger.info('[PRELOAD] Phase 2: Preloading transitions and neighbors');
|
||||||
|
|
||||||
|
// Current page element assets (moved from Phase 1 for faster startup)
|
||||||
const currentPageAssets = assets.filter(
|
const currentPageAssets = assets.filter(
|
||||||
(asset) => asset.pageId === currentPageId,
|
(asset) => asset.pageId === currentPageId,
|
||||||
);
|
);
|
||||||
currentPageAssets.forEach((asset) => {
|
currentPageAssets.forEach((asset) => {
|
||||||
const job = createDownloadJob(
|
createDownloadJob(
|
||||||
generateJobId(),
|
generateJobId(),
|
||||||
asset.url,
|
asset.url,
|
||||||
asset.priority,
|
asset.priority,
|
||||||
asset.assetType,
|
asset.assetType,
|
||||||
asset.pageId,
|
asset.pageId,
|
||||||
);
|
);
|
||||||
if (job) currentPageJobs.push(job);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait for all current page assets to complete
|
|
||||||
if (currentPageJobs.length > 0) {
|
|
||||||
logger.info('[PRELOAD] Waiting for current page assets', {
|
|
||||||
count: currentPageJobs.length,
|
|
||||||
});
|
|
||||||
await Promise.all(currentPageJobs);
|
|
||||||
logger.info('[PRELOAD] Current page assets ready');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// PHASE 2: Preload neighbor assets (don't wait)
|
|
||||||
// ============================================
|
|
||||||
logger.info('[PRELOAD] Phase 2: Preloading neighbor assets');
|
|
||||||
|
|
||||||
// Neighbor page element assets
|
// Neighbor page element assets
|
||||||
const neighborAssets = assets.filter(
|
const neighborAssets = assets.filter(
|
||||||
(asset) => asset.pageId !== currentPageId,
|
(asset) => asset.pageId !== currentPageId,
|
||||||
|
|||||||
@ -8,8 +8,15 @@ import {
|
|||||||
} from 'react';
|
} from 'react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { logger } from '../lib/logger';
|
import { logger } from '../lib/logger';
|
||||||
import { markPresignedUrlFailed, isRelativeStoragePath } from '../lib/assetUrl';
|
import {
|
||||||
import { baseURLApi } from '../config';
|
markPresignedUrlFailed,
|
||||||
|
isRelativeStoragePath,
|
||||||
|
resolveAssetPlaybackUrl,
|
||||||
|
isPresignedUrl,
|
||||||
|
buildProxyUrl,
|
||||||
|
extractStoragePath,
|
||||||
|
} from '../lib/assetUrl';
|
||||||
|
import { downloadManager } from '../lib/offline/DownloadManager';
|
||||||
import { useReversePlayback } from './useReversePlayback';
|
import { useReversePlayback } from './useReversePlayback';
|
||||||
|
|
||||||
export type ReverseMode = 'none' | 'reverse' | 'separate';
|
export type ReverseMode = 'none' | 'reverse' | 'separate';
|
||||||
@ -107,43 +114,6 @@ function buildBlobRequestUrl(url: string): string {
|
|||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a URL is a presigned S3 URL (contains X-Amz-Signature)
|
|
||||||
*/
|
|
||||||
function isPresignedUrl(url: string): boolean {
|
|
||||||
return url.includes('X-Amz-Signature=') || url.includes('x-amz-signature=');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a presigned URL back to proxy URL
|
|
||||||
* Extracts the storage key from the S3 path and builds a proxy URL
|
|
||||||
*/
|
|
||||||
function getProxyUrlFallback(
|
|
||||||
presignedUrl: string,
|
|
||||||
originalStorageKey?: string,
|
|
||||||
): string | null {
|
|
||||||
// If we have the original storage key, use it directly
|
|
||||||
if (originalStorageKey && isRelativeStoragePath(originalStorageKey)) {
|
|
||||||
const normalizedPath = originalStorageKey.replace(/^\/+/, '');
|
|
||||||
return `${baseURLApi}/file/download?privateUrl=${encodeURIComponent(normalizedPath)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to extract path from presigned URL
|
|
||||||
try {
|
|
||||||
const url = new URL(presignedUrl);
|
|
||||||
// S3 path format: /bucket-prefix/assets/project-id/filename.ext
|
|
||||||
const pathParts = url.pathname.split('/').filter(Boolean);
|
|
||||||
// Skip the bucket prefix, take the rest as the storage path
|
|
||||||
if (pathParts.length >= 2) {
|
|
||||||
const storagePath = pathParts.slice(1).join('/');
|
|
||||||
return `${baseURLApi}/file/download?privateUrl=${encodeURIComponent(storagePath)}`;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// URL parsing failed
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForImages(urls: string[], timeoutMs = 2000): Promise<void> {
|
async function waitForImages(urls: string[], timeoutMs = 2000): Promise<void> {
|
||||||
if (urls.length === 0) return;
|
if (urls.length === 0) return;
|
||||||
|
|
||||||
@ -524,27 +494,74 @@ export function useTransitionPlayback(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Fetch video as blob (network fallback)
|
// 6. Fetch video as blob with presigned URL support
|
||||||
|
// Follows usePageSwitch.loadImageWithFallback pattern:
|
||||||
|
// Try presigned URL first (SW can intercept for caching), fallback to proxy if it fails
|
||||||
logger.info('Fetching video as blob for seeking support', {
|
logger.info('Fetching video as blob for seeking support', {
|
||||||
reverseMode: currentTransition.reverseMode,
|
reverseMode: currentTransition.reverseMode,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Re-resolve URL to get presigned URL if now available
|
||||||
|
// (may have been cached since transition started)
|
||||||
|
const freshUrl = storageKey
|
||||||
|
? resolveAssetPlaybackUrl(storageKey)
|
||||||
|
: sourceUrl;
|
||||||
|
|
||||||
const token =
|
const token =
|
||||||
typeof window !== 'undefined'
|
typeof window !== 'undefined'
|
||||||
? localStorage.getItem('token') || ''
|
? localStorage.getItem('token') || ''
|
||||||
: '';
|
: '';
|
||||||
const requestUrl = buildBlobRequestUrl(sourceUrl);
|
|
||||||
const response = await axios.get(requestUrl, {
|
|
||||||
responseType: 'blob',
|
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const blobUrl = URL.createObjectURL(response.data);
|
// Helper: Fetch video and return blob URL, caching for next time
|
||||||
lastLoadedBlobUrlRef.current = blobUrl;
|
const fetchVideoAsBlob = async (url: string): Promise<string> => {
|
||||||
lastLoadedSourceUrlRef.current = sourceUrl;
|
logger.info('Fetching video from URL', {
|
||||||
logger.info('Created blob URL for video', {
|
url: url.slice(0, 80),
|
||||||
blobUrl: blobUrl.substring(0, 50),
|
isPresigned: isPresignedUrl(url),
|
||||||
});
|
});
|
||||||
return blobUrl;
|
|
||||||
|
const response = await axios.get(url, {
|
||||||
|
responseType: 'blob',
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const blob = response.data as Blob;
|
||||||
|
|
||||||
|
// Cache for next time using existing DownloadManager pattern
|
||||||
|
if (storageKey) {
|
||||||
|
const normalizedKey = extractStoragePath(storageKey);
|
||||||
|
const blobUrl = await downloadManager.cacheBlob(normalizedKey, blob, {
|
||||||
|
assetType: 'transition',
|
||||||
|
});
|
||||||
|
lastLoadedBlobUrlRef.current = blobUrl;
|
||||||
|
lastLoadedSourceUrlRef.current = sourceUrl;
|
||||||
|
return blobUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: create blob URL without caching
|
||||||
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
|
lastLoadedBlobUrlRef.current = blobUrl;
|
||||||
|
lastLoadedSourceUrlRef.current = sourceUrl;
|
||||||
|
logger.info('Created blob URL for video (no caching)', {
|
||||||
|
blobUrl: blobUrl.substring(0, 50),
|
||||||
|
});
|
||||||
|
return blobUrl;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try fetching with potentially presigned URL (SW can intercept if S3)
|
||||||
|
return await fetchVideoAsBlob(freshUrl);
|
||||||
|
} catch (error) {
|
||||||
|
// If presigned URL failed and we have storage key, retry with proxy
|
||||||
|
if (storageKey && isPresignedUrl(freshUrl)) {
|
||||||
|
logger.info('Presigned URL failed, retrying with proxy', {
|
||||||
|
storageKey: storageKey.slice(-40),
|
||||||
|
});
|
||||||
|
markPresignedUrlFailed(storageKey);
|
||||||
|
const proxyUrl = buildProxyUrl(storageKey);
|
||||||
|
return await fetchVideoAsBlob(proxyUrl);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadAndPlay = async () => {
|
const loadAndPlay = async () => {
|
||||||
@ -720,12 +737,10 @@ export function useTransitionPlayback(
|
|||||||
markPresignedUrlFailed(originalVideoUrl);
|
markPresignedUrlFailed(originalVideoUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get proxy fallback URL
|
// Get proxy fallback URL using storage key
|
||||||
const fallbackUrl = getProxyUrlFallback(
|
const videoStorageKey = currentTransition.videoUrl;
|
||||||
currentUrl,
|
if (videoStorageKey && isRelativeStoragePath(videoStorageKey)) {
|
||||||
currentTransition.videoUrl,
|
const fallbackUrl = buildProxyUrl(videoStorageKey);
|
||||||
);
|
|
||||||
if (fallbackUrl) {
|
|
||||||
didTryFallbackRef.current = true;
|
didTryFallbackRef.current = true;
|
||||||
video.pause();
|
video.pause();
|
||||||
video.src = fallbackUrl;
|
video.src = fallbackUrl;
|
||||||
|
|||||||
@ -16,6 +16,21 @@ const isPresignedS3Url = (url: string): boolean => {
|
|||||||
return url.includes('X-Amz-Signature=') || url.includes('x-amz-signature=');
|
return url.includes('X-Amz-Signature=') || url.includes('x-amz-signature=');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if URL is a presigned S3 URL (exported version for reuse across hooks)
|
||||||
|
*/
|
||||||
|
export const isPresignedUrl = (url: string): boolean => {
|
||||||
|
return url.includes('X-Amz-Signature=') || url.includes('x-amz-signature=');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build proxy URL from storage key for fallback when presigned URL fails
|
||||||
|
*/
|
||||||
|
export const buildProxyUrl = (storageKey: string): string => {
|
||||||
|
const normalizedPath = storageKey.replace(/^\/+/, '');
|
||||||
|
return `${baseURLApi}/file/download?privateUrl=${encodeURIComponent(normalizedPath)}`;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Setup Axios interceptor to detect presigned URL failures.
|
* Setup Axios interceptor to detect presigned URL failures.
|
||||||
* Called once during app initialization.
|
* Called once during app initialization.
|
||||||
|
|||||||
@ -180,3 +180,130 @@ export function extractPageLinksAndElements(
|
|||||||
|
|
||||||
return { pageLinks, preloadElements };
|
return { pageLinks, preloadElements };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract only page links from pages (lightweight - no asset extraction).
|
||||||
|
* Used for building navigation graph without loading all asset URLs.
|
||||||
|
*
|
||||||
|
* This is more efficient than extractPageLinksAndElements when you only need
|
||||||
|
* the navigation structure (e.g., for determining neighbors).
|
||||||
|
*
|
||||||
|
* @param pages - Array of pages with ui_schema_json
|
||||||
|
* @param allPages - Optional: all pages for slug-to-id resolution. If not provided, uses `pages`.
|
||||||
|
* @returns Array of page links for navigation graph
|
||||||
|
*/
|
||||||
|
export function extractPageLinksOnly(
|
||||||
|
pages: PageWithSchema[],
|
||||||
|
allPages?: PageWithSchema[],
|
||||||
|
): PreloadPageLink[] {
|
||||||
|
const pagesForLookup = allPages || pages;
|
||||||
|
const pageLinks: PreloadPageLink[] = [];
|
||||||
|
|
||||||
|
// Build slug-to-id map for resolving targetPageSlug
|
||||||
|
const slugToIdMap = new Map<string, string>();
|
||||||
|
pagesForLookup.forEach((page) => {
|
||||||
|
if (page.slug) {
|
||||||
|
slugToIdMap.set(page.slug, page.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
pages.forEach((page) => {
|
||||||
|
const uiSchema = parseUiSchema(page.ui_schema_json);
|
||||||
|
if (!uiSchema) return;
|
||||||
|
|
||||||
|
const pageElements = Array.isArray(uiSchema.elements)
|
||||||
|
? (uiSchema.elements as Record<string, unknown>[])
|
||||||
|
: [];
|
||||||
|
|
||||||
|
pageElements.forEach((el) => {
|
||||||
|
// Build synthetic page link for navigation elements
|
||||||
|
const targetSlug =
|
||||||
|
el.targetPageSlug && typeof el.targetPageSlug === 'string'
|
||||||
|
? el.targetPageSlug
|
||||||
|
: '';
|
||||||
|
const legacyTargetId =
|
||||||
|
el.targetPageId && typeof el.targetPageId === 'string'
|
||||||
|
? el.targetPageId
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// Resolve slug to page ID (prefer slug, fall back to legacy ID)
|
||||||
|
let resolvedTargetPageId = '';
|
||||||
|
if (targetSlug) {
|
||||||
|
resolvedTargetPageId = slugToIdMap.get(targetSlug) || '';
|
||||||
|
} else if (legacyTargetId) {
|
||||||
|
// Legacy: targetPageId might be a slug or an ID
|
||||||
|
resolvedTargetPageId =
|
||||||
|
slugToIdMap.get(legacyTargetId) || legacyTargetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvedTargetPageId && resolvedTargetPageId !== page.id) {
|
||||||
|
pageLinks.push({
|
||||||
|
id: `synthetic-${page.id}-${el.id || Math.random().toString(36).slice(2)}`,
|
||||||
|
from_pageId: page.id,
|
||||||
|
to_pageId: resolvedTargetPageId,
|
||||||
|
is_active: true,
|
||||||
|
transition:
|
||||||
|
el.transitionVideoUrl && typeof el.transitionVideoUrl === 'string'
|
||||||
|
? {
|
||||||
|
id: `transition-${el.id || Math.random().toString(36).slice(2)}`,
|
||||||
|
video_url: el.transitionVideoUrl,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return pageLinks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract preload elements only for specified pages (on-demand).
|
||||||
|
* Used for progressive element loading - only parses ui_schema_json
|
||||||
|
* for pages that are actually needed (current + neighbors).
|
||||||
|
*
|
||||||
|
* @param pages - Array of pages with ui_schema_json
|
||||||
|
* @param pageIds - Array of page IDs to extract elements for
|
||||||
|
* @returns Array of preload elements for the specified pages
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Extract elements only for current page and its neighbors
|
||||||
|
* const neighborIds = [currentPageId, ...getNeighborIds(currentPageId)];
|
||||||
|
* const elements = extractElementsForPages(pages, neighborIds);
|
||||||
|
*/
|
||||||
|
export function extractElementsForPages(
|
||||||
|
pages: PageWithSchema[],
|
||||||
|
pageIds: string[],
|
||||||
|
): PreloadElement[] {
|
||||||
|
const preloadElements: PreloadElement[] = [];
|
||||||
|
const pageIdSet = new Set(pageIds);
|
||||||
|
|
||||||
|
pages.forEach((page) => {
|
||||||
|
// Skip pages not in requested set
|
||||||
|
if (!pageIdSet.has(page.id)) return;
|
||||||
|
|
||||||
|
const uiSchema = parseUiSchema(page.ui_schema_json);
|
||||||
|
if (!uiSchema) return;
|
||||||
|
|
||||||
|
const pageElements = Array.isArray(uiSchema.elements)
|
||||||
|
? (uiSchema.elements as Record<string, unknown>[])
|
||||||
|
: [];
|
||||||
|
|
||||||
|
pageElements.forEach((el) => {
|
||||||
|
// Build preload element with asset URLs
|
||||||
|
const contentObj = extractAssetFields(el);
|
||||||
|
if (Object.keys(contentObj).length > 0) {
|
||||||
|
preloadElements.push({
|
||||||
|
id:
|
||||||
|
String(el.id || '') ||
|
||||||
|
`element-${page.id}-${Math.random().toString(36).slice(2)}`,
|
||||||
|
pageId: page.id,
|
||||||
|
element_type: String(el.type || ''),
|
||||||
|
content_json: JSON.stringify(contentObj),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return preloadElements;
|
||||||
|
}
|
||||||
|
|||||||
@ -37,6 +37,8 @@ interface DownloadJob {
|
|||||||
storageKey: string; // Canonical storage key for consistent caching
|
storageKey: string; // Canonical storage key for consistent caching
|
||||||
createBlobUrl?: boolean; // Create decoded blob URL after download
|
createBlobUrl?: boolean; // Create decoded blob URL after download
|
||||||
persist?: boolean; // Persist to IndexedDB for resume (default: true)
|
persist?: boolean; // Persist to IndexedDB for resume (default: true)
|
||||||
|
maxBytes?: number; // Partial download limit (undefined = full download)
|
||||||
|
isPartial?: boolean; // Whether this was a partial download (for tracking)
|
||||||
abortController?: AbortController;
|
abortController?: AbortController;
|
||||||
resolve?: () => void;
|
resolve?: () => void;
|
||||||
reject?: (error: Error) => void;
|
reject?: (error: Error) => void;
|
||||||
@ -51,6 +53,10 @@ class DownloadManagerClass {
|
|||||||
// Blob URL cache for instant lookup (storageKey → blobUrl)
|
// Blob URL cache for instant lookup (storageKey → blobUrl)
|
||||||
private readyBlobUrls: Map<string, string> = new Map();
|
private readyBlobUrls: Map<string, string> = new Map();
|
||||||
|
|
||||||
|
// Track partial downloads completed in this session (not persisted)
|
||||||
|
// Prevents re-downloading same partial content on repeated page visits
|
||||||
|
private partialDownloadsReady: Set<string> = new Set();
|
||||||
|
|
||||||
private config = {
|
private config = {
|
||||||
maxConcurrent: PRELOAD_CONFIG.maxConcurrentDownloads,
|
maxConcurrent: PRELOAD_CONFIG.maxConcurrentDownloads,
|
||||||
chunkSize: PRELOAD_CONFIG.videoChunkSize,
|
chunkSize: PRELOAD_CONFIG.videoChunkSize,
|
||||||
@ -73,19 +79,31 @@ class DownloadManagerClass {
|
|||||||
storageKey?: string; // Optional, will extract if not provided
|
storageKey?: string; // Optional, will extract if not provided
|
||||||
createBlobUrl?: boolean; // Create blob URL after download
|
createBlobUrl?: boolean; // Create blob URL after download
|
||||||
persist?: boolean; // Persist to IndexedDB for resume (default: true)
|
persist?: boolean; // Persist to IndexedDB for resume (default: true)
|
||||||
|
maxBytes?: number; // Download limit in bytes (for partial preload)
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const storageKey = params.storageKey || extractStoragePath(params.url);
|
const storageKey = params.storageKey || extractStoragePath(params.url);
|
||||||
|
const isPartialDownload = params.maxBytes !== undefined;
|
||||||
|
|
||||||
// Check if already downloaded using canonical key
|
// For partial downloads, check session cache (not persisted to storage)
|
||||||
const hasAsset = await StorageManager.hasAsset(storageKey);
|
if (isPartialDownload && this.partialDownloadsReady.has(storageKey)) {
|
||||||
if (hasAsset) {
|
logger.info('[DownloadManager] Partial download already ready (session)', {
|
||||||
// Already cached - create blob URL if requested
|
storageKey: storageKey.slice(-50),
|
||||||
if (params.createBlobUrl && !this.readyBlobUrls.has(storageKey)) {
|
});
|
||||||
await this.createBlobUrlFromCache(storageKey);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if already downloaded using canonical key (full downloads only)
|
||||||
|
if (!isPartialDownload) {
|
||||||
|
const hasAsset = await StorageManager.hasAsset(storageKey);
|
||||||
|
if (hasAsset) {
|
||||||
|
// Already cached - create blob URL if requested
|
||||||
|
if (params.createBlobUrl && !this.readyBlobUrls.has(storageKey)) {
|
||||||
|
await this.createBlobUrlFromCache(storageKey);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check if already in queue (use storageKey for deduplication)
|
// Check if already in queue (use storageKey for deduplication)
|
||||||
if (
|
if (
|
||||||
this.queue.some((j) => j.storageKey === storageKey) ||
|
this.queue.some((j) => j.storageKey === storageKey) ||
|
||||||
@ -97,6 +115,16 @@ class DownloadManagerClass {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
// For partial downloads, don't persist and don't create blob URL
|
||||||
|
// (video will play from presigned URL, browser handles buffering)
|
||||||
|
const isPartialDownload = params.maxBytes !== undefined;
|
||||||
|
const shouldPersist = isPartialDownload
|
||||||
|
? false
|
||||||
|
: (params.persist ?? true);
|
||||||
|
const shouldCreateBlobUrl = isPartialDownload
|
||||||
|
? false
|
||||||
|
: (params.createBlobUrl ?? false);
|
||||||
|
|
||||||
const job: DownloadJob = {
|
const job: DownloadJob = {
|
||||||
id: `dl-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
id: `dl-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||||
assetId: params.assetId,
|
assetId: params.assetId,
|
||||||
@ -115,8 +143,10 @@ class DownloadManagerClass {
|
|||||||
retryCount: 0,
|
retryCount: 0,
|
||||||
addedAt: Date.now(),
|
addedAt: Date.now(),
|
||||||
storageKey,
|
storageKey,
|
||||||
createBlobUrl: params.createBlobUrl ?? false,
|
createBlobUrl: shouldCreateBlobUrl,
|
||||||
persist: params.persist ?? true,
|
persist: shouldPersist,
|
||||||
|
maxBytes: params.maxBytes,
|
||||||
|
isPartial: isPartialDownload,
|
||||||
resolve,
|
resolve,
|
||||||
reject,
|
reject,
|
||||||
};
|
};
|
||||||
@ -213,23 +243,39 @@ class DownloadManagerClass {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Build request headers - use Range header for partial downloads
|
||||||
|
const headers: HeadersInit = {};
|
||||||
|
if (job.maxBytes) {
|
||||||
|
headers['Range'] = `bytes=0-${job.maxBytes - 1}`;
|
||||||
|
logger.info('[DownloadManager] Partial download requested', {
|
||||||
|
url: job.url.slice(-50),
|
||||||
|
maxBytes: job.maxBytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(job.url, {
|
const response = await fetch(job.url, {
|
||||||
signal: job.abortController.signal,
|
signal: job.abortController.signal,
|
||||||
|
headers,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
// Accept both 200 OK and 206 Partial Content
|
||||||
|
if (!response.ok && response.status !== 206) {
|
||||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const contentLength = response.headers.get('content-length');
|
const contentLength = response.headers.get('content-length');
|
||||||
job.totalBytes = contentLength ? parseInt(contentLength, 10) : 0;
|
job.totalBytes = contentLength ? parseInt(contentLength, 10) : 0;
|
||||||
|
|
||||||
|
// For partial downloads, track if we reached the limit
|
||||||
|
const isPartialResponse = response.status === 206 || job.maxBytes;
|
||||||
|
|
||||||
let blob: Blob;
|
let blob: Blob;
|
||||||
|
|
||||||
if (response.body) {
|
if (response.body) {
|
||||||
// Stream with progress tracking
|
// Stream with progress tracking
|
||||||
const reader = response.body.getReader();
|
const reader = response.body.getReader();
|
||||||
const chunks: BlobPart[] = [];
|
const chunks: BlobPart[] = [];
|
||||||
|
let reachedLimit = false;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
@ -237,6 +283,23 @@ class DownloadManagerClass {
|
|||||||
|
|
||||||
chunks.push(value);
|
chunks.push(value);
|
||||||
job.bytesLoaded += value.length;
|
job.bytesLoaded += value.length;
|
||||||
|
|
||||||
|
// Check if we've reached the maxBytes limit
|
||||||
|
if (job.maxBytes && job.bytesLoaded >= job.maxBytes) {
|
||||||
|
reachedLimit = true;
|
||||||
|
logger.info('[DownloadManager] Reached partial download limit', {
|
||||||
|
bytesLoaded: job.bytesLoaded,
|
||||||
|
maxBytes: job.maxBytes,
|
||||||
|
});
|
||||||
|
// Cancel the remaining download gracefully
|
||||||
|
try {
|
||||||
|
await reader.cancel();
|
||||||
|
} catch {
|
||||||
|
// Ignore cancel errors - stream may already be closed
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
job.progress =
|
job.progress =
|
||||||
job.totalBytes > 0
|
job.totalBytes > 0
|
||||||
? Math.round((job.bytesLoaded / job.totalBytes) * 100)
|
? Math.round((job.bytesLoaded / job.totalBytes) * 100)
|
||||||
@ -249,17 +312,25 @@ class DownloadManagerClass {
|
|||||||
totalBytes: job.totalBytes,
|
totalBytes: job.totalBytes,
|
||||||
});
|
});
|
||||||
|
|
||||||
await OfflineDbManager.updateQueueProgress(
|
// Only update queue progress if persisting
|
||||||
job.id,
|
if (job.persist !== false) {
|
||||||
job.bytesLoaded,
|
await OfflineDbManager.updateQueueProgress(
|
||||||
job.totalBytes,
|
job.id,
|
||||||
);
|
job.bytesLoaded,
|
||||||
|
job.totalBytes,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
blob = new Blob(chunks, {
|
blob = new Blob(chunks, {
|
||||||
type:
|
type:
|
||||||
response.headers.get('content-type') || 'application/octet-stream',
|
response.headers.get('content-type') || 'application/octet-stream',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// For partial downloads, mark as complete even if we didn't get everything
|
||||||
|
if (reachedLimit || isPartialResponse) {
|
||||||
|
job.progress = 100; // Consider partial download as "complete"
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// No streaming, get blob directly
|
// No streaming, get blob directly
|
||||||
blob = await response.blob();
|
blob = await response.blob();
|
||||||
@ -268,18 +339,34 @@ class DownloadManagerClass {
|
|||||||
job.progress = 100;
|
job.progress = 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store the asset using canonical storage key
|
// For partial downloads, don't store to cache (not useful for offline)
|
||||||
await StorageManager.storeAsset(job.storageKey, blob, {
|
// Full downloads are stored for offline access
|
||||||
id: job.assetId,
|
if (!job.isPartial) {
|
||||||
projectId: job.projectId,
|
// Store the asset using canonical storage key
|
||||||
filename: job.filename,
|
await StorageManager.storeAsset(job.storageKey, blob, {
|
||||||
variantType: job.variantType,
|
id: job.assetId,
|
||||||
assetType: job.assetType,
|
projectId: job.projectId,
|
||||||
});
|
filename: job.filename,
|
||||||
|
variantType: job.variantType,
|
||||||
|
assetType: job.assetType,
|
||||||
|
});
|
||||||
|
|
||||||
// Create blob URL if requested
|
// Create blob URL if requested
|
||||||
if (job.createBlobUrl) {
|
if (job.createBlobUrl) {
|
||||||
await this.createBlobUrlFromCache(job.storageKey);
|
await this.createBlobUrlFromCache(job.storageKey);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Mark partial download as ready in session cache
|
||||||
|
this.partialDownloadsReady.add(job.storageKey);
|
||||||
|
|
||||||
|
// Register with Service Worker for full-file caching during playback
|
||||||
|
// When the browser fetches the full media, SW will cache it using the storage key
|
||||||
|
this.registerUrlForCaching(job.url, job.storageKey);
|
||||||
|
|
||||||
|
logger.info('[DownloadManager] Partial download complete', {
|
||||||
|
storageKey: job.storageKey.slice(-50),
|
||||||
|
bytesLoaded: job.bytesLoaded,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark as completed
|
// Mark as completed
|
||||||
@ -502,6 +589,45 @@ class DownloadManagerClass {
|
|||||||
return this.readyBlobUrls.get(storageKey) || null;
|
return this.readyBlobUrls.get(storageKey) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache an externally fetched blob and register blob URL for instant lookup.
|
||||||
|
* Use this when fetching via XHR (e.g., transition playback) to enable caching.
|
||||||
|
*/
|
||||||
|
async cacheBlob(
|
||||||
|
storageKey: string,
|
||||||
|
blob: Blob,
|
||||||
|
metadata: {
|
||||||
|
assetType: AssetType;
|
||||||
|
projectId?: string;
|
||||||
|
},
|
||||||
|
): Promise<string> {
|
||||||
|
// Store in Cache API / IndexedDB via existing StorageManager
|
||||||
|
await StorageManager.storeAsset(storageKey, blob, {
|
||||||
|
id: `cached-${storageKey}`,
|
||||||
|
projectId: metadata.projectId || '',
|
||||||
|
filename: storageKey.split('/').pop() || 'asset',
|
||||||
|
variantType: 'original',
|
||||||
|
assetType: metadata.assetType,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create blob URL and register for instant O(1) lookup
|
||||||
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
|
this.readyBlobUrls.set(storageKey, blobUrl);
|
||||||
|
|
||||||
|
// Emit event for consumers (existing pattern)
|
||||||
|
downloadEventBus.emitBlobUrlReady({
|
||||||
|
storageKey,
|
||||||
|
blobUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('[DownloadManager] Cached external blob', {
|
||||||
|
storageKey: storageKey.slice(-50),
|
||||||
|
size: blob.size,
|
||||||
|
});
|
||||||
|
|
||||||
|
return blobUrl;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create blob URL from cached asset and store in readyBlobUrls map
|
* Create blob URL from cached asset and store in readyBlobUrls map
|
||||||
*/
|
*/
|
||||||
@ -543,11 +669,36 @@ class DownloadManagerClass {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear blob URLs (call on unmount to prevent memory leaks)
|
* Register a presigned URL → storage key mapping with the Service Worker.
|
||||||
|
* This enables the SW to cache the full response when the browser fetches the media
|
||||||
|
* during playback, using the canonical storage key instead of the expiring presigned URL.
|
||||||
|
*/
|
||||||
|
private registerUrlForCaching(presignedUrl: string, storageKey: string): void {
|
||||||
|
if (navigator.serviceWorker?.controller) {
|
||||||
|
navigator.serviceWorker.controller.postMessage({
|
||||||
|
type: 'REGISTER_CACHE_URL',
|
||||||
|
payload: { presignedUrl, storageKey },
|
||||||
|
});
|
||||||
|
logger.info('[DownloadManager] Registered URL for SW caching', {
|
||||||
|
storageKey: storageKey.slice(-40),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear blob URLs and partial downloads cache (call on unmount to prevent memory leaks)
|
||||||
*/
|
*/
|
||||||
clearBlobUrls(): void {
|
clearBlobUrls(): void {
|
||||||
this.readyBlobUrls.forEach((blobUrl) => URL.revokeObjectURL(blobUrl));
|
this.readyBlobUrls.forEach((blobUrl) => URL.revokeObjectURL(blobUrl));
|
||||||
this.readyBlobUrls.clear();
|
this.readyBlobUrls.clear();
|
||||||
|
this.partialDownloadsReady.clear();
|
||||||
|
|
||||||
|
// Clear SW URL mappings (optional, SW has its own cleanup interval)
|
||||||
|
if (navigator.serviceWorker?.controller) {
|
||||||
|
navigator.serviceWorker.controller.postMessage({
|
||||||
|
type: 'CLEAR_URL_MAPPINGS',
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -93,6 +93,77 @@ const isVideoRequest = (request: Request): boolean => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Check if request is audio
|
||||||
|
const isAudioRequest = (request: Request): boolean => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
return ['.mp3', '.wav', '.ogg', '.m4a', '.aac'].some((ext) =>
|
||||||
|
url.pathname.toLowerCase().endsWith(ext),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract storage path from various URL formats.
|
||||||
|
* Handles:
|
||||||
|
* - Presigned S3 URLs: https://s3.../bucket/assets/project/file.mp4?X-Amz-Signature=...
|
||||||
|
* - Backend proxy URLs: http://localhost:8080/api/file/download?privateUrl=assets%2F...
|
||||||
|
* - Relative paths: assets/project/file.mp4
|
||||||
|
*/
|
||||||
|
const extractStoragePathFromUrl = (url: string): string | null => {
|
||||||
|
try {
|
||||||
|
// Backend proxy URL format
|
||||||
|
if (url.includes('/file/download?privateUrl=')) {
|
||||||
|
const match = url.match(/privateUrl=([^&]+)/);
|
||||||
|
if (match) {
|
||||||
|
return decodeURIComponent(match[1]).replace(/^\/+/, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Presigned S3 URL
|
||||||
|
if (url.includes('X-Amz-Signature=') || url.includes('x-amz-signature=')) {
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
const pathParts = urlObj.pathname.split('/').filter(Boolean);
|
||||||
|
const assetsIndex = pathParts.findIndex((part) => part === 'assets');
|
||||||
|
if (assetsIndex !== -1) {
|
||||||
|
return pathParts.slice(assetsIndex).join('/');
|
||||||
|
}
|
||||||
|
if (pathParts.length > 1) {
|
||||||
|
return pathParts.slice(1).join('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already a relative storage path (starts with 'assets/')
|
||||||
|
if (url.startsWith('assets/')) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full S3 URL (non-presigned)
|
||||||
|
const s3Match = url.match(
|
||||||
|
/^https?:\/\/[^/]+\.s3\.[^/]+\.amazonaws\.com\/[^/]+\/(assets\/.+)$/,
|
||||||
|
);
|
||||||
|
if (s3Match) {
|
||||||
|
return s3Match[1].split('?')[0]; // Remove query params
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Storage path → storage key mapping (for caching browser video/audio requests)
|
||||||
|
// When main thread does partial preload, it registers {storagePath → storageKey}
|
||||||
|
// SW extracts storage path from any URL format and uses it for cache lookups
|
||||||
|
const storagePathToKeyMap = new Map<string, string>();
|
||||||
|
|
||||||
|
// Clean up old mappings every hour (session cleanup)
|
||||||
|
setInterval(
|
||||||
|
() => {
|
||||||
|
storagePathToKeyMap.clear();
|
||||||
|
console.log('[SW] Cleared storage path mappings');
|
||||||
|
},
|
||||||
|
60 * 60 * 1000,
|
||||||
|
);
|
||||||
|
|
||||||
// Initialize Serwist
|
// Initialize Serwist
|
||||||
const serwist = new Serwist({
|
const serwist = new Serwist({
|
||||||
precacheEntries: self.__SW_MANIFEST,
|
precacheEntries: self.__SW_MANIFEST,
|
||||||
@ -117,6 +188,19 @@ const serwist = new Serwist({
|
|||||||
cacheName: OFFLINE_CONFIG.cacheNames.assets,
|
cacheName: OFFLINE_CONFIG.cacheNames.assets,
|
||||||
plugins: [
|
plugins: [
|
||||||
{
|
{
|
||||||
|
// Transform URL to storage key for consistent caching
|
||||||
|
// Matches preload behavior (DownloadManager uses storage keys)
|
||||||
|
cacheKeyWillBeUsed: async ({ request, mode }) => {
|
||||||
|
const storagePath = extractStoragePathFromUrl(request.url);
|
||||||
|
if (storagePath) {
|
||||||
|
console.log(
|
||||||
|
`[SW] Using storagePath for static asset ${mode}:`,
|
||||||
|
storagePath.slice(-40),
|
||||||
|
);
|
||||||
|
return new Request(storagePath);
|
||||||
|
}
|
||||||
|
return request;
|
||||||
|
},
|
||||||
cacheWillUpdate: async ({ response }) => {
|
cacheWillUpdate: async ({ response }) => {
|
||||||
if (response && response.status === 200) {
|
if (response && response.status === 200) {
|
||||||
return response;
|
return response;
|
||||||
@ -127,13 +211,39 @@ const serwist = new Serwist({
|
|||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
// Videos - Cache First with Range Request support
|
// Videos - Cache First with Range Request support and storage key mapping
|
||||||
{
|
{
|
||||||
matcher: ({ request }) => isVideoRequest(request),
|
matcher: ({ request }) => isVideoRequest(request),
|
||||||
handler: new CacheFirst({
|
handler: new CacheFirst({
|
||||||
cacheName: OFFLINE_CONFIG.cacheNames.assets,
|
cacheName: OFFLINE_CONFIG.cacheNames.assets,
|
||||||
plugins: [
|
plugins: [
|
||||||
{
|
{
|
||||||
|
// Transform URL to storage key for BOTH cache reads and writes
|
||||||
|
// Per Serwist: mode='read' for lookups, mode='write' for storing
|
||||||
|
cacheKeyWillBeUsed: async ({ request, mode }) => {
|
||||||
|
// Extract storage path from any URL format
|
||||||
|
const storagePath = extractStoragePathFromUrl(request.url);
|
||||||
|
if (storagePath) {
|
||||||
|
// Check if we have a mapping for this storage path
|
||||||
|
const storageKey = storagePathToKeyMap.get(storagePath);
|
||||||
|
if (storageKey) {
|
||||||
|
console.log(
|
||||||
|
`[SW] Using storageKey for video ${mode}:`,
|
||||||
|
storageKey.slice(-40),
|
||||||
|
);
|
||||||
|
return new Request(storageKey);
|
||||||
|
}
|
||||||
|
// No explicit mapping, use extracted storage path as cache key
|
||||||
|
console.log(
|
||||||
|
`[SW] Using storagePath for video ${mode}:`,
|
||||||
|
storagePath.slice(-40),
|
||||||
|
);
|
||||||
|
return new Request(storagePath);
|
||||||
|
}
|
||||||
|
// No storage path extracted - use original URL (fallback)
|
||||||
|
return request;
|
||||||
|
},
|
||||||
|
|
||||||
// Handle range requests for video seeking
|
// Handle range requests for video seeking
|
||||||
cachedResponseWillBeUsed: async ({ cachedResponse, request }) => {
|
cachedResponseWillBeUsed: async ({ cachedResponse, request }) => {
|
||||||
if (!cachedResponse) return null;
|
if (!cachedResponse) return null;
|
||||||
@ -178,6 +288,77 @@ const serwist = new Serwist({
|
|||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
// Audio - Cache First with Range Request support and storage key mapping
|
||||||
|
{
|
||||||
|
matcher: ({ request }) => isAudioRequest(request),
|
||||||
|
handler: new CacheFirst({
|
||||||
|
cacheName: OFFLINE_CONFIG.cacheNames.assets,
|
||||||
|
plugins: [
|
||||||
|
{
|
||||||
|
// Transform URL to storage key for BOTH cache reads and writes
|
||||||
|
cacheKeyWillBeUsed: async ({ request, mode }) => {
|
||||||
|
// Extract storage path from any URL format
|
||||||
|
const storagePath = extractStoragePathFromUrl(request.url);
|
||||||
|
if (storagePath) {
|
||||||
|
const storageKey = storagePathToKeyMap.get(storagePath);
|
||||||
|
if (storageKey) {
|
||||||
|
console.log(
|
||||||
|
`[SW] Using storageKey for audio ${mode}:`,
|
||||||
|
storageKey.slice(-40),
|
||||||
|
);
|
||||||
|
return new Request(storageKey);
|
||||||
|
}
|
||||||
|
// No explicit mapping, use extracted storage path as cache key
|
||||||
|
console.log(
|
||||||
|
`[SW] Using storagePath for audio ${mode}:`,
|
||||||
|
storagePath.slice(-40),
|
||||||
|
);
|
||||||
|
return new Request(storagePath);
|
||||||
|
}
|
||||||
|
return request;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Handle range requests for audio seeking
|
||||||
|
cachedResponseWillBeUsed: async ({ cachedResponse, request }) => {
|
||||||
|
if (!cachedResponse) return null;
|
||||||
|
|
||||||
|
const rangeHeader = request.headers.get('range');
|
||||||
|
if (!rangeHeader) return cachedResponse;
|
||||||
|
|
||||||
|
const match = rangeHeader.match(/bytes=(\d+)-(\d*)/);
|
||||||
|
if (!match) return cachedResponse;
|
||||||
|
|
||||||
|
const start = parseInt(match[1], 10);
|
||||||
|
const end = match[2] ? parseInt(match[2], 10) : undefined;
|
||||||
|
|
||||||
|
const blob = await cachedResponse.blob();
|
||||||
|
const slicedBlob =
|
||||||
|
end !== undefined
|
||||||
|
? blob.slice(start, end + 1)
|
||||||
|
: blob.slice(start);
|
||||||
|
|
||||||
|
return new Response(slicedBlob, {
|
||||||
|
status: 206,
|
||||||
|
statusText: 'Partial Content',
|
||||||
|
headers: {
|
||||||
|
'Content-Type':
|
||||||
|
cachedResponse.headers.get('Content-Type') || 'audio/mpeg',
|
||||||
|
'Content-Length': String(slicedBlob.size),
|
||||||
|
'Content-Range': `bytes ${start}-${end !== undefined ? end : blob.size - 1}/${blob.size}`,
|
||||||
|
'Accept-Ranges': 'bytes',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
cacheWillUpdate: async ({ response }) => {
|
||||||
|
if (response && response.status === 200) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
// API requests - Network First
|
// API requests - Network First
|
||||||
{
|
{
|
||||||
matcher: ({ url }) => url.pathname.startsWith('/api/'),
|
matcher: ({ url }) => url.pathname.startsWith('/api/'),
|
||||||
@ -186,15 +367,30 @@ const serwist = new Serwist({
|
|||||||
networkTimeoutSeconds: 10,
|
networkTimeoutSeconds: 10,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
// Dynamic assets (audio, other cacheable) - Cache First
|
// Dynamic assets (other cacheable, excluding video and audio) - Cache First
|
||||||
// Uses 'assets' cache to match preloading (usePreloadOrchestrator stores here)
|
// Uses 'assets' cache to match preloading (usePreloadOrchestrator stores here)
|
||||||
{
|
{
|
||||||
matcher: ({ request }) =>
|
matcher: ({ request }) =>
|
||||||
isCacheableRequest(request) && !isVideoRequest(request),
|
isCacheableRequest(request) &&
|
||||||
|
!isVideoRequest(request) &&
|
||||||
|
!isAudioRequest(request),
|
||||||
handler: new CacheFirst({
|
handler: new CacheFirst({
|
||||||
cacheName: OFFLINE_CONFIG.cacheNames.assets,
|
cacheName: OFFLINE_CONFIG.cacheNames.assets,
|
||||||
plugins: [
|
plugins: [
|
||||||
{
|
{
|
||||||
|
// Transform URL to storage key for consistent caching
|
||||||
|
// Matches preload behavior (DownloadManager uses storage keys)
|
||||||
|
cacheKeyWillBeUsed: async ({ request, mode }) => {
|
||||||
|
const storagePath = extractStoragePathFromUrl(request.url);
|
||||||
|
if (storagePath) {
|
||||||
|
console.log(
|
||||||
|
`[SW] Using storagePath for dynamic asset ${mode}:`,
|
||||||
|
storagePath.slice(-40),
|
||||||
|
);
|
||||||
|
return new Request(storagePath);
|
||||||
|
}
|
||||||
|
return request;
|
||||||
|
},
|
||||||
cacheWillUpdate: async ({ response }) => {
|
cacheWillUpdate: async ({ response }) => {
|
||||||
if (response && response.status === 200) {
|
if (response && response.status === 200) {
|
||||||
return response;
|
return response;
|
||||||
@ -215,6 +411,29 @@ self.addEventListener('message', (event) => {
|
|||||||
const { type, payload } = event.data || {};
|
const { type, payload } = event.data || {};
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
|
case 'REGISTER_CACHE_URL':
|
||||||
|
// Register storage path → storage key mapping for media caching
|
||||||
|
// Main thread sends this after partial preload; when browser fetches
|
||||||
|
// the full media during playback, we cache using the storage key
|
||||||
|
if (payload?.storageKey) {
|
||||||
|
// Extract storage path from presigned URL (or use storageKey directly)
|
||||||
|
const storagePath = payload.presignedUrl
|
||||||
|
? extractStoragePathFromUrl(payload.presignedUrl) || payload.storageKey
|
||||||
|
: payload.storageKey;
|
||||||
|
storagePathToKeyMap.set(storagePath, payload.storageKey);
|
||||||
|
console.log('[SW] Registered storage path for caching', {
|
||||||
|
storagePath: storagePath.slice(-50),
|
||||||
|
storageKey: payload.storageKey.slice(-50),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'CLEAR_URL_MAPPINGS':
|
||||||
|
// Clear storage path mappings (called on cleanup/unmount)
|
||||||
|
storagePathToKeyMap.clear();
|
||||||
|
console.log('[SW] Storage path mappings cleared');
|
||||||
|
break;
|
||||||
|
|
||||||
case 'CACHE_ASSETS':
|
case 'CACHE_ASSETS':
|
||||||
// Cache specific assets for a project/page
|
// Cache specific assets for a project/page
|
||||||
if (Array.isArray(payload?.urls)) {
|
if (Array.isArray(payload?.urls)) {
|
||||||
@ -256,13 +475,14 @@ self.addEventListener('message', (event) => {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'CLEAR_CACHE':
|
case 'CLEAR_CACHE':
|
||||||
// Clear all dynamic caches
|
// Clear all dynamic caches and storage path mappings
|
||||||
|
storagePathToKeyMap.clear();
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
Promise.all([
|
Promise.all([
|
||||||
caches.delete(OFFLINE_CONFIG.cacheNames.dynamic),
|
caches.delete(OFFLINE_CONFIG.cacheNames.dynamic),
|
||||||
caches.delete(OFFLINE_CONFIG.cacheNames.assets),
|
caches.delete(OFFLINE_CONFIG.cacheNames.assets),
|
||||||
]).then(() => {
|
]).then(() => {
|
||||||
console.log('[SW] Caches cleared');
|
console.log('[SW] Caches and storage path mappings cleared');
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user