504 lines
16 KiB
TypeScript
504 lines
16 KiB
TypeScript
/**
|
|
* usePageSwitch Hook
|
|
*
|
|
* Unified page navigation hook that eliminates white/black flashes during page transitions.
|
|
* Uses preloaded blob URLs when available and keeps previous background visible
|
|
* until new one is ready to paint.
|
|
*
|
|
* Features:
|
|
* - Blob URL resolution from preload cache (instant display)
|
|
* - Presigned URL fallback (retries with proxy on CORS failure)
|
|
* - Previous background overlay for smooth transitions
|
|
* - Ready state management for Image onLoad coordination
|
|
*/
|
|
|
|
import { useCallback, useRef, useState } from 'react';
|
|
import {
|
|
resolveAssetPlaybackUrl,
|
|
markPresignedUrlFailed,
|
|
isRelativeStoragePath,
|
|
isPresignedUrl,
|
|
buildProxyUrl,
|
|
} from '../lib/assetUrl';
|
|
import { logger } from '../lib/logger';
|
|
import {
|
|
scheduleAfterPaint,
|
|
scheduleAfterPaintSafari,
|
|
isSafari,
|
|
} from '../lib/browserUtils';
|
|
|
|
/**
|
|
* Minimal page interface for page switching
|
|
*/
|
|
export interface SwitchablePage {
|
|
id: string;
|
|
background_image_url?: string;
|
|
background_video_url?: string;
|
|
background_audio_url?: string;
|
|
// Background video playback settings
|
|
background_video_autoplay?: boolean;
|
|
background_video_loop?: boolean;
|
|
background_video_muted?: boolean;
|
|
background_video_start_time?: number | null;
|
|
background_video_end_time?: number | null;
|
|
}
|
|
|
|
/**
|
|
* Preload cache provider interface
|
|
*/
|
|
export interface PreloadCacheProvider {
|
|
/** Instant lookup - returns decoded blob URL ready to display (O(1)) */
|
|
getReadyBlobUrl?: (url: string) => string | null;
|
|
/** Fallback: async blob URL from cache (creates new blob URL) */
|
|
getCachedBlobUrl?: (url: string) => Promise<string | null>;
|
|
preloadedUrls?: Set<string>;
|
|
}
|
|
|
|
export interface UsePageSwitchOptions {
|
|
/** Preload cache provider for blob URL resolution */
|
|
preloadCache?: PreloadCacheProvider;
|
|
}
|
|
|
|
export interface UsePageSwitchResult {
|
|
/** Currently displayed background image URL */
|
|
currentBgImageUrl: string;
|
|
/** Currently displayed background video URL */
|
|
currentBgVideoUrl: string;
|
|
/** Currently displayed background audio URL */
|
|
currentBgAudioUrl: string;
|
|
/** Previous background image URL (for overlay) */
|
|
previousBgImageUrl: string;
|
|
/** Previous background video URL (for overlay during fade) */
|
|
previousBgVideoUrl: string;
|
|
/** Whether we're in the middle of a page switch */
|
|
isSwitching: boolean;
|
|
/** Whether the new background is ready to display */
|
|
isNewBgReady: boolean;
|
|
/**
|
|
* Switch to a new page with smooth transition.
|
|
* Resolves blob URLs from cache, shows previous background until new one is ready.
|
|
*/
|
|
switchToPage: (
|
|
targetPage: SwitchablePage | null,
|
|
onSwitched?: () => void,
|
|
) => Promise<void>;
|
|
/**
|
|
* Directly set backgrounds without transition overlay.
|
|
* Use for initial page load.
|
|
*/
|
|
setBackgroundsDirectly: (
|
|
imageUrl: string,
|
|
videoUrl: string,
|
|
audioUrl: string,
|
|
) => void;
|
|
/**
|
|
* Mark the new background as ready to display.
|
|
* Call this from Image onLoad callback.
|
|
*/
|
|
markBackgroundReady: () => void;
|
|
/**
|
|
* Clear the previous background overlay.
|
|
* Call after transition completes or when ready to show new background.
|
|
*/
|
|
clearPreviousBackground: () => void;
|
|
}
|
|
|
|
/**
|
|
* Load and decode an image with presigned URL fallback.
|
|
* Returns the URL that successfully loaded.
|
|
*
|
|
* Safari-specific handling:
|
|
* Safari's img.decode() can resolve before pixels are actually ready for painting.
|
|
* For Safari, we add an extra frame wait after decode to ensure the image is
|
|
* truly ready to display, preventing black flash during page transitions.
|
|
*/
|
|
const loadImageWithFallback = (
|
|
url: string,
|
|
storageKey?: string,
|
|
): Promise<string> => {
|
|
return new Promise((resolve) => {
|
|
const img = new window.Image();
|
|
const safariMode = isSafari();
|
|
|
|
const onImageReady = (srcUrl: string) => {
|
|
if (safariMode) {
|
|
// Safari: wait an extra frame after decode to ensure pixels are ready
|
|
scheduleAfterPaintSafari(() => resolve(srcUrl));
|
|
} else {
|
|
resolve(srcUrl);
|
|
}
|
|
};
|
|
|
|
const tryLoad = (srcUrl: string, isRetry = false) => {
|
|
img.src = srcUrl;
|
|
|
|
img.onload = () => {
|
|
if (typeof img.decode === 'function') {
|
|
img
|
|
.decode()
|
|
.then(() => onImageReady(srcUrl))
|
|
.catch(() => onImageReady(srcUrl));
|
|
} else {
|
|
onImageReady(srcUrl);
|
|
}
|
|
};
|
|
|
|
img.onerror = () => {
|
|
// If presigned URL failed and we have storage key, retry with proxy
|
|
if (!isRetry && isPresignedUrl(srcUrl) && storageKey) {
|
|
logger.info('Image presigned URL failed, retrying with proxy', {
|
|
storageKey: storageKey.slice(-50),
|
|
});
|
|
markPresignedUrlFailed(storageKey);
|
|
const proxyUrl = buildProxyUrl(storageKey);
|
|
tryLoad(proxyUrl, true);
|
|
} else {
|
|
// Give up but still resolve to not block navigation
|
|
onImageReady(srcUrl);
|
|
}
|
|
};
|
|
};
|
|
|
|
tryLoad(url);
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Hook for smooth page switching without white/black flashes.
|
|
*
|
|
* Strategy:
|
|
* 1. When switching pages, check if target background is in preload cache
|
|
* 2. If cached, use blob URL (instant local data)
|
|
* 3. If not cached, load with presigned URL fallback
|
|
* 4. Keep previous background visible until new one is ready
|
|
*
|
|
* @example
|
|
* const pageSwitch = usePageSwitch({
|
|
* preloadCache: {
|
|
* getCachedBlobUrl: preloadOrchestrator?.getCachedBlobUrl,
|
|
* preloadedUrls: preloadOrchestrator?.preloadedUrls,
|
|
* },
|
|
* });
|
|
*
|
|
* // Switch to a page (with transition)
|
|
* await pageSwitch.switchToPage(targetPage, () => {
|
|
* setActivePageId(targetPage.id);
|
|
* });
|
|
*
|
|
* // In render, show previous background overlay while switching
|
|
* {pageSwitch.previousBgImageUrl && !pageSwitch.isNewBgReady && (
|
|
* <div style={{ backgroundImage: `url("${pageSwitch.previousBgImageUrl}")` }} />
|
|
* )}
|
|
*
|
|
* // On Image onLoad, mark background as ready
|
|
* <Image onLoad={() => pageSwitch.markBackgroundReady()} />
|
|
*/
|
|
export function usePageSwitch(
|
|
options: UsePageSwitchOptions = {},
|
|
): UsePageSwitchResult {
|
|
const { preloadCache } = options;
|
|
|
|
// Ref to track preload cache (avoids dependency issues with object identity)
|
|
const preloadCacheRef = useRef(preloadCache);
|
|
preloadCacheRef.current = preloadCache;
|
|
|
|
// Current backgrounds
|
|
const [currentBgImageUrl, setCurrentBgImageUrl] = useState('');
|
|
const [currentBgVideoUrl, setCurrentBgVideoUrl] = useState('');
|
|
const [currentBgAudioUrl, setCurrentBgAudioUrl] = useState('');
|
|
|
|
// Refs to track current URLs for use in callbacks (avoids dependency issues)
|
|
const currentBgImageUrlRef = useRef('');
|
|
const currentBgVideoUrlRef = useRef('');
|
|
const currentBgAudioUrlRef = useRef('');
|
|
currentBgImageUrlRef.current = currentBgImageUrl;
|
|
currentBgVideoUrlRef.current = currentBgVideoUrl;
|
|
currentBgAudioUrlRef.current = currentBgAudioUrl;
|
|
|
|
// Previous background for overlay
|
|
const [previousBgImageUrl, setPreviousBgImageUrl] = useState('');
|
|
const previousBgImageUrlRef = useRef('');
|
|
previousBgImageUrlRef.current = previousBgImageUrl;
|
|
|
|
const [previousBgVideoUrl, setPreviousBgVideoUrl] = useState('');
|
|
const previousBgVideoUrlRef = useRef('');
|
|
previousBgVideoUrlRef.current = previousBgVideoUrl;
|
|
|
|
// Transition state
|
|
const [isSwitching, setIsSwitching] = useState(false);
|
|
// Initialize as false to trigger fade-in animation on initial page load
|
|
const [isNewBgReady, setIsNewBgReady] = useState(false);
|
|
|
|
// Track blob URLs we created so we can revoke them
|
|
const createdBlobUrlsRef = useRef<Set<string>>(new Set());
|
|
|
|
/**
|
|
* Revoke blob URLs that we created to prevent memory leaks
|
|
*/
|
|
const revokeBlobUrl = useCallback((url: string) => {
|
|
if (url.startsWith('blob:') && createdBlobUrlsRef.current.has(url)) {
|
|
URL.revokeObjectURL(url);
|
|
createdBlobUrlsRef.current.delete(url);
|
|
}
|
|
}, []);
|
|
|
|
/**
|
|
* Resolve a storage path to a displayable URL.
|
|
* Priority: 1) ready blob URL by storage path, 2) cached blob URL by storage path,
|
|
* 3) ready blob URL by resolved URL, 4) cached blob URL, 5) presigned URL with fallback
|
|
*/
|
|
const resolveToDisplayUrl = useCallback(
|
|
async (storagePath: string | undefined): Promise<string> => {
|
|
if (!storagePath) return '';
|
|
|
|
const cache = preloadCacheRef.current;
|
|
|
|
// 1. Try in-memory storage path lookup first (instant, same session)
|
|
if (cache?.getReadyBlobUrl) {
|
|
const readyUrl = cache.getReadyBlobUrl(storagePath);
|
|
if (readyUrl) {
|
|
logger.info('Using ready blob URL (storage key)', {
|
|
storagePath: storagePath.slice(-50),
|
|
});
|
|
return readyUrl;
|
|
}
|
|
}
|
|
|
|
// 2. Try persistent cache by storage path (survives page refresh)
|
|
if (cache?.getCachedBlobUrl) {
|
|
try {
|
|
const blobUrl = await cache.getCachedBlobUrl(storagePath);
|
|
if (blobUrl) {
|
|
createdBlobUrlsRef.current.add(blobUrl);
|
|
logger.info('Using cached blob URL (storage key)', {
|
|
storagePath: storagePath.slice(-50),
|
|
});
|
|
return blobUrl;
|
|
}
|
|
} catch {
|
|
// Fall through to URL resolution
|
|
}
|
|
}
|
|
|
|
// 3. Resolve to playback URL and try lookup (fallback for resolved URLs)
|
|
const originalUrl = resolveAssetPlaybackUrl(storagePath);
|
|
if (!originalUrl) return '';
|
|
|
|
// Try instant blob URL lookup by resolved URL
|
|
if (cache?.getReadyBlobUrl) {
|
|
const readyUrl = cache.getReadyBlobUrl(originalUrl);
|
|
if (readyUrl) {
|
|
logger.info('Using ready blob URL', { url: originalUrl.slice(-50) });
|
|
return readyUrl;
|
|
}
|
|
}
|
|
|
|
// Fallback: try cached blob URL by resolved URL (check Cache API directly)
|
|
if (cache?.getCachedBlobUrl) {
|
|
try {
|
|
const blobUrl = await cache.getCachedBlobUrl(originalUrl);
|
|
if (blobUrl) {
|
|
createdBlobUrlsRef.current.add(blobUrl);
|
|
logger.info('Using cached blob URL for background', {
|
|
originalUrl: originalUrl.slice(-50),
|
|
});
|
|
return blobUrl;
|
|
}
|
|
} catch {
|
|
// Fall through
|
|
}
|
|
}
|
|
|
|
// Load with presigned URL fallback (handles CORS failures)
|
|
const storageKey = isRelativeStoragePath(storagePath)
|
|
? storagePath
|
|
: undefined;
|
|
return loadImageWithFallback(originalUrl, storageKey);
|
|
},
|
|
[],
|
|
);
|
|
|
|
/**
|
|
* Resolve video/audio URL.
|
|
* Priority: 1) ready blob URL by storage path, 2) cached blob URL by storage path,
|
|
* 3) ready blob URL by resolved URL, 4) cached blob URL, 5) resolved URL
|
|
*/
|
|
const resolveMediaUrl = useCallback(
|
|
async (storagePath: string | undefined): Promise<string> => {
|
|
if (!storagePath) return '';
|
|
|
|
const cache = preloadCacheRef.current;
|
|
|
|
// 1. Try in-memory storage path lookup first (instant, same session)
|
|
if (cache?.getReadyBlobUrl) {
|
|
const readyUrl = cache.getReadyBlobUrl(storagePath);
|
|
if (readyUrl) {
|
|
return readyUrl;
|
|
}
|
|
}
|
|
|
|
// 2. Try persistent cache by storage path (survives page refresh)
|
|
if (cache?.getCachedBlobUrl) {
|
|
try {
|
|
const blobUrl = await cache.getCachedBlobUrl(storagePath);
|
|
if (blobUrl) {
|
|
createdBlobUrlsRef.current.add(blobUrl);
|
|
return blobUrl;
|
|
}
|
|
} catch {
|
|
// Fall through
|
|
}
|
|
}
|
|
|
|
// 3. Resolve URL and try lookup by resolved URL
|
|
const originalUrl = resolveAssetPlaybackUrl(storagePath);
|
|
if (!originalUrl) return '';
|
|
|
|
if (cache?.getReadyBlobUrl) {
|
|
const readyUrl = cache.getReadyBlobUrl(originalUrl);
|
|
if (readyUrl) {
|
|
return readyUrl;
|
|
}
|
|
}
|
|
|
|
// Try cached blob URL by resolved URL (check Cache API directly)
|
|
if (cache?.getCachedBlobUrl) {
|
|
try {
|
|
const blobUrl = await cache.getCachedBlobUrl(originalUrl);
|
|
if (blobUrl) {
|
|
createdBlobUrlsRef.current.add(blobUrl);
|
|
return blobUrl;
|
|
}
|
|
} catch {
|
|
// Fall through
|
|
}
|
|
}
|
|
|
|
return originalUrl;
|
|
},
|
|
[],
|
|
);
|
|
|
|
/**
|
|
* Switch to a new page with smooth transition
|
|
*/
|
|
const switchToPage = useCallback(
|
|
async (targetPage: SwitchablePage | null, onSwitched?: () => void) => {
|
|
if (!targetPage) {
|
|
setCurrentBgImageUrl('');
|
|
setCurrentBgVideoUrl('');
|
|
setCurrentBgAudioUrl('');
|
|
setPreviousBgImageUrl('');
|
|
setPreviousBgVideoUrl('');
|
|
setIsSwitching(false);
|
|
setIsNewBgReady(true);
|
|
onSwitched?.();
|
|
return;
|
|
}
|
|
|
|
// Save current backgrounds as previous for overlay (use refs to avoid dependencies)
|
|
if (currentBgImageUrlRef.current) {
|
|
setPreviousBgImageUrl(currentBgImageUrlRef.current);
|
|
}
|
|
if (currentBgVideoUrlRef.current) {
|
|
setPreviousBgVideoUrl(currentBgVideoUrlRef.current);
|
|
}
|
|
|
|
setIsSwitching(true);
|
|
setIsNewBgReady(false);
|
|
|
|
// Resolve URLs in parallel, preferring cached blob URLs
|
|
const [imageUrl, videoUrl, audioUrl] = await Promise.all([
|
|
resolveToDisplayUrl(targetPage.background_image_url),
|
|
resolveMediaUrl(targetPage.background_video_url),
|
|
resolveMediaUrl(targetPage.background_audio_url),
|
|
]);
|
|
|
|
// Set new backgrounds
|
|
setCurrentBgImageUrl(imageUrl);
|
|
setCurrentBgVideoUrl(videoUrl);
|
|
setCurrentBgAudioUrl(audioUrl);
|
|
|
|
// Notify caller that backgrounds are set
|
|
onSwitched?.();
|
|
|
|
// For blob URLs, mark ready after paint (Safari-compatible)
|
|
if (imageUrl.startsWith('blob:') || !imageUrl) {
|
|
scheduleAfterPaint(() => {
|
|
setIsNewBgReady(true);
|
|
});
|
|
}
|
|
// For remote images, wait for Image onLoad (caller should use markBackgroundReady)
|
|
},
|
|
[resolveToDisplayUrl, resolveMediaUrl],
|
|
);
|
|
|
|
/**
|
|
* Directly set backgrounds without transition overlay.
|
|
* Used for initial page load with fade-in animation.
|
|
*/
|
|
const setBackgroundsDirectly = useCallback(
|
|
(imageUrl: string, videoUrl: string, audioUrl: string) => {
|
|
// Revoke old blob URLs (use refs to avoid dependency)
|
|
revokeBlobUrl(currentBgImageUrlRef.current);
|
|
revokeBlobUrl(currentBgVideoUrlRef.current);
|
|
revokeBlobUrl(currentBgAudioUrlRef.current);
|
|
revokeBlobUrl(previousBgImageUrlRef.current);
|
|
|
|
setCurrentBgImageUrl(imageUrl);
|
|
setCurrentBgVideoUrl(videoUrl);
|
|
setCurrentBgAudioUrl(audioUrl);
|
|
setPreviousBgImageUrl('');
|
|
setIsSwitching(false);
|
|
|
|
// Trigger fade-in animation: set not-ready then ready after paint
|
|
// This ensures the CSS animation triggers on initial page load
|
|
setIsNewBgReady(false);
|
|
scheduleAfterPaint(() => {
|
|
setIsNewBgReady(true);
|
|
});
|
|
},
|
|
[revokeBlobUrl],
|
|
);
|
|
|
|
/**
|
|
* Mark background as ready (call from Image onLoad)
|
|
*/
|
|
const markBackgroundReady = useCallback(() => {
|
|
setIsNewBgReady(true);
|
|
}, []);
|
|
|
|
/**
|
|
* Clear the previous background overlay (both image and video)
|
|
*/
|
|
const clearPreviousBackground = useCallback(() => {
|
|
const prevImageUrl = previousBgImageUrlRef.current;
|
|
const prevVideoUrl = previousBgVideoUrlRef.current;
|
|
setPreviousBgImageUrl('');
|
|
setPreviousBgVideoUrl('');
|
|
setIsSwitching(false);
|
|
|
|
// Revoke the previous blob URLs after clearing
|
|
if (prevImageUrl) {
|
|
revokeBlobUrl(prevImageUrl);
|
|
}
|
|
if (prevVideoUrl) {
|
|
revokeBlobUrl(prevVideoUrl);
|
|
}
|
|
}, [revokeBlobUrl]);
|
|
|
|
return {
|
|
currentBgImageUrl,
|
|
currentBgVideoUrl,
|
|
currentBgAudioUrl,
|
|
previousBgImageUrl,
|
|
previousBgVideoUrl,
|
|
isSwitching,
|
|
isNewBgReady,
|
|
switchToPage,
|
|
setBackgroundsDirectly,
|
|
markBackgroundReady,
|
|
clearPreviousBackground,
|
|
};
|
|
}
|