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

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