improved video processing, pages navigation and assets preloading

This commit is contained in:
Dmitri 2026-05-08 07:54:30 +02:00
parent ba813d2602
commit 06a29dbf6a
43 changed files with 4201 additions and 2769 deletions

View File

@ -335,6 +335,38 @@ const uploadFile = async (folder, req, res) => {
} }
}; };
/**
* Parse Range header value
* @param {string} rangeHeader - Range header value (e.g., "bytes=0-1000")
* @param {number} totalSize - Total file size
* @returns {{start: number, end: number} | null}
*/
const parseRangeHeader = (rangeHeader, totalSize) => {
if (!rangeHeader || !rangeHeader.startsWith('bytes=')) return null;
const range = rangeHeader.slice(6); // Remove "bytes="
const parts = range.split('-');
let start = parseInt(parts[0], 10);
let end = parts[1] ? parseInt(parts[1], 10) : totalSize - 1;
// Handle suffix ranges (e.g., bytes=-500 means last 500 bytes)
if (isNaN(start)) {
start = totalSize - end;
end = totalSize - 1;
}
// Validate range
if (isNaN(start) || isNaN(end) || start > end || start >= totalSize) {
return null;
}
// Cap end to file size
end = Math.min(end, totalSize - 1);
return { start, end };
};
const downloadFile = async (req, res) => { const downloadFile = async (req, res) => {
const provider = getFileStorageProvider(); const provider = getFileStorageProvider();
const privateUrl = req.query.privateUrl; const privateUrl = req.query.privateUrl;
@ -359,6 +391,7 @@ const downloadFile = async (req, res) => {
} }
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
res.setHeader('Accept-Ranges', 'bytes');
// Create AbortController for request cancellation // Create AbortController for request cancellation
const abortController = new AbortController(); const abortController = new AbortController();
@ -393,14 +426,6 @@ const downloadFile = async (req, res) => {
return res.status(304).end(); return res.status(304).end();
} }
// Set caching headers
res.setHeader('ETag', etag);
res.setHeader(
'Cache-Control',
`public, max-age=${config.s3CacheMaxAge}`,
);
res.setHeader('Content-Length', stats.size);
// Determine content type from extension // Determine content type from extension
const ext = path.extname(privateUrl).toLowerCase(); const ext = path.extname(privateUrl).toLowerCase();
const mimeTypes = { const mimeTypes = {
@ -421,12 +446,84 @@ const downloadFile = async (req, res) => {
res.setHeader('Content-Type', mimeTypes[ext]); res.setHeader('Content-Type', mimeTypes[ext]);
} }
// Handle Range requests for cached files
const rangeHeader = req.headers.range;
if (rangeHeader) {
const range = parseRangeHeader(rangeHeader, stats.size);
if (range) {
const { start, end } = range;
const chunkSize = end - start + 1;
res.status(206);
res.setHeader('Content-Range', `bytes ${start}-${end}/${stats.size}`);
res.setHeader('Content-Length', chunkSize);
res.setHeader('ETag', etag);
res.setHeader('Cache-Control', `public, max-age=${config.s3CacheMaxAge}`);
return fs.createReadStream(cachePath, { start, end }).pipe(res);
}
// Invalid range - return 416
res.setHeader('Content-Range', `bytes */${stats.size}`);
return res.status(416).end();
}
// Set caching headers for full file
res.setHeader('ETag', etag);
res.setHeader(
'Cache-Control',
`public, max-age=${config.s3CacheMaxAge}`,
);
res.setHeader('Content-Length', stats.size);
// Stream from cache // Stream from cache
return fs.createReadStream(cachePath).pipe(res); return fs.createReadStream(cachePath).pipe(res);
} }
} }
// Download from S3 // Handle Range requests for S3 (bypass cache for partial requests)
const rangeHeader = req.headers.range;
if (rangeHeader) {
// For Range requests, we need to get file size first via headObject
const headResult = await s3.download(privateUrl, { signal, headOnly: true });
const totalSize = headResult.contentLength;
if (!totalSize) {
log.warn({ privateUrl }, 'Cannot determine file size for range request');
return res.status(500).send(createErrorResponse('Cannot determine file size', 'SIZE_UNKNOWN'));
}
const range = parseRangeHeader(rangeHeader, totalSize);
if (!range) {
res.setHeader('Content-Range', `bytes */${totalSize}`);
return res.status(416).end();
}
const { start, end } = range;
const chunkSize = end - start + 1;
// Download range from S3
const rangeResult = await s3.download(privateUrl, {
signal,
range: `bytes=${start}-${end}`,
});
res.status(206);
res.setHeader('Content-Range', `bytes ${start}-${end}/${totalSize}`);
res.setHeader('Content-Length', chunkSize);
if (rangeResult.contentType) res.setHeader('Content-Type', rangeResult.contentType);
res.setHeader('Cache-Control', `public, max-age=${config.s3CacheMaxAge}`);
if (typeof rangeResult.body.pipe === 'function') {
return rangeResult.body.pipe(res);
} else if (typeof rangeResult.body.transformToByteArray === 'function') {
const bytes = await rangeResult.body.transformToByteArray();
return res.send(Buffer.from(bytes));
} else {
return res.send(rangeResult.body);
}
}
// Download from S3 (full file)
const result = await s3.download(privateUrl, { signal }); const result = await s3.download(privateUrl, { signal });
if (result.contentType) res.setHeader('Content-Type', result.contentType); if (result.contentType) res.setHeader('Content-Type', result.contentType);
@ -537,7 +634,58 @@ const downloadFile = async (req, res) => {
.send(createErrorResponse('File not found', 'NOT_FOUND')); .send(createErrorResponse('File not found', 'NOT_FOUND'));
} }
} else { } else {
res.download(path.join(config.uploadDir, privateUrl)); // Local storage - support Range requests for video streaming
const localFilePath = path.join(config.uploadDir, privateUrl);
if (!fs.existsSync(localFilePath)) {
return res.status(404).send(createErrorResponse('File not found', 'NOT_FOUND'));
}
const stats = fs.statSync(localFilePath);
const totalSize = stats.size;
// Determine content type from extension
const ext = path.extname(privateUrl).toLowerCase();
const mimeTypes = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.pdf': 'application/pdf',
};
if (mimeTypes[ext]) {
res.setHeader('Content-Type', mimeTypes[ext]);
}
// Handle Range requests
const rangeHeader = req.headers.range;
if (rangeHeader) {
const range = parseRangeHeader(rangeHeader, totalSize);
if (range) {
const { start, end } = range;
const chunkSize = end - start + 1;
res.status(206);
res.setHeader('Content-Range', `bytes ${start}-${end}/${totalSize}`);
res.setHeader('Content-Length', chunkSize);
return fs.createReadStream(localFilePath, { start, end }).pipe(res);
}
// Invalid range - return 416
res.setHeader('Content-Range', `bytes */${totalSize}`);
return res.status(416).end();
}
// Full file download
res.setHeader('Content-Length', totalSize);
return fs.createReadStream(localFilePath).pipe(res);
} }
} catch (error) { } catch (error) {
// Don't log abort errors as they're expected when client disconnects // Don't log abort errors as they're expected when client disconnects

View File

@ -227,18 +227,44 @@ class S3StorageProvider extends BaseStorageProvider {
* @param {string} key - Storage key/path * @param {string} key - Storage key/path
* @param {Object} [options] - Download options * @param {Object} [options] - Download options
* @param {AbortSignal} [options.signal] - AbortController signal for cancellation * @param {AbortSignal} [options.signal] - AbortController signal for cancellation
* @param {boolean} [options.headOnly] - Only get metadata (HEAD request)
* @param {string} [options.range] - HTTP Range header value (e.g., "bytes=0-1000")
* @returns {Promise<{ body: ReadableStream, contentType?: string, contentLength?: number }>} * @returns {Promise<{ body: ReadableStream, contentType?: string, contentLength?: number }>}
*/ */
async download(key, options = {}) { async download(key, options = {}) {
const fullKey = this.buildKey(key); const fullKey = this.buildKey(key);
const { signal } = options; const { signal, headOnly, range } = options;
const sendOptions = signal ? { abortSignal: signal } : {}; const sendOptions = signal ? { abortSignal: signal } : {};
// HEAD request for metadata only
if (headOnly) {
const output = await this.client.send(
new HeadObjectCommand({
Bucket: this.bucket,
Key: fullKey,
}),
sendOptions,
);
return {
body: null,
contentType: output.ContentType,
contentLength: output.ContentLength,
};
}
// Build GetObjectCommand with optional Range header
const commandParams = {
Bucket: this.bucket,
Key: fullKey,
};
if (range) {
commandParams.Range = range;
}
const output = await this.client.send( const output = await this.client.send(
new GetObjectCommand({ new GetObjectCommand(commandParams),
Bucket: this.bucket,
Key: fullKey,
}),
sendOptions, sendOptions,
); );

File diff suppressed because one or more lines are too long

View File

@ -24,7 +24,7 @@ interface CanvasLoadingSpinnerProps {
const CanvasLoadingSpinner: React.FC<CanvasLoadingSpinnerProps> = ({ const CanvasLoadingSpinner: React.FC<CanvasLoadingSpinnerProps> = ({
isVisible, isVisible,
message = 'Loading...', message,
size = 'md', size = 'md',
progress, progress,
zIndex = 100, zIndex = 100,
@ -50,23 +50,20 @@ const CanvasLoadingSpinner: React.FC<CanvasLoadingSpinnerProps> = ({
const sizeClasses = { const sizeClasses = {
sm: 'w-8 h-8 border-2', sm: 'w-8 h-8 border-2',
md: 'w-12 h-12 border-3', md: 'w-12 h-12 border-[3px]',
lg: 'w-16 h-16 border-4', lg: 'w-16 h-16 border-4',
}; };
return ( return (
<div <div
className='absolute inset-0 flex flex-col items-center justify-center transition-opacity duration-300' className='absolute inset-0 flex flex-col items-center justify-center pointer-events-none'
style={{ style={{ zIndex }}
// Semi-transparent background with blur
// Reduced blur on mobile for better performance
background: 'rgba(0, 0, 0, 0.3)',
backdropFilter: 'blur(4px)',
WebkitBackdropFilter: 'blur(4px)',
zIndex,
}}
> >
<div className='relative'> {/* Spinner with subtle shadow for visibility on any background */}
<div
className='relative'
style={{ filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.5))' }}
>
{/* Spinner ring */} {/* Spinner ring */}
<div <div
className={`${sizeClasses[size]} rounded-full border-white/30 border-t-white animate-spin`} className={`${sizeClasses[size]} rounded-full border-white/30 border-t-white animate-spin`}
@ -75,14 +72,16 @@ const CanvasLoadingSpinner: React.FC<CanvasLoadingSpinnerProps> = ({
{/* Progress indicator (optional) */} {/* Progress indicator (optional) */}
{progress !== undefined && ( {progress !== undefined && (
<div className='absolute inset-0 flex items-center justify-center'> <div className='absolute inset-0 flex items-center justify-center'>
<span className='text-white text-xs font-medium'> <span className='text-white text-xs font-medium drop-shadow-md'>
{Math.round(progress)}% {Math.round(progress)}%
</span> </span>
</div> </div>
)} )}
</div> </div>
{message && ( {message && (
<p className='mt-3 text-white/90 text-sm font-medium'>{message}</p> <p className='mt-3 text-white/90 text-sm font-medium drop-shadow-md'>
{message}
</p>
)} )}
</div> </div>
); );

View File

@ -16,10 +16,19 @@ import React, {
import NextImage from 'next/image'; import NextImage from 'next/image';
import { useBackgroundVideoPlayback } from '../../hooks/useBackgroundVideoPlayback'; import { useBackgroundVideoPlayback } from '../../hooks/useBackgroundVideoPlayback';
import PreviousBackgroundOverlay from '../PreviousBackgroundOverlay'; import PreviousBackgroundOverlay from '../PreviousBackgroundOverlay';
import CanvasLoadingSpinner from '../CanvasLoadingSpinner';
import { scheduleAfterPaint } from '../../lib/browserUtils';
import { baseURLApi } from '../../config'; import { baseURLApi } from '../../config';
/**
* Schedule a callback to run after the next browser paint.
* Uses double rAF pattern: first rAF schedules for next frame,
* second rAF ensures the frame has actually been committed.
*/
const scheduleAfterPaint = (callback: () => void): void => {
requestAnimationFrame(() => {
requestAnimationFrame(callback);
});
};
// Type for requestVideoFrameCallback (Safari 15.4+, Chrome 83+) // Type for requestVideoFrameCallback (Safari 15.4+, Chrome 83+)
// The callback receives (now: DOMHighResTimeStamp, metadata: VideoFrameCallbackMetadata) // The callback receives (now: DOMHighResTimeStamp, metadata: VideoFrameCallbackMetadata)
// but we ignore them since we only need to know the frame was painted // but we ignore them since we only need to know the frame was painted
@ -51,6 +60,8 @@ interface CanvasBackgroundProps {
videoEndTime?: number | null; videoEndTime?: number | null;
/** Original storage path for video - used for play-once tracking (not the resolved blob URL) */ /** Original storage path for video - used for play-once tracking (not the resolved blob URL) */
videoStoragePath?: string; videoStoragePath?: string;
/** Pause video playback (e.g., during navigation to show frozen frame) */
pauseVideo?: boolean;
} }
const CanvasBackground: React.FC<CanvasBackgroundProps> = ({ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
@ -69,21 +80,33 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
videoStartTime = null, videoStartTime = null,
videoEndTime = null, videoEndTime = null,
videoStoragePath, videoStoragePath,
pauseVideo = false,
}) => { }) => {
// During page switching with video paused, keep showing the previous video URL.
// This prevents black flash when the video element would remount with a new URL.
// The old video element stays visible (paused at frozen frame) until new page is ready.
const activeVideoUrl =
isSwitching && !isNewBgReady && pauseVideo && previousBgVideoUrl
? previousBgVideoUrl
: backgroundVideoUrl;
// Use background video playback hook for custom start/end time handling // Use background video playback hook for custom start/end time handling
// Use storagePath for play-once tracking (falls back to videoUrl if not provided) // Use storagePath for play-once tracking (falls back to videoUrl if not provided)
// Pass pauseVideo to hook for centralized playback control (fixes video playing during navigation)
const { videoRef, shouldBlockAutoplay } = useBackgroundVideoPlayback({ const { videoRef, shouldBlockAutoplay } = useBackgroundVideoPlayback({
videoUrl: backgroundVideoUrl, videoUrl: activeVideoUrl,
videoStoragePath: videoStoragePath || backgroundVideoUrl, videoStoragePath: videoStoragePath || backgroundVideoUrl,
autoplay: videoAutoplay, autoplay: videoAutoplay,
loop: videoLoop, loop: videoLoop,
muted: videoMuted, muted: videoMuted,
startTime: videoStartTime, startTime: videoStartTime,
endTime: videoEndTime, endTime: videoEndTime,
paused: pauseVideo,
}); });
// Block autoplay if video already played this session (when loop=false) // Block autoplay if: video already played this session OR externally paused
const effectiveAutoplay = videoAutoplay && !shouldBlockAutoplay; const effectiveAutoplay =
videoAutoplay && !shouldBlockAutoplay && !pauseVideo;
// Video error state for fallback to proxy URL // Video error state for fallback to proxy URL
const [videoError, setVideoError] = useState(false); const [videoError, setVideoError] = useState(false);
@ -96,6 +119,9 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
const video = videoRef.current; const video = videoRef.current;
if (!backgroundVideoUrl || !video) { if (!backgroundVideoUrl || !video) {
setIsVideoBuffering(false); setIsVideoBuffering(false);
// CRITICAL: Also notify parent that buffering is done when there's no video
// Without this, parent's isBackgroundVideoBuffering stays stuck at true from previous page
onVideoBufferStateChange?.(false);
return; return;
} }
@ -124,13 +150,13 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
// Fallback to proxy URL if presigned URL fails (e.g., CORS, expiration) // Fallback to proxy URL if presigned URL fails (e.g., CORS, expiration)
const videoSrc = useMemo(() => { const videoSrc = useMemo(() => {
if (!backgroundVideoUrl) return undefined; if (!activeVideoUrl) return undefined;
if (videoError && videoStoragePath) { if (videoError && videoStoragePath) {
// Fallback to backend proxy (bypasses CORS issues) // Fallback to backend proxy (bypasses CORS issues)
return `${baseURLApi}/file/download?privateUrl=${encodeURIComponent(videoStoragePath)}`; return `${baseURLApi}/file/download?privateUrl=${encodeURIComponent(videoStoragePath)}`;
} }
return backgroundVideoUrl; return activeVideoUrl;
}, [backgroundVideoUrl, videoStoragePath, videoError]); }, [activeVideoUrl, videoStoragePath, videoError]);
// Reset error state when video URL changes // Reset error state when video URL changes
useEffect(() => { useEffect(() => {
@ -145,25 +171,159 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
} }
}, [videoError, videoStoragePath]); }, [videoError, videoStoragePath]);
const handleLoad = () => { // Track if we've already called onBackgroundReady to avoid double calls
const didReportImageReadyRef = useRef(false);
const imageRef = useRef<HTMLImageElement>(null);
// Ref for NextImage wrapper to detect its internal img element
const nextImageWrapperRef = useRef<HTMLDivElement>(null);
// Track previous URL to detect changes synchronously during render
const prevImageUrlRef = useRef<string | undefined>(undefined);
// Track previous switching state to detect navigation start
const prevIsSwitchingRef = useRef(false);
// CRITICAL: Reset ready flag SYNCHRONOUSLY during render, before onLoad can fire.
// Reset when:
// 1. URL changes - new image needs to report ready
// 2. isSwitching transitions from false to true - navigation started, even if URL is the same
// (handles case where two pages have the same background image)
//
// Using useEffect for this creates a race condition:
// 1. URL changes, component re-renders
// 2. For cached images, onLoad fires immediately (maybe even before React attaches handlers)
// 3. handleLoad checks didReportImageReadyRef which is still TRUE from previous image
// 4. Guard exits early, callback is skipped
// 5. useEffect runs AFTER render, resetting the flag too late
// By resetting synchronously here, we ensure the flag is false before any event handlers run.
const switchingStarted = isSwitching && !prevIsSwitchingRef.current;
if (prevImageUrlRef.current !== backgroundImageUrl || switchingStarted) {
didReportImageReadyRef.current = false;
prevImageUrlRef.current = backgroundImageUrl;
}
prevIsSwitchingRef.current = isSwitching;
const handleLoad = useCallback(() => {
if (didReportImageReadyRef.current) {
return;
}
didReportImageReadyRef.current = true;
// Wait for paint to ensure background is actually rendered before reporting ready. // Wait for paint to ensure background is actually rendered before reporting ready.
// This prevents the transition overlay from being removed before the background is visible. // This prevents the transition overlay from being removed before the background is visible.
scheduleAfterPaint(() => { scheduleAfterPaint(() => {
onBackgroundReady?.(); onBackgroundReady?.();
}); });
}; }, [onBackgroundReady, backgroundImageUrl]);
const handleError = () => { const handleError = useCallback(() => {
if (didReportImageReadyRef.current) return;
didReportImageReadyRef.current = true;
onBackgroundReady?.(); onBackgroundReady?.();
}; }, [onBackgroundReady]);
// Track if we've already called onBackgroundReady to avoid double calls // Handle already-loaded images (blob URLs from preload cache)
const didReportReadyRef = useRef(false); // The onLoad event may not fire for images that are already in memory
// Reset ready flag when video URL changes
useEffect(() => { useEffect(() => {
const img = imageRef.current;
if (!backgroundImageUrl || !img || didReportImageReadyRef.current) return;
// Check if image is already loaded (common with blob URLs)
if (img.complete && img.naturalWidth > 0) {
// Use decode() to ensure image is fully decoded before reporting ready
if (typeof img.decode === 'function') {
img.decode().then(handleLoad).catch(handleLoad);
} else {
handleLoad();
}
}
}, [backgroundImageUrl, handleLoad]);
// Handle NextImage load detection (for non-blob URLs like presigned URLs)
// NextImage's onLoad may not fire for cached images, so we detect its internal img element
useEffect(() => {
// Only handle non-blob URLs (blob URLs use native img with imageRef)
if (
!backgroundImageUrl ||
backgroundImageUrl.startsWith('blob:') ||
didReportImageReadyRef.current
)
return;
const wrapper = nextImageWrapperRef.current;
if (!wrapper) return;
let loadCleanup: (() => void) | null = null;
// Setup load listener on the internal img element
const setupLoadListener = (img: HTMLImageElement) => {
// Use decode() to ensure image is fully decoded before reporting ready
// This prevents flash on first load when image needs to be fetched and decoded
const decodeAndReport = () => {
if (typeof img.decode === 'function') {
img.decode().then(handleLoad).catch(handleLoad);
} else {
handleLoad();
}
};
// If already loaded, decode and report
if (img.complete && img.naturalWidth > 0) {
decodeAndReport();
return;
}
// Not loaded yet, attach load event listener
const onLoad = () => decodeAndReport();
img.addEventListener('load', onLoad, { once: true });
loadCleanup = () => img.removeEventListener('load', onLoad);
};
// Check if NextImage's internal img element already exists
const existingImg = wrapper.querySelector('img');
if (existingImg) {
setupLoadListener(existingImg);
return () => loadCleanup?.();
}
// Wait for NextImage to render its internal img element using MutationObserver
const observer = new MutationObserver((mutations) => {
for (let i = 0; i < mutations.length; i++) {
const addedNodes = mutations[i].addedNodes;
for (let j = 0; j < addedNodes.length; j++) {
const node = addedNodes[j];
if (node instanceof HTMLImageElement) {
setupLoadListener(node);
observer.disconnect();
return;
}
if (node instanceof Element) {
const img = node.querySelector('img');
if (img) {
setupLoadListener(img);
observer.disconnect();
return;
}
}
}
}
});
observer.observe(wrapper, { childList: true, subtree: true });
return () => {
observer.disconnect();
loadCleanup?.();
};
}, [backgroundImageUrl, handleLoad]);
// Track if we've already called onBackgroundReady to avoid double calls (for video)
const didReportReadyRef = useRef(false);
// Track previous video URL to detect changes synchronously during render
const prevVideoUrlRef = useRef<string | undefined>(undefined);
// CRITICAL: Reset ready flag SYNCHRONOUSLY during render (same reason as image above).
// Also reset when switching starts, to handle pages with same video URL.
if (prevVideoUrlRef.current !== backgroundVideoUrl || switchingStarted) {
didReportReadyRef.current = false; didReportReadyRef.current = false;
}, [backgroundVideoUrl]); prevVideoUrlRef.current = backgroundVideoUrl;
}
// Handle video first frame ready using requestVideoFrameCallback // Handle video first frame ready using requestVideoFrameCallback
// This ensures the video's first frame is actually painted before we report ready // This ensures the video's first frame is actually painted before we report ready
@ -188,11 +348,15 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
}, 5000); }, 5000);
// Use requestVideoFrameCallback for precise frame-level timing (Safari 15.4+, Chrome 83+) // Use requestVideoFrameCallback for precise frame-level timing (Safari 15.4+, Chrome 83+)
// RVFC fires when frame is decoded, but compositor may not have painted yet.
// Wrap in scheduleAfterPaint for consistency with image handling.
const videoWithRVFC = video as HTMLVideoElementWithRVFC; const videoWithRVFC = video as HTMLVideoElementWithRVFC;
if (typeof videoWithRVFC.requestVideoFrameCallback === 'function') { if (typeof videoWithRVFC.requestVideoFrameCallback === 'function') {
videoWithRVFC.requestVideoFrameCallback(() => { videoWithRVFC.requestVideoFrameCallback(() => {
clearTimeout(timeout); clearTimeout(timeout);
reportVideoReady(); scheduleAfterPaint(() => {
reportVideoReady();
});
}); });
} else { } else {
// Fallback: use playing event + scheduleAfterPaint // Fallback: use playing event + scheduleAfterPaint
@ -216,6 +380,10 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
// When endTime is set, we disable native loop and handle it via the hook // When endTime is set, we disable native loop and handle it via the hook
const useNativeLoop = videoEndTime == null ? videoLoop : false; const useNativeLoop = videoEndTime == null ? videoLoop : false;
// Note: pauseVideo is now handled by useBackgroundVideoPlayback hook directly.
// The hook centralizes all playback control, eliminating race conditions between
// separate effects competing to control the video element.
return ( return (
<> <>
{/* Background image - z-1 keeps it below backdrop blur layer (z-5). {/* Background image - z-1 keeps it below backdrop blur layer (z-5).
@ -233,6 +401,7 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
{backgroundImageUrl.startsWith('blob:') ? ( {backgroundImageUrl.startsWith('blob:') ? (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img <img
ref={imageRef}
key={`bg_image_${backgroundImageUrl}`} key={`bg_image_${backgroundImageUrl}`}
src={backgroundImageUrl} src={backgroundImageUrl}
alt='Background' alt='Background'
@ -242,18 +411,23 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
onError={handleError} onError={handleError}
/> />
) : ( ) : (
<NextImage <div
key={`bg_image_${backgroundImageUrl}`} ref={nextImageWrapperRef}
src={backgroundImageUrl} className='absolute inset-0 h-full w-full'
alt='Background' >
fill <NextImage
sizes='100vw' key={`bg_image_${backgroundImageUrl}`}
className='object-contain' src={backgroundImageUrl}
draggable={false} alt='Background'
unoptimized fill
onLoad={handleLoad} sizes='100vw'
onError={handleError} className='object-contain'
/> draggable={false}
unoptimized
onLoad={handleLoad}
onError={handleError}
/>
</div>
)} )}
</div> </div>
)} )}
@ -265,6 +439,7 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
videoUrl={previousBgVideoUrl} videoUrl={previousBgVideoUrl}
isSwitching={isSwitching} isSwitching={isSwitching}
isNewBgReady={isNewBgReady} isNewBgReady={isNewBgReady}
paused={pauseVideo}
/> />
{/* Background video - z-1 keeps it below backdrop blur layer (z-5) {/* Background video - z-1 keeps it below backdrop blur layer (z-5)
@ -274,18 +449,13 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
webkit-playsinline is legacy attribute for older iOS versions. webkit-playsinline is legacy attribute for older iOS versions.
preload="metadata" is required for iOS Safari video initialization. preload="metadata" is required for iOS Safari video initialization.
Video fades in when ready (opacity transition from 0 to 1). */} Video fades in when ready (opacity transition from 0 to 1). */}
{backgroundVideoUrl && ( {activeVideoUrl && (
<video <video
ref={videoRef} ref={videoRef}
key={`bg_video_${backgroundVideoUrl}`} key={`bg_video_${activeVideoUrl}`}
className='absolute inset-0 z-1 h-full w-full object-contain' className='absolute inset-0 z-1 h-full w-full object-contain'
style={{
// Fade in when video is ready
opacity: isVideoBuffering ? 0 : 1,
transition: 'opacity 300ms ease-out',
}}
src={videoSrc} src={videoSrc}
preload='metadata' preload='auto'
autoPlay={effectiveAutoplay} autoPlay={effectiveAutoplay}
loop={useNativeLoop} loop={useNativeLoop}
muted={videoMuted} muted={videoMuted}
@ -295,17 +465,6 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
/> />
)} )}
{/* Loading spinner for video-only pages (no image fallback).
Shows while video is buffering, provides user feedback. */}
{backgroundVideoUrl && !backgroundImageUrl && isVideoBuffering && (
<CanvasLoadingSpinner
isVisible={true}
message='Loading video...'
size='md'
zIndex={4}
/>
)}
{/* Background audio */} {/* Background audio */}
{backgroundAudioUrl && ( {backgroundAudioUrl && (
<audio <audio

View File

@ -18,12 +18,12 @@ import {
} from '../../lib/elementEffects'; } from '../../lib/elementEffects';
import type { CanvasElement as CanvasElementType } from '../../types/constructor'; import type { CanvasElement as CanvasElementType } from '../../types/constructor';
import type { ResolvedTransitionSettings } from '../../types/transition'; import type { ResolvedTransitionSettings } from '../../types/transition';
import type { PreloadCacheProvider } from '../../hooks/video';
interface CanvasElementProps { interface CanvasElementProps {
element: CanvasElementType; element: CanvasElementType;
isSelected: boolean; isSelected: boolean;
isEditMode: boolean; isEditMode: boolean;
isDisabled?: boolean;
onClick: () => void; onClick: () => void;
onMouseDown?: (event: React.MouseEvent) => void; onMouseDown?: (event: React.MouseEvent) => void;
/** Optional URL resolver for preloaded blob URLs */ /** Optional URL resolver for preloaded blob URLs */
@ -40,13 +40,14 @@ interface CanvasElementProps {
letterboxStyles?: React.CSSProperties; letterboxStyles?: React.CSSProperties;
/** Page transition settings (for slide transition cascade in carousel/gallery) */ /** Page transition settings (for slide transition cascade in carousel/gallery) */
pageTransitionSettings?: ResolvedTransitionSettings; pageTransitionSettings?: ResolvedTransitionSettings;
/** Preload cache provider for video elements */
preloadCache?: PreloadCacheProvider;
} }
const CanvasElement: React.FC<CanvasElementProps> = ({ const CanvasElement: React.FC<CanvasElementProps> = ({
element, element,
isSelected, isSelected,
isEditMode, isEditMode,
isDisabled = false,
onClick, onClick,
onMouseDown, onMouseDown,
resolveUrl, resolveUrl,
@ -54,6 +55,7 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
onCarouselButtonPositionChange, onCarouselButtonPositionChange,
letterboxStyles, letterboxStyles,
pageTransitionSettings, pageTransitionSettings,
preloadCache,
}) => { }) => {
// Extract effect properties from element // Extract effect properties from element
const effectProperties: Partial<ElementEffectProperties> = { const effectProperties: Partial<ElementEffectProperties> = {
@ -91,11 +93,6 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
left: `${xClamped}%`, left: `${xClamped}%`,
top: `${yClamped}%`, top: `${yClamped}%`,
transform: 'translate(-50%, -50%)', transform: 'translate(-50%, -50%)',
// Reset button defaults to let UiElementRenderer control styling
background: 'transparent',
border: 'none',
padding: 0,
margin: 0,
}; };
// Merge interactive effects (preview mode only) // Merge interactive effects (preview mode only)
@ -125,14 +122,24 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
}; };
} }
// Handle keyboard interaction for accessibility
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onClick();
}
};
return ( return (
<button <div
type='button' role='button'
tabIndex={0}
data-constructor-element-id={element.id} data-constructor-element-id={element.id}
className='absolute' className='absolute cursor-pointer'
style={positionStyle} style={positionStyle}
onMouseDown={isEditMode ? onMouseDown : undefined} onMouseDown={isEditMode ? onMouseDown : undefined}
onClick={onClick} onClick={onClick}
onKeyDown={handleKeyDown}
{...(!isEditMode ? eventHandlers : {})} {...(!isEditMode ? eventHandlers : {})}
> >
<UiElementRenderer <UiElementRenderer
@ -140,13 +147,13 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
resolveUrl={resolveUrl} resolveUrl={resolveUrl}
isSelected={isSelected} isSelected={isSelected}
isEditMode={isEditMode} isEditMode={isEditMode}
isDisabled={isDisabled}
onGalleryCardClick={onGalleryCardClick} onGalleryCardClick={onGalleryCardClick}
onCarouselButtonPositionChange={onCarouselButtonPositionChange} onCarouselButtonPositionChange={onCarouselButtonPositionChange}
letterboxStyles={letterboxStyles} letterboxStyles={letterboxStyles}
pageTransitionSettings={pageTransitionSettings} pageTransitionSettings={pageTransitionSettings}
preloadCache={preloadCache}
/> />
</button> </div>
); );
}; };

View File

@ -7,9 +7,14 @@
* *
* Supports letterbox mode to constrain transitions within canvas bounds, * Supports letterbox mode to constrain transitions within canvas bounds,
* matching the behavior of background images and UI elements. * matching the behavior of background images and UI elements.
*
* Hide behavior:
* - Waits one requestAnimationFrame after isFadingOut=true
* - This ensures the new background is painted before hiding
* - Then hides instantly (no CSS transition) since last video frame = new bg
*/ */
import React from 'react'; import React, { useState, useEffect } from 'react';
import CanvasLoadingSpinner from '../CanvasLoadingSpinner'; import CanvasLoadingSpinner from '../CanvasLoadingSpinner';
interface TransitionPreviewOverlayProps { interface TransitionPreviewOverlayProps {
@ -17,12 +22,12 @@ interface TransitionPreviewOverlayProps {
videoRef: React.RefObject<HTMLVideoElement | null>; videoRef: React.RefObject<HTMLVideoElement | null>;
/** Whether the overlay is visible */ /** Whether the overlay is visible */
isActive: boolean; isActive: boolean;
/** Whether the video is currently buffering (used to hide video during load) */ /** Whether the video is currently buffering (used to show spinner) */
isBuffering?: boolean; isBuffering?: boolean;
/** Whether first video frame has been displayed (used to determine if video should be visible during buffering) */
isVideoReady?: boolean;
/** Show loading spinner during buffering (default: false for backward compat) */ /** Show loading spinner during buffering (default: false for backward compat) */
showSpinner?: boolean; showSpinner?: boolean;
/** Loading message for spinner */
spinnerMessage?: string;
/** Letterbox styles from useCanvasScale - positions overlay within canvas bounds */ /** Letterbox styles from useCanvasScale - positions overlay within canvas bounds */
letterboxStyles?: React.CSSProperties; letterboxStyles?: React.CSSProperties;
/** Video object-fit mode (default: 'contain' to match backgrounds) */ /** Video object-fit mode (default: 'contain' to match backgrounds) */
@ -31,45 +36,72 @@ interface TransitionPreviewOverlayProps {
opacity?: number; opacity?: number;
/** Forces video element remount when changed - prevents decoder state issues with pre-created blob URLs */ /** Forces video element remount when changed - prevents decoder state issues with pre-created blob URLs */
videoKey?: string; videoKey?: string;
/** When true, overlay will hide after one paint frame (ensures bg is painted first) */
isFadingOut?: boolean;
/** Fade-out duration in ms - kept for interface compat, not used for video transitions (instant hide) */
fadeOutDuration?: number;
} }
const TransitionPreviewOverlay: React.FC<TransitionPreviewOverlayProps> = ({ const TransitionPreviewOverlay: React.FC<TransitionPreviewOverlayProps> = ({
videoRef, videoRef,
isActive, isActive,
isBuffering = false, isBuffering = false,
isVideoReady = false,
showSpinner = false, showSpinner = false,
spinnerMessage = 'Preparing transition...',
letterboxStyles, letterboxStyles,
videoFit = 'contain', videoFit = 'contain',
opacity, opacity,
videoKey, videoKey,
isFadingOut = false,
// fadeOutDuration - not used for video transitions (instant hide)
}) => { }) => {
// Delay hide by one frame to ensure new background is painted
const [shouldHide, setShouldHide] = useState(false);
useEffect(() => {
if (isFadingOut) {
// Wait one frame to ensure new background is painted
const rafId = requestAnimationFrame(() => {
setShouldHide(true);
});
return () => cancelAnimationFrame(rafId);
} else {
setShouldHide(false);
}
}, [isFadingOut]);
if (!isActive) return null; if (!isActive) return null;
// Container opacity: 0 while buffering to prevent black flash // Container opacity:
// Video first frame = old page background, so we hide everything until ready // - 0 during initial buffering (before first frame displayed)
const containerOpacity = isBuffering ? 0 : (opacity ?? 1); // - 0 when shouldHide (after one-frame delay, new bg is painted)
// - otherwise use provided opacity or 1
const isInitialBuffering = isBuffering && !isVideoReady;
const containerOpacity = isInitialBuffering
? 0
: shouldHide
? 0
: (opacity ?? 1);
// Only use transition for initial buffering fade-in (150ms)
// No transition when hiding - instant hide since last video frame = new bg
const useTransition = isInitialBuffering || (!shouldHide && !isFadingOut);
return ( return (
// Outer: full viewport, transparent background // Outer: full viewport, transparent background
// Transparent ensures if Safari clears video frame when paused, // Transparent ensures if Safari clears video frame when paused,
// the new page background shows through instead of black flash // the new page background shows through instead of black flash
<div className='fixed inset-0 z-50 overflow-hidden pointer-events-none'> <div className='fixed inset-0 z-50 overflow-hidden pointer-events-none'>
{/* Loading spinner during buffering - provides user feedback */} {/* Loading spinner during buffering */}
{isBuffering && showSpinner && ( {isBuffering && showSpinner && (
<CanvasLoadingSpinner <CanvasLoadingSpinner isVisible={true} size='lg' zIndex={60} />
isVisible={true}
message={spinnerMessage}
size='lg'
zIndex={60}
/>
)} )}
{/* Video container - hidden while buffering */} {/* Video container - hidden while buffering or fading out */}
<div <div
style={{ style={{
opacity: containerOpacity, opacity: containerOpacity,
transition: 'opacity 150ms ease-out', transition: useTransition ? 'opacity 150ms ease-out' : 'none',
}} }}
> >
{/* Inner: respects letterbox dimensions when provided */} {/* Inner: respects letterbox dimensions when provided */}

View File

@ -109,7 +109,6 @@ export interface CanvasElementProps {
element: CanvasElement; element: CanvasElement;
isSelected: boolean; isSelected: boolean;
isEditMode: boolean; isEditMode: boolean;
isDisabled?: boolean;
canvasElapsedSec: number; canvasElapsedSec: number;
preloadedIconUrl: boolean; preloadedIconUrl: boolean;
onClick: (element: CanvasElement) => void; onClick: (element: CanvasElement) => void;

View File

@ -1,10 +1,14 @@
/** /**
* PreviousBackgroundOverlay Component * PreviousBackgroundOverlay Component
* *
* Shows the previous page background during page transitions * Shows the previous page background IMAGE during page transitions
* while the new background is loading. * while the new background is loading.
* *
* Used by CanvasBackground component. * Renders when: isSwitching=true AND isNewBgReady=false
* Hides instantly when new background is ready.
*
* Note: Video backgrounds are NOT rendered here. During transitions,
* video is covered by TransitionPreviewOverlay at z-50.
*/ */
import React from 'react'; import React from 'react';
@ -12,54 +16,44 @@ import React from 'react';
interface PreviousBackgroundOverlayProps { interface PreviousBackgroundOverlayProps {
/** Previous background image URL */ /** Previous background image URL */
imageUrl?: string; imageUrl?: string;
/** Previous background video URL */ /** Previous background video URL (kept for interface compatibility, not rendered) */
videoUrl?: string; videoUrl?: string;
/** Whether page is currently switching */ /** Whether page is currently switching */
isSwitching?: boolean; isSwitching?: boolean;
/** Whether new background is ready */ /** Whether new background is ready */
isNewBgReady?: boolean; isNewBgReady?: boolean;
/** Whether to pause video playback (kept for interface compatibility) */
paused?: boolean;
/** Additional CSS classes */ /** Additional CSS classes */
className?: string; className?: string;
/** Fade duration - DEPRECATED, kept for interface compat */
fadeDuration?: number;
} }
const PreviousBackgroundOverlay: React.FC<PreviousBackgroundOverlayProps> = ({ const PreviousBackgroundOverlay: React.FC<PreviousBackgroundOverlayProps> = ({
imageUrl, imageUrl,
videoUrl, // videoUrl - not used, see docstring
isSwitching = false, isSwitching = false,
isNewBgReady = false, isNewBgReady = false,
// paused - not used, see docstring
className = '', className = '',
// fadeDuration - deprecated, not used
}) => { }) => {
// Show previous background during loading (before new bg is ready) // Simple render logic: show while switching AND new bg not ready
const showPreviousBackground = isSwitching && !isNewBgReady; const shouldRender = isSwitching && !isNewBgReady && !!imageUrl;
if (!showPreviousBackground) return null; if (!shouldRender) return null;
return ( return (
<> <div
{/* Previous background image */} className={`pointer-events-none absolute inset-0 z-2 ${className}`}
{imageUrl && ( style={{
<div backgroundImage: `url("${imageUrl}")`,
className={`pointer-events-none absolute inset-0 z-2 ${className}`} backgroundSize: 'contain',
style={{ backgroundPosition: 'center',
backgroundImage: `url("${imageUrl}")`, backgroundRepeat: 'no-repeat',
backgroundSize: 'contain', }}
backgroundPosition: 'center', />
backgroundRepeat: 'no-repeat',
}}
/>
)}
{/* Previous background video */}
{videoUrl && (
<video
className={`absolute inset-0 z-2 h-full w-full object-contain pointer-events-none ${className}`}
src={videoUrl}
autoPlay
loop
muted
playsInline
/>
)}
</>
); );
}; };

View File

@ -15,10 +15,9 @@ import {
hasAnyEffects, hasAnyEffects,
type ElementEffectProperties, type ElementEffectProperties,
} from '../lib/elementEffects'; } from '../lib/elementEffects';
import { isNavigationElementType } from '../lib/elementDefaults';
import { isBackNavigation } from '../lib/navigationHelpers';
import type { CanvasElement } from '../types/constructor'; import type { CanvasElement } from '../types/constructor';
import type { ResolvedTransitionSettings } from '../types/transition'; import type { ResolvedTransitionSettings } from '../types/transition';
import type { PreloadCacheProvider } from '../hooks/video';
interface RuntimeElementProps { interface RuntimeElementProps {
element: CanvasElement; element: CanvasElement;
@ -29,10 +28,10 @@ interface RuntimeElementProps {
onGalleryCardClick?: (cardIndex: number) => void; onGalleryCardClick?: (cardIndex: number) => void;
/** Letterbox styles for constraining fullscreen elements to canvas bounds */ /** Letterbox styles for constraining fullscreen elements to canvas bounds */
letterboxStyles?: React.CSSProperties; letterboxStyles?: React.CSSProperties;
/** Whether forward navigation is disabled (neighbor pages not yet preloaded) */
isForwardNavDisabled?: boolean;
/** Page transition settings (for slide transition cascade in carousel/gallery) */ /** Page transition settings (for slide transition cascade in carousel/gallery) */
pageTransitionSettings?: ResolvedTransitionSettings; pageTransitionSettings?: ResolvedTransitionSettings;
/** Preload cache provider for video elements */
preloadCache?: PreloadCacheProvider;
} }
// Clamp position to canvas bounds (0-100%) // Clamp position to canvas bounds (0-100%)
@ -45,8 +44,8 @@ const RuntimeElement: React.FC<RuntimeElementProps> = ({
resolveUrl, resolveUrl,
onGalleryCardClick, onGalleryCardClick,
letterboxStyles, letterboxStyles,
isForwardNavDisabled = false,
pageTransitionSettings, pageTransitionSettings,
preloadCache,
}) => { }) => {
// Clamp coordinates to canvas bounds // Clamp coordinates to canvas bounds
const xPercent = clamp(element.xPercent ?? 50, 0, 100); const xPercent = clamp(element.xPercent ?? 50, 0, 100);
@ -106,14 +105,6 @@ const RuntimeElement: React.FC<RuntimeElementProps> = ({
positionStyle = { ...positionStyle, ...animationStyle }; positionStyle = { ...positionStyle, ...animationStyle };
} }
// Compute disabled state for navigation elements
// Forward navigation disabled when neighbor pages not preloaded
// Back navigation always enabled (previous pages are already visited)
const isDisabled =
isNavigationElementType(element.type) &&
!isBackNavigation(element) &&
isForwardNavDisabled;
return ( return (
<div <div
className='absolute cursor-pointer' className='absolute cursor-pointer'
@ -127,8 +118,8 @@ const RuntimeElement: React.FC<RuntimeElementProps> = ({
resolveUrl={resolveUrl} resolveUrl={resolveUrl}
onGalleryCardClick={onGalleryCardClick} onGalleryCardClick={onGalleryCardClick}
letterboxStyles={letterboxStyles} letterboxStyles={letterboxStyles}
isDisabled={isDisabled}
pageTransitionSettings={pageTransitionSettings} pageTransitionSettings={pageTransitionSettings}
preloadCache={preloadCache}
/> />
</div> </div>
); );

View File

@ -39,12 +39,12 @@ import {
extractPageLinksOnly, extractPageLinksOnly,
extractElementsForPages, extractElementsForPages,
} from '../lib/extractPageLinks'; } from '../lib/extractPageLinks';
import { usePageSwitch } from '../hooks/usePageSwitch'; import { usePageNavigationState } from '../hooks/usePageNavigationState';
import { useTransitionPlayback } from '../hooks/useTransitionPlayback'; import { useTransitionPlayback } from '../hooks/useTransitionPlayback';
import { useBackgroundTransition } from '../hooks/useBackgroundTransition'; import { useNetworkAware } from '../hooks/useNetworkAware';
import { useBackgroundUrls } from '../hooks/useBackgroundUrls';
import { resolveAssetPlaybackUrl } from '../lib/assetUrl'; import { resolveAssetPlaybackUrl } from '../lib/assetUrl';
import { isSafari, scheduleAfterPaint } from '../lib/browserUtils'; import { downloadManager } from '../lib/offline/DownloadManager';
import { isSafari } from '../lib/browserUtils';
import { logger } from '../lib/logger'; import { logger } from '../lib/logger';
import { import {
resolveNavigationTarget, resolveNavigationTarget,
@ -164,32 +164,23 @@ export default function RuntimePresentation({
designHeight: currentPage?.design_height ?? undefined, designHeight: currentPage?.design_height ?? undefined,
}); });
// Network-aware transitions: skip video on slow networks, use CSS fade instead
const { shouldUseVideoTransitions, networkInfo } = useNetworkAware();
const [transitionPreview, setTransitionPreview] = useState<{ const [transitionPreview, setTransitionPreview] = useState<{
targetPageId: string; targetPageId: string;
videoUrl: string; videoUrl: string;
storageKey: string; storageKey: string;
isBack: boolean; isBack: boolean;
reverseVideoUrl?: string; reverseVideoUrl?: string;
reverseStorageKey?: string;
} | null>(null); } | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);
const [isBackgroundReady, setIsBackgroundReady] = useState(true);
// Track when transition video has completed but we're waiting for background to load
const [pendingTransitionComplete, setPendingTransitionComplete] =
useState(false);
const [activeGalleryCarousel, setActiveGalleryCarousel] = useState<{ const [activeGalleryCarousel, setActiveGalleryCarousel] = useState<{
element: CanvasElement; element: CanvasElement;
initialIndex: number; initialIndex: number;
} | null>(null); } | null>(null);
// Safari Black Flash Prevention (video transitions only):
// Track the last successfully displayed background to use as a "snapshot" layer.
// Only shown during video transitions to prevent black flashes.
const [lastKnownBgUrl, setLastKnownBgUrl] = useState<string>('');
// Track background video buffering state for loading indicator
const [isBackgroundVideoBuffering, setIsBackgroundVideoBuffering] =
useState(false);
const transitionVideoRef = useRef<HTMLVideoElement>(null); const transitionVideoRef = useRef<HTMLVideoElement>(null);
const lastInitializedPageIdRef = useRef<string | null>(null); const lastInitializedPageIdRef = useRef<string | null>(null);
@ -237,6 +228,9 @@ export default function RuntimePresentation({
}, [pages, pageLinks, selectedPageId]); }, [pages, pageLinks, selectedPageId]);
// Initialize preload orchestrator with transformed data // Initialize preload orchestrator with transformed data
// STREAM-FIRST: Preloads current page + transition videos only
// Online: Videos stream on-demand, cache after playback (no bandwidth competition)
// Offline: Assets already fully downloaded via useOfflineMode.startDownload()
const preloadOrchestrator = usePreloadOrchestrator({ const preloadOrchestrator = usePreloadOrchestrator({
pages, pages,
pageLinks, pageLinks,
@ -246,8 +240,15 @@ export default function RuntimePresentation({
enabled: !isLoading && !error, enabled: !isLoading && !error,
}); });
// Initialize page switch hook for smooth background transitions // Selected page - moved early for easier access
const pageSwitch = usePageSwitch({ const selectedPage = useMemo(
() => pages.find((p) => p.id === selectedPageId) || null,
[pages, selectedPageId],
);
// Unified page navigation state machine (replaces 6+ separate hooks)
// Uses useReducer for atomic state transitions, preventing race conditions
const navState = usePageNavigationState({
preloadCache: preloadOrchestrator preloadCache: preloadOrchestrator
? { ? {
getReadyBlobUrl: preloadOrchestrator.getReadyBlobUrl, getReadyBlobUrl: preloadOrchestrator.getReadyBlobUrl,
@ -255,10 +256,37 @@ export default function RuntimePresentation({
preloadedUrls: preloadOrchestrator.preloadedUrls, preloadedUrls: preloadOrchestrator.preloadedUrls,
} }
: undefined, : undefined,
transitionSettings,
}); });
// Destructure for convenience (matches previous hook interfaces)
// showElements/showSpinner are derived from the unified state machine phase:
// - showElements: true when phase is 'idle' or 'fading_in'
// - showSpinner: true when phase is 'preparing', 'loading_bg', or 'transition_done'
const {
currentImageUrl: navCurrentBgImageUrl,
currentVideoUrl: navCurrentBgVideoUrl,
previousImageUrl: navPreviousBgImageUrl,
previousVideoUrl: navPreviousBgVideoUrl,
isSwitching: navIsSwitching,
isNewBgReady: navIsNewBgReady,
pendingTransitionComplete,
isFadingIn,
showElements: navShowElements,
showSpinner: navShowSpinner,
showTransitionVideo,
transitionStyle,
lastKnownBgUrl,
onBackgroundReady: navOnBackgroundReady,
onVideoBufferStateChange,
onTransitionEnded,
navigateToPage: navNavigateToPage,
resetToIdle: navResetToIdle,
startTransition,
} = navState;
// Integrate useTransitionPlayback hook for smooth transitions (matches Constructor pattern) // Integrate useTransitionPlayback hook for smooth transitions (matches Constructor pattern)
const { isBuffering, phase: transitionPhase } = useTransitionPlayback({ const { isBuffering, isVideoReady, phase: transitionPhase } = useTransitionPlayback({
videoRef: transitionVideoRef, videoRef: transitionVideoRef,
transition: transitionPreview transition: transitionPreview
? { ? {
@ -266,33 +294,39 @@ export default function RuntimePresentation({
storageKey: transitionPreview.storageKey, storageKey: transitionPreview.storageKey,
reverseMode: transitionPreview.reverseVideoUrl ? 'separate' : 'none', reverseMode: transitionPreview.reverseVideoUrl ? 'separate' : 'none',
reverseVideoUrl: transitionPreview.reverseVideoUrl, reverseVideoUrl: transitionPreview.reverseVideoUrl,
reverseStorageKey: transitionPreview.reverseStorageKey, // Raw path for cache lookup
targetPageId: transitionPreview.targetPageId, targetPageId: transitionPreview.targetPageId,
displayName: 'Transition', displayName: 'Transition',
isBack: transitionPreview.isBack, isBack: transitionPreview.isBack,
} }
: null, : null,
onComplete: async (targetPageId, isBack) => { onComplete: async (targetPageId, isBack) => {
// Resume background downloads now that transition is complete
downloadManager.resumeAll();
if (targetPageId) { if (targetPageId) {
const targetPage = pages.find((p) => p.id === targetPageId); const targetPage = pages.find((p) => p.id === targetPageId);
// Mark this page as initialized to prevent redundant effect calls // Mark this page as initialized to prevent redundant effect calls
lastInitializedPageIdRef.current = targetPageId; lastInitializedPageIdRef.current = targetPageId;
// Use shared hook to resolve blob URLs and switch page // Signal that transition video has ended
await pageSwitch.switchToPage(targetPage, () => { // State machine transitions to 'transition_done', waiting for background
// Use applyPageSelection for proper history management (pops on back) onTransitionEnded();
applyPageSelection(targetPageId, isBack ?? false); // DON'T close transitionPreview here - it stays visible until background is ready
// The useEffect below will close it when pendingTransitionComplete becomes false
// Navigate to target page - state machine handles ready state
await navNavigateToPage(targetPage, {
hasTransition: false, // Already played
isBack: isBack ?? false,
onSwitched: () => {
applyPageSelection(targetPageId, isBack ?? false);
},
}); });
setIsBackgroundReady(false);
// Video transition completed - last frame shows new page background
// Signal that we're waiting for background to load before removing overlay
// Overlay will be removed instantly (no fade) when isBackgroundReady becomes true
setPendingTransitionComplete(true);
} else { } else {
// No target page - clean up and remove overlay // No target page - clean up and remove overlay
const video = transitionVideoRef.current; const video = transitionVideoRef.current;
video?.removeAttribute('src'); video?.removeAttribute('src');
video?.load(); video?.load();
setTransitionPreview(null); setTransitionPreview(null);
setPendingTransitionComplete(false); navResetToIdle();
} }
}, },
features: { features: {
@ -309,18 +343,26 @@ export default function RuntimePresentation({
}, },
}); });
// Use shared background transition hook for fade-from-black effects // Sync transition video buffering state with navigation state machine
// Video transitions end instantly (last frame = new page, then overlay removed). // This enables unified showSpinner logic in the state machine
// fadeIn controls the black overlay for non-video navigation. useEffect(() => {
// hasActiveTransition prevents fade during video-to-background handoff. const isTransitionBuffering = Boolean(transitionPreview) && isBuffering;
const { isFadingIn, resetFadeIn, transitionStyle } = useBackgroundTransition({ onVideoBufferStateChange(isTransitionBuffering);
pageSwitch, }, [transitionPreview, isBuffering, onVideoBufferStateChange]);
fadeIn: {
hasActiveTransition: // Clean up transition preview when state machine says video overlay should be hidden
Boolean(transitionPreview) || pendingTransitionComplete, // showTransitionVideo is true during 'transitioning', 'transition_done', and 'fading_in' phases
}, // During 'fading_in', the overlay fades out (isFadingOut=true), then removed when phase goes to 'idle'
transitionSettings, useEffect(() => {
}); if (transitionPreview && !showTransitionVideo) {
setTransitionPreview(null);
}
}, [transitionPreview, showTransitionVideo]);
// Reset navigation state when starting a new transition
const resetFadeIn = useCallback(() => {
navResetToIdle();
}, [navResetToIdle]);
const toggleFullscreen = useCallback(async () => { const toggleFullscreen = useCallback(async () => {
try { try {
@ -349,11 +391,6 @@ export default function RuntimePresentation({
document.removeEventListener('fullscreenchange', handleFullscreenChange); document.removeEventListener('fullscreenchange', handleFullscreenChange);
}, []); }, []);
const selectedPage = useMemo(
() => pages.find((p) => p.id === selectedPageId) || null,
[pages, selectedPageId],
);
const pageElements = useMemo(() => { const pageElements = useMemo(() => {
if (!selectedPage) return []; if (!selectedPage) return [];
@ -375,84 +412,27 @@ export default function RuntimePresentation({
useEffect(() => { useEffect(() => {
if (selectedPage && lastInitializedPageIdRef.current !== selectedPage.id) { if (selectedPage && lastInitializedPageIdRef.current !== selectedPage.id) {
// Only initialize when backgrounds are empty (initial load) // Only initialize when backgrounds are empty (initial load)
// navigateToPage handles subsequent navigation by calling switchToPage directly if (!navCurrentBgImageUrl && !navCurrentBgVideoUrl) {
if (!pageSwitch.currentBgImageUrl && !pageSwitch.currentBgVideoUrl) {
lastInitializedPageIdRef.current = selectedPage.id; lastInitializedPageIdRef.current = selectedPage.id;
pageSwitch.switchToPage(selectedPage); navNavigateToPage(selectedPage);
} }
} }
}, [ }, [
selectedPage, selectedPage,
pageSwitch.currentBgImageUrl, navCurrentBgImageUrl,
pageSwitch.currentBgVideoUrl, navCurrentBgVideoUrl,
pageSwitch.switchToPage, navNavigateToPage,
]); ]);
// Handle background ready state for pages without any background // Video transition overlay removal - clears when elements should show
// When phase becomes 'idle' or 'fading_in' (navShowElements=true),
// the transition preview is no longer needed and can be cleared
useEffect(() => { useEffect(() => {
// Only mark ready immediately if there's no background media at all. if (navShowElements && transitionPreview) {
// For pages with image or video, CanvasBackground will call onBackgroundReady // Clear transition preview - overlay will be removed
// after the media's first frame is painted (using scheduleAfterPaint or requestVideoFrameCallback). setTransitionPreview(null);
if (
!selectedPage?.background_image_url &&
!selectedPage?.background_video_url
) {
setIsBackgroundReady(true);
} }
}, [selectedPage?.background_image_url, selectedPage?.background_video_url]); }, [navShowElements, transitionPreview]);
// Video transition overlay removal - instant (no fade) when background is ready
// Uses scheduleAfterPaint to ensure browser has painted the new background before removing overlay
// Note: Double RAF doesn't work in Safari (nested RAFs can execute in same frame)
// CRITICAL: Must wait for BOTH isBackgroundReady AND pageSwitch.isNewBgReady
// - isBackgroundReady: RuntimePresentation state (image onLoad or immediate for video pages)
// - pageSwitch.isNewBgReady: Controls PreviousBackgroundOverlay visibility
// If we only check isBackgroundReady, PreviousBackgroundOverlay may still be showing
useEffect(() => {
if (
pendingTransitionComplete &&
isBackgroundReady &&
pageSwitch.isNewBgReady
) {
// Wait for paint cycle to complete before removing overlay
// scheduleAfterPaint handles Safari's RAF quirks automatically
scheduleAfterPaint(() => {
// CRITICAL: Remove overlay from DOM FIRST, then clear video src
// If we clear src before removing overlay, Safari shows black frame
// because video.removeAttribute('src') immediately clears the frame
setTransitionPreview(null);
setPendingTransitionComplete(false);
// Clear previous background now that transition is complete
// This resets isSwitching state for next navigation
pageSwitch.clearPreviousBackground();
// Clear video src AFTER overlay is removed from DOM
// Use another scheduleAfterPaint to ensure React has unmounted the overlay
scheduleAfterPaint(() => {
const video = transitionVideoRef.current;
if (video) {
video.removeAttribute('src');
video.load();
}
});
});
}
}, [
pendingTransitionComplete,
isBackgroundReady,
pageSwitch.isNewBgReady,
pageSwitch.clearPreviousBackground,
]);
// Safari Black Flash Prevention (video transitions only):
// Update lastKnownBgUrl whenever we have a valid background image.
// This ensures snapshot is always ready before transitions start.
useEffect(() => {
if (pageSwitch.currentBgImageUrl) {
setLastKnownBgUrl(pageSwitch.currentBgImageUrl);
}
}, [pageSwitch.currentBgImageUrl]);
const navigateToPage = useCallback( const navigateToPage = useCallback(
async ( async (
@ -464,10 +444,22 @@ export default function RuntimePresentation({
const targetPage = pages.find((p) => p.id === targetPageId); const targetPage = pages.find((p) => p.id === targetPageId);
if (!targetPage) return; if (!targetPage) return;
if (transitionVideoUrl) { // Check if video is already cached (use video even on slow network if cached)
const isTransitionCached =
transitionVideoUrl && preloadOrchestrator?.getReadyBlobUrl(transitionVideoUrl);
// Use video if: has transition AND (cached OR good network)
const useVideoTransition =
transitionVideoUrl && (isTransitionCached || shouldUseVideoTransitions);
if (useVideoTransition) {
// Reset states from previous transition/navigation // Reset states from previous transition/navigation
resetFadeIn(); resetFadeIn();
setPendingTransitionComplete(false); // Pause background downloads to give transition video exclusive bandwidth
downloadManager.pauseAll();
// Signal navigation state machine that video transition is starting
// This sets phase to 'transitioning' so spinner shows during buffering
startTransition(targetPageId, isBack);
// Play transition using useTransitionPlayback hook // Play transition using useTransitionPlayback hook
setTransitionPreview({ setTransitionPreview({
targetPageId, targetPageId,
@ -477,36 +469,64 @@ export default function RuntimePresentation({
reverseVideoUrl: reverseVideoUrl reverseVideoUrl: reverseVideoUrl
? resolveAssetPlaybackUrl(reverseVideoUrl) ? resolveAssetPlaybackUrl(reverseVideoUrl)
: undefined, : undefined,
reverseStorageKey: reverseVideoUrl, // Raw storage path for reverse video cache lookup
}); });
} else { } else {
// Direct navigation with fade-from-black effect: // Direct navigation with fade-from-black effect:
// Page switches instantly, black overlay fades out to reveal new page // Page switches instantly, black overlay fades out to reveal new page
setIsBackgroundReady(false);
// Mark this page as initialized to prevent redundant effect calls // Mark this page as initialized to prevent redundant effect calls
lastInitializedPageIdRef.current = targetPageId; lastInitializedPageIdRef.current = targetPageId;
await pageSwitch.switchToPage(targetPage, () => { // Log when skipping video due to slow network
// Use applyPageSelection for proper history management (pops on back) if (transitionVideoUrl && !shouldUseVideoTransitions) {
applyPageSelection(targetPageId, isBack); logger.info(
'[NAVIGATION] Skipping video transition due to slow network, downloading in background',
{
effectiveType: networkInfo.effectiveType,
downlink: networkInfo.downlink,
rtt: networkInfo.rtt,
},
);
// Start background download of transition video for future use (low priority)
downloadManager.addJob({
assetId: `transition-bg-${transitionVideoUrl}`,
projectId: 'transition-preload',
url: resolveAssetPlaybackUrl(transitionVideoUrl),
filename: transitionVideoUrl.split('/').pop() || 'transition.mp4',
variantType: 'original',
assetType: 'video',
priority: 10, // Low priority - background preload
storageKey: transitionVideoUrl,
});
}
await navNavigateToPage(targetPage, {
hasTransition: false,
isBack,
onSwitched: () => {
applyPageSelection(targetPageId, isBack);
},
}); });
} }
}, },
[pages, pageSwitch, resetFadeIn, applyPageSelection], [
pages,
navNavigateToPage,
resetFadeIn,
applyPageSelection,
startTransition,
shouldUseVideoTransitions,
networkInfo,
preloadOrchestrator,
],
); );
// Compute whether all neighbor backgrounds are ready for instant navigation // Page loading state from unified navigation state machine
const areNeighborBackgroundsReady = // navShowSpinner: true when phase is 'preparing', 'loading_bg', or 'transition_done'
preloadOrchestrator?.areNeighborBackgroundsReady ?? true; // navShowElements: true when phase is 'idle' or 'fading_in'
const areTransitionsReady = preloadOrchestrator?.areTransitionsReady ?? true;
// Compute page loading state for UI feedback
const isPageLoading =
preloadOrchestrator?.currentPhase === 'phase1_current_page';
const areTransitionsReady =
preloadOrchestrator?.areTransitionsReady ?? true;
// Compute disabled state for forward navigation elements
// DISABLED: Allow navigation even if neighbors not preloaded
const isForwardNavDisabled = false && !areNeighborBackgroundsReady;
const handleElementClick = useCallback( const handleElementClick = useCallback(
(element: CanvasElement) => { (element: CanvasElement) => {
@ -517,18 +537,6 @@ export default function RuntimePresentation({
return; return;
} }
// DISABLED: Block forward navigation if neighbor backgrounds not yet preloaded
// Back navigation is always allowed (previous pages are already visited)
if (
false &&
isNavigationType(element.type) &&
!isBackNavigation(element) &&
!areNeighborBackgroundsReady
) {
logger.info('Navigation blocked - neighbors not preloaded');
return;
}
// Get navigation context from hook for history-based back navigation // Get navigation context from hook for history-based back navigation
const navContext = getNavigationContext(); const navContext = getNavigationContext();
@ -577,7 +585,6 @@ export default function RuntimePresentation({
transitionPhase, transitionPhase,
isBuffering, isBuffering,
getNavigationContext, getNavigationContext,
areNeighborBackgroundsReady,
setCurrentElementTransitionSettings, setCurrentElementTransitionSettings,
], ],
); );
@ -610,15 +617,9 @@ export default function RuntimePresentation({
[preloadOrchestrator], [preloadOrchestrator],
); );
// Unified background URL resolution via shared hook (same as constructor) // Background URLs come directly from navigation state (already resolved)
// No localPaths needed since RuntimePresentation has no editing mode const backgroundImageUrl = navCurrentBgImageUrl;
const { const backgroundVideoUrl = navCurrentBgVideoUrl;
backgroundImageSrc: backgroundImageUrl,
backgroundVideoSrc: backgroundVideoUrl,
} = useBackgroundUrls({
pageSwitch,
resolveUrl: resolveUrlWithBlob,
});
// Background video playback settings from selected page // Background video playback settings from selected page
const videoAutoplay = selectedPage?.background_video_autoplay ?? true; const videoAutoplay = selectedPage?.background_video_autoplay ?? true;
@ -772,57 +773,68 @@ export default function RuntimePresentation({
<CanvasBackground <CanvasBackground
backgroundImageUrl={backgroundImageUrl} backgroundImageUrl={backgroundImageUrl}
backgroundVideoUrl={backgroundVideoUrl} backgroundVideoUrl={backgroundVideoUrl}
previousBgImageUrl={pageSwitch.previousBgImageUrl} previousBgImageUrl={navPreviousBgImageUrl}
previousBgVideoUrl={pageSwitch.previousBgVideoUrl} previousBgVideoUrl={navPreviousBgVideoUrl}
isSwitching={pageSwitch.isSwitching} isSwitching={navIsSwitching}
isNewBgReady={pageSwitch.isNewBgReady} isNewBgReady={navIsNewBgReady}
onBackgroundReady={() => { onBackgroundReady={navOnBackgroundReady}
setIsBackgroundReady(true); onVideoBufferStateChange={onVideoBufferStateChange}
pageSwitch.markBackgroundReady();
}}
onVideoBufferStateChange={setIsBackgroundVideoBuffering}
videoAutoplay={videoAutoplay} videoAutoplay={videoAutoplay}
videoLoop={videoLoop} videoLoop={videoLoop}
videoMuted={soundControl.isMuted} videoMuted={soundControl.isMuted}
videoStartTime={videoStartTime} videoStartTime={videoStartTime}
videoEndTime={videoEndTime} videoEndTime={videoEndTime}
videoStoragePath={selectedPage?.background_video_url} videoStoragePath={selectedPage?.background_video_url}
pauseVideo={
Boolean(transitionPreview) ||
pendingTransitionComplete ||
navIsSwitching
}
/> />
</div> </div>
{/* End page background wrapper */} {/* End page background wrapper */}
{/* Page loading spinner - shows during Phase 1 (current page loading) */} {/* Page loading spinner - from unified navigation state machine.
{isPageLoading && ( navShowSpinner is true when:
<CanvasLoadingSpinner - Phase is 'preparing', 'loading_bg', 'transition_done', OR
isVisible={true} - Video transition is active but buffering
message='Loading page...' Skip when video transition overlay is active - it has its own spinner. */}
progress={preloadOrchestrator?.phaseProgress} {navShowSpinner && !transitionPreview && (
zIndex={100} <CanvasLoadingSpinner isVisible={true} zIndex={100} />
/>
)} )}
{/* Page elements wrapper - z-[46] keeps it ABOVE carousel slide (z-10) AND carousel controls (z-45). {/* Page elements wrapper - z-[46] keeps it ABOVE carousel slide (z-10) AND carousel controls (z-45).
UI controls (z-50) remain on top. UI controls (z-50) remain on top.
No fade animation - elements switch instantly behind the black overlay. */} No fade animation - elements switch instantly behind the black overlay.
<div Shows when phase is 'idle' or 'fading_in' (navShowElements). */}
data-testid='page-elements-wrapper' {navShowElements && (
className='absolute inset-0 z-[46]' <div
> data-testid='page-elements-wrapper'
{pageElements.map((element: CanvasElement) => ( className='absolute inset-0 z-[46]'
<RuntimeElement >
key={element.id} {pageElements.map((element: CanvasElement) => (
element={element} <RuntimeElement
onClick={() => handleElementClick(element)} key={element.id}
resolveUrl={resolveUrlWithBlob} element={element}
onGalleryCardClick={(cardIndex) => onClick={() => handleElementClick(element)}
handleGalleryCardClick(element, cardIndex) resolveUrl={resolveUrlWithBlob}
} onGalleryCardClick={(cardIndex) =>
letterboxStyles={letterboxStyles} handleGalleryCardClick(element, cardIndex)
isForwardNavDisabled={isForwardNavDisabled} }
pageTransitionSettings={transitionSettings} letterboxStyles={letterboxStyles}
/> pageTransitionSettings={transitionSettings}
))} preloadCache={
</div> preloadOrchestrator
? {
getReadyBlob: preloadOrchestrator.getReadyBlob,
getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl,
}
: undefined
}
/>
))}
</div>
)}
{/* End page elements wrapper */} {/* End page elements wrapper */}
{/* Overlay for fade-through-color transition - z-[100] is ABOVE elements (z-[46]). {/* Overlay for fade-through-color transition - z-[100] is ABOVE elements (z-[46]).
@ -837,26 +849,27 @@ export default function RuntimePresentation({
{/* Transition overlay - uses useTransitionPlayback hook for smooth transitions */} {/* Transition overlay - uses useTransitionPlayback hook for smooth transitions */}
{/* Opacity is 0 during 'preparing' phase to show old page while video loads */} {/* Opacity is 0 during 'preparing' phase to show old page while video loads */}
{/* NO fade-out: video itself IS the transition (last frame = new page) */} {/* Fades out during 'fading_in' phase when background is ready */}
{/* Overlay removed instantly via setTransitionPreview(null) in onComplete */} {/* Overlay stays visible until fade completes (phase goes to 'idle') */}
{transitionPreview && ( {transitionPreview && showTransitionVideo && (
<TransitionPreviewOverlay <TransitionPreviewOverlay
videoKey={transitionPreview.videoUrl} videoKey={transitionPreview.videoUrl}
videoRef={transitionVideoRef} videoRef={transitionVideoRef}
isActive={true} isActive={true}
isBuffering={ isBuffering={
// Hide overlay until video first frame is painted: // Show spinner during buffering:
// - 'idle': React render cycle before hook effect runs // - 'idle': React render cycle before hook effect runs
// - 'preparing': Video loading/buffering // - 'preparing': Video loading/buffering
// - isBuffering: Waiting for first frame paint (from hook) // - isBuffering: Waiting for first frame or mid-playback buffering
transitionPhase === 'idle' || transitionPhase === 'idle' ||
transitionPhase === 'preparing' || transitionPhase === 'preparing' ||
isBuffering isBuffering
} }
isVideoReady={isVideoReady}
showSpinner={true} showSpinner={true}
spinnerMessage='Preparing transition...'
letterboxStyles={letterboxStyles} letterboxStyles={letterboxStyles}
opacity={1} isFadingOut={isFadingIn}
fadeOutDuration={transitionSettings.durationMs}
/> />
)} )}

View File

@ -19,7 +19,7 @@ interface TransitionBlackOverlayProps {
isFadingIn: boolean; isFadingIn: boolean;
/** Transition type - only renders overlay for 'fade' type */ /** Transition type - only renders overlay for 'fade' type */
transitionType?: TransitionType; transitionType?: TransitionType;
/** Inline styles for transition duration/easing (from useBackgroundTransition) */ /** Inline styles for transition duration/easing (from usePageNavigationState) */
transitionStyle?: React.CSSProperties; transitionStyle?: React.CSSProperties;
/** Overlay color (default: #000000) */ /** Overlay color (default: #000000) */
overlayColor?: string; overlayColor?: string;

View File

@ -11,6 +11,7 @@
import React from 'react'; import React from 'react';
import type { CanvasElement } from '../../types/constructor'; import type { CanvasElement } from '../../types/constructor';
import type { ResolvedTransitionSettings } from '../../types/transition'; import type { ResolvedTransitionSettings } from '../../types/transition';
import type { PreloadCacheProvider } from '../../hooks/video';
import { useElementWrapperStyle } from './shared/useElementWrapperStyle'; import { useElementWrapperStyle } from './shared/useElementWrapperStyle';
import { import {
isNavigationElementType, isNavigationElementType,
@ -43,7 +44,6 @@ export interface UiElementRendererProps {
// Constructor-specific props (optional) // Constructor-specific props (optional)
isSelected?: boolean; isSelected?: boolean;
isEditMode?: boolean; isEditMode?: boolean;
isDisabled?: boolean;
// Gallery carousel callback // Gallery carousel callback
onGalleryCardClick?: (cardIndex: number) => void; onGalleryCardClick?: (cardIndex: number) => void;
// Carousel-specific callback for button position changes (constructor only) // Carousel-specific callback for button position changes (constructor only)
@ -56,6 +56,8 @@ export interface UiElementRendererProps {
letterboxStyles?: React.CSSProperties; letterboxStyles?: React.CSSProperties;
// Page transition settings (for slide transition cascade in carousel/gallery) // Page transition settings (for slide transition cascade in carousel/gallery)
pageTransitionSettings?: ResolvedTransitionSettings; pageTransitionSettings?: ResolvedTransitionSettings;
// Preload cache provider for video elements
preloadCache?: PreloadCacheProvider;
} }
/** /**
@ -69,17 +71,16 @@ export const UiElementRenderer: React.FC<UiElementRendererProps> = ({
resolveUrl, resolveUrl,
isSelected = false, isSelected = false,
isEditMode = false, isEditMode = false,
isDisabled = false,
onGalleryCardClick, onGalleryCardClick,
onCarouselButtonPositionChange, onCarouselButtonPositionChange,
letterboxStyles, letterboxStyles,
pageTransitionSettings, pageTransitionSettings,
preloadCache,
}) => { }) => {
const { className, style } = useElementWrapperStyle({ const { className, style } = useElementWrapperStyle({
element, element,
isSelected, isSelected,
isEditMode, isEditMode,
isDisabled,
}); });
// Common props for all element types // Common props for all element types
@ -110,7 +111,7 @@ export const UiElementRenderer: React.FC<UiElementRendererProps> = ({
); );
} }
if (isVideoPlayerElementType(element.type)) { if (isVideoPlayerElementType(element.type)) {
return <VideoPlayerElement {...commonProps} />; return <VideoPlayerElement {...commonProps} preloadCache={preloadCache} />;
} }
if (isAudioPlayerElementType(element.type)) { if (isAudioPlayerElementType(element.type)) {
return <AudioPlayerElement {...commonProps} />; return <AudioPlayerElement {...commonProps} />;

View File

@ -2,28 +2,43 @@
* VideoPlayerElement Component * VideoPlayerElement Component
* *
* Video player element with controls. * Video player element with controls.
* Renders with unified wrapper styling + content. * Uses unified video hook for consistent behavior:
* - Multi-tier URL resolution (blob cached presigned proxy)
* - Safari decode error recovery
* - Buffering state indicator
*/ */
import React from 'react'; import React from 'react';
import type { CSSProperties } from 'react'; import type { CSSProperties } from 'react';
import type { CanvasElement } from '../../../types/constructor'; import type { CanvasElement } from '../../../types/constructor';
import { resolveAssetPlaybackUrl } from '../../../lib/assetUrl'; import type { PreloadCacheProvider } from '../../../hooks/video';
import { useVideoPlayer } from '../../../hooks/video';
interface VideoPlayerElementProps { interface VideoPlayerElementProps {
element: CanvasElement; element: CanvasElement;
resolveUrl?: (url: string | undefined) => string; preloadCache?: PreloadCacheProvider;
className: string; className: string;
style: CSSProperties; style: CSSProperties;
} }
const VideoPlayerElement: React.FC<VideoPlayerElementProps> = ({ const VideoPlayerElement: React.FC<VideoPlayerElementProps> = ({
element, element,
resolveUrl, preloadCache,
className, className,
style, style,
}) => { }) => {
const resolve = resolveUrl ?? resolveAssetPlaybackUrl; const {
videoRef,
resolvedUrl,
isBuffering,
} = useVideoPlayer({
sourceUrl: element.mediaUrl,
preloadCache,
autoplay: Boolean(element.mediaAutoplay),
loop: Boolean(element.mediaLoop),
muted: Boolean(element.mediaMuted),
trackBuffering: true,
});
if (!element.mediaUrl) { if (!element.mediaUrl) {
return ( return (
@ -35,9 +50,16 @@ const VideoPlayerElement: React.FC<VideoPlayerElementProps> = ({
return ( return (
<div className={className} style={style}> <div className={className} style={style}>
{/* Loading spinner during buffering */}
{isBuffering && (
<div className='absolute inset-0 flex items-center justify-center bg-black/20 rounded z-10'>
<div className='w-8 h-8 border-2 border-white/30 border-t-white rounded-full animate-spin' />
</div>
)}
<video <video
className='w-full h-full object-cover rounded' ref={videoRef}
src={resolve(element.mediaUrl)} className={`w-full h-full object-cover rounded ${isBuffering ? 'opacity-70' : ''}`}
src={resolvedUrl || ''}
controls controls
autoPlay={Boolean(element.mediaAutoplay)} autoPlay={Boolean(element.mediaAutoplay)}
loop={Boolean(element.mediaLoop)} loop={Boolean(element.mediaLoop)}

View File

@ -27,8 +27,6 @@ interface UseElementWrapperStyleOptions {
isSelected?: boolean; isSelected?: boolean;
/** Constructor-specific: show edit mode styling */ /** Constructor-specific: show edit mode styling */
isEditMode?: boolean; isEditMode?: boolean;
/** Constructor-specific: show disabled styling */
isDisabled?: boolean;
} }
interface ElementWrapperStyle { interface ElementWrapperStyle {
@ -47,7 +45,6 @@ export function useElementWrapperStyle({
element, element,
isSelected = false, isSelected = false,
isEditMode = false, isEditMode = false,
isDisabled = false,
}: UseElementWrapperStyleOptions): ElementWrapperStyle { }: UseElementWrapperStyleOptions): ElementWrapperStyle {
return useMemo(() => { return useMemo(() => {
// Determine element characteristics // Determine element characteristics
@ -100,11 +97,7 @@ export function useElementWrapperStyle({
// Flex centering for navigation elements (both icons and text) // Flex centering for navigation elements (both icons and text)
isNavigationElement ? 'flex items-center justify-center' : '', isNavigationElement ? 'flex items-center justify-center' : '',
// Constructor-specific states (only applied when in constructor) // Constructor-specific states (only applied when in constructor)
isEditMode isEditMode ? 'cursor-move' : 'cursor-pointer',
? 'cursor-move'
: isDisabled
? 'cursor-not-allowed opacity-50'
: 'cursor-pointer',
] ]
.filter(Boolean) .filter(Boolean)
.join(' '); .join(' ');
@ -116,5 +109,5 @@ export function useElementWrapperStyle({
className: classNames, className: classNames,
style: { ...paddingStyle, ...inlineStyle }, style: { ...paddingStyle, ...inlineStyle },
}; };
}, [element, isSelected, isEditMode, isDisabled]); }, [element, isSelected, isEditMode]);
} }

View File

@ -1,27 +1,22 @@
/** /**
* Preload Configuration * Preload Configuration
*
* Centralized configuration for asset preloading, priority weights, and queue settings.
*/ */
export const PRELOAD_CONFIG = { export const PRELOAD_CONFIG = {
// Queue settings
maxConcurrentDownloads: 3, maxConcurrentDownloads: 3,
maxRetries: 3, maxRetries: 3,
retryDelayMs: 1000, retryDelayMs: 1000,
// Size thresholds largeFileThreshold: 5 * 1024 * 1024,
largeFileThreshold: 5 * 1024 * 1024, // 5MB -> use IndexedDB videoChunkSize: 5 * 1024 * 1024,
videoChunkSize: 5 * 1024 * 1024, // 5MB chunks
initialVideoBufferSeconds: 5, initialVideoBufferSeconds: 5,
// Priority weights (higher = load first)
priority: { priority: {
currentPage: 1000, currentPage: 1000,
neighborBase: 500, neighborBase: 500,
assetType: { assetType: {
transition: 150, // Transitions preloaded for faster start transition: 150,
image: 100, // Backgrounds load first image: 100,
audio: 50, audio: 50,
video: 30, video: 30,
} as Record<string, number>, } as Record<string, number>,
@ -37,57 +32,39 @@ export const PRELOAD_CONFIG = {
maxLinkBonus: 50, maxLinkBonus: 50,
}, },
// Storage
storage: { storage: {
warningPercent: 80, warningPercent: 80,
criticalPercent: 95, criticalPercent: 95,
minFreeBuffer: 50 * 1024 * 1024, // 50MB minFreeBuffer: 50 * 1024 * 1024,
}, },
// Auto-cleanup timeouts (from hoboken pattern)
autoRemove: { autoRemove: {
completedMs: 3000, completedMs: 3000,
errorMs: 10000, errorMs: 10000,
}, },
// Neighbor graph traversal
neighborGraph: {
maxDepth: 1, // Only preload immediate neighbors (depth 2 was causing too many requests)
constructorMaxDepth: 1, // Same as maxDepth for constructor preview
},
// 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)
partialPreload: { partialPreload: {
enabled: true, enabled: true,
videoMaxBytes: 5 * 1024 * 1024, // 5MB (~5 seconds of video) videoMaxBytes: 5 * 1024 * 1024,
audioMaxBytes: 512 * 1024, // 512KB (~5 seconds of audio) audioMaxBytes: 512 * 1024,
transitionMaxBytes: 3 * 1024 * 1024, // 3MB (~3 seconds of transition video) transitionMaxBytes: 3 * 1024 * 1024,
}, },
// Streaming mode settings (for progressive playback)
// When enabled, presigned URL is used immediately for playback
// Streaming ready event fires after minimum buffer downloaded
// Full download continues in background for caching
streaming: { streaming: {
enabled: true, // Enable streaming for faster first playback enabled: true,
minBufferBytes: 3 * 1024 * 1024, // 3MB minimum before signaling ready minBufferBytes: 3 * 1024 * 1024,
videoBufferTarget: 5, // seconds of video to buffer before playback videoBufferTarget: 5,
audioBufferTarget: 3, // seconds of audio to buffer before playback audioBufferTarget: 3,
mobile: { mobile: {
minBufferBytes: 2 * 1024 * 1024, // 2MB on mobile (lower memory) minBufferBytes: 2 * 1024 * 1024,
}, },
}, },
// UI feedback settings
ui: { ui: {
spinnerDelayMs: 500, // Delay before showing loading spinner (avoids flash) spinnerDelayMs: 500,
}, },
// Asset URL field names in element content_json (camelCase)
assetFields: { assetFields: {
// All asset URL fields for preloading extraction
all: [ all: [
'iconUrl', 'iconUrl',
'imageUrl', 'imageUrl',
@ -108,7 +85,6 @@ export const PRELOAD_CONFIG = {
'poster', 'poster',
'thumbnail', 'thumbnail',
] as const, ] as const,
// Image-only fields for decode before page switch
images: [ images: [
'iconUrl', 'iconUrl',
'imageUrl', 'imageUrl',
@ -121,9 +97,7 @@ export const PRELOAD_CONFIG = {
'galleryCarouselBackIconUrl', 'galleryCarouselBackIconUrl',
'src', 'src',
] as const, ] as const,
// Nested array fields containing assets
nested: ['galleryCards', 'carouselSlides', 'galleryInfoSpans'] as const, nested: ['galleryCards', 'carouselSlides', 'galleryInfoSpans'] as const,
// Fields within nested items that contain URLs
nestedUrlFields: ['imageUrl', 'videoUrl', 'iconUrl'] as const, nestedUrlFields: ['imageUrl', 'videoUrl', 'iconUrl'] as const,
}, },
} as const; } as const;

View File

@ -1,54 +1,34 @@
/** /**
* Transition Playback Configuration * Transition Playback Configuration
*
* Centralized configuration for transition video playback timing.
* These values control when the transition finishes relative to the video end
* to prevent black frames while maximizing video playback time.
*/ */
export const TRANSITION_CONFIG = { export const TRANSITION_CONFIG = {
/**
* Duration timer offset (milliseconds before video end)
* The video playback finishes this many ms before the actual video end.
* Used as a fallback when other detection methods fail.
*/
finishBeforeEndMs: { finishBeforeEndMs: {
safari: 100, safari: 100,
firefox: 100, firefox: 100,
default: 100, default: 100,
}, },
/**
* TimeUpdate safety buffer (seconds before video end)
* When timeupdate event fires with currentTime >= (duration - buffer),
* the transition is considered finished.
* Safari needs a slightly larger buffer due to less frequent timeupdate events.
*/
timeUpdateSafetyBuffer: { timeUpdateSafetyBuffer: {
safari: 0.15, safari: 0.15,
default: 0.1, default: 0.1,
}, },
/**
* RequestVideoFrameCallback threshold (seconds before video end)
* When using high-precision frame callbacks, finish this many seconds
* before the video end to prevent showing black/fade frames.
*/
rvfcThreshold: 0.1, rvfcThreshold: 0.1,
/**
* Video loading timeouts
*/
timeouts: { timeouts: {
loadTimeoutMs: 10000, // Max time to wait for video to load loadTimeoutMs: 10000,
playTimeoutMs: 5000, // Max time to wait for play to start playTimeoutMs: 5000,
}, },
/**
* Retry settings
*/
retry: { retry: {
maxDecodeRetries: 1, // Safari decode error retry attempts maxDecodeRetries: 1,
},
progressTimeout: {
noProgressMs: 15000,
checkIntervalMs: 5000,
mobileMultiplier: 2,
}, },
} as const; } as const;

View File

@ -0,0 +1,163 @@
/**
* PageNavigationContext
*
* Context provider for sharing page navigation state with child components.
* Wraps the usePageNavigationState hook to provide state machine access
* throughout the component tree without prop drilling.
*
* Usage:
* ```tsx
* // In parent (constructor.tsx or RuntimePresentation.tsx)
* <PageNavigationProvider preloadCache={preloadOrchestrator} transitionSettings={transitionSettings}>
* <CanvasBackground />
* <Elements />
* </PageNavigationProvider>
*
* // In child components
* const { phase, onBackgroundReady, isSwitching } = usePageNavigationContext();
* ```
*/
import React, { createContext, useContext, ReactNode, useMemo } from 'react';
import {
usePageNavigationState,
UsePageNavigationStateOptions,
UsePageNavigationStateResult,
} from '../hooks/usePageNavigationState';
// ============================================================================
// Context
// ============================================================================
const PageNavigationContext =
createContext<UsePageNavigationStateResult | null>(null);
// ============================================================================
// Provider
// ============================================================================
export interface PageNavigationProviderProps
extends UsePageNavigationStateOptions {
children: ReactNode;
}
/**
* Provider component that wraps usePageNavigationState and exposes it via context.
*
* @example
* ```tsx
* <PageNavigationProvider
* preloadCache={preloadOrchestrator}
* transitionSettings={transitionSettings}
* >
* <CanvasBackground ... />
* <PageElements ... />
* </PageNavigationProvider>
* ```
*/
export function PageNavigationProvider({
children,
...options
}: PageNavigationProviderProps) {
const navState = usePageNavigationState(options);
return (
<PageNavigationContext.Provider value={navState}>
{children}
</PageNavigationContext.Provider>
);
}
// ============================================================================
// Hooks
// ============================================================================
/**
* Hook to access page navigation state from context.
* Must be used within a PageNavigationProvider.
*
* @throws Error if used outside of PageNavigationProvider
*
* @example
* ```tsx
* function CanvasBackground() {
* const { onBackgroundReady, isSwitching, isNewBgReady } = usePageNavigationContext();
*
* return (
* <img
* onLoad={onBackgroundReady}
* style={{ opacity: isNewBgReady ? 1 : 0 }}
* />
* );
* }
* ```
*/
export function usePageNavigationContext(): UsePageNavigationStateResult {
const ctx = useContext(PageNavigationContext);
if (!ctx) {
throw new Error(
'usePageNavigationContext must be used within a PageNavigationProvider',
);
}
return ctx;
}
/**
* Hook to optionally access page navigation state from context.
* Returns null if used outside of PageNavigationProvider.
* Useful for components that may be used both inside and outside the provider.
*
* @example
* ```tsx
* function FlexibleComponent() {
* const navState = usePageNavigationContextOptional();
*
* if (navState) {
* // Inside provider - use navigation state
* return <div>Phase: {navState.phase}</div>;
* }
*
* // Outside provider - render without navigation state
* return <div>Standalone mode</div>;
* }
* ```
*/
export function usePageNavigationContextOptional(): UsePageNavigationStateResult | null {
return useContext(PageNavigationContext);
}
// ============================================================================
// Selector Hook
// ============================================================================
/**
* Hook to select specific parts of the navigation state.
* Helps with performance by allowing components to subscribe to only what they need.
*
* @example
* ```tsx
* // Only re-render when phase changes
* const phase = usePageNavigationSelector(state => state.phase);
*
* // Get multiple values
* const { isSwitching, isNewBgReady } = usePageNavigationSelector(state => ({
* isSwitching: state.isSwitching,
* isNewBgReady: state.isNewBgReady,
* }));
* ```
*/
export function usePageNavigationSelector<T>(
selector: (state: UsePageNavigationStateResult) => T,
): T {
const ctx = usePageNavigationContext();
// Note: This doesn't prevent re-renders on its own since the context value changes.
// For true selector optimization, consider using useSyncExternalStore or a state management library.
// This is primarily for code organization/readability.
return useMemo(() => selector(ctx), [ctx, selector]);
}
// ============================================================================
// Exports
// ============================================================================
export type { UsePageNavigationStateResult } from '../hooks/usePageNavigationState';

View File

@ -20,12 +20,14 @@ export type {
UsePageNavigationOptions, UsePageNavigationOptions,
UsePageNavigationResult, UsePageNavigationResult,
} from './usePageNavigation'; } from './usePageNavigation';
export { useBackgroundTransition } from './useBackgroundTransition'; export { usePageNavigationState } from './usePageNavigationState';
export type { export type {
FadeInConfig, NavigationPhase,
UseBackgroundTransitionOptions, NavigablePage as NavStatePage,
UseBackgroundTransitionResult, PreloadCacheProvider,
} from './useBackgroundTransition'; UsePageNavigationStateOptions,
UsePageNavigationStateResult,
} from './usePageNavigationState';
export { useBackgroundVideoPlayback } from './useBackgroundVideoPlayback'; export { useBackgroundVideoPlayback } from './useBackgroundVideoPlayback';
export type { export type {
UseBackgroundVideoPlaybackOptions, UseBackgroundVideoPlaybackOptions,
@ -66,3 +68,7 @@ export { useSlideTransition } from './useSlideTransition';
// import { useTransitionPreview } from '../hooks/useTransitionPreview'; // import { useTransitionPreview } from '../hooks/useTransitionPreview';
// import { useConstructorElements } from '../hooks/useConstructorElements'; // import { useConstructorElements } from '../hooks/useConstructorElements';
// import { useConstructorPageActions } from '../hooks/useConstructorPageActions'; // import { useConstructorPageActions } from '../hooks/useConstructorPageActions';
// Video primitives - for building custom video playback hooks:
// import { useVideoEventManager, useVideoBufferingState, ... } from './video';
export * from './video';

View File

@ -1,204 +0,0 @@
/**
* useBackgroundTransition Hook
*
* Manages background transition effects when switching between pages.
* Controls the fade-from-black overlay for smooth page transitions.
*
* When a page switch occurs:
* 1. Page content switches instantly (hidden by black overlay)
* 2. Black overlay fades out (1 0) revealing new page
*
* NOTE: Video transitions do NOT use fades - the video itself IS the transition.
*/
import {
useEffect,
useLayoutEffect,
useState,
useCallback,
useRef,
} from 'react';
import { isSafari, getCrossfadeDuration } from '../lib/browserUtils';
import type { ResolvedTransitionSettings } from '../types/transition';
/**
* Fade-in configuration for page content
*/
export interface FadeInConfig {
/** Whether a transition video is currently active (disables fade-in) */
hasActiveTransition: boolean;
}
export interface UseBackgroundTransitionOptions {
/** Page switch hook instance for clearing previous background */
pageSwitch: {
clearPreviousBackground: () => void;
isSwitching: boolean;
isNewBgReady: boolean;
previousBgImageUrl: string;
previousBgVideoUrl: string;
};
/** Optional fade-in configuration for page content */
fadeIn?: FadeInConfig;
/** Optional resolved transition settings for dynamic duration/easing */
transitionSettings?: ResolvedTransitionSettings | null;
}
export interface UseBackgroundTransitionResult {
/** Whether page content is currently fading (fade-from-black in progress) */
isFadingIn: boolean;
/** Reset fade-in state (for cleanup or cancellation) */
resetFadeIn: () => void;
/** Inline style for transition duration and easing */
transitionStyle: React.CSSProperties;
}
/**
* Hook for managing background transition effects.
*
* @example
* const { isFadingIn, transitionStyle } = useBackgroundTransition({
* pageSwitch,
* fadeIn: {
* hasActiveTransition: Boolean(transitionPreview),
* },
* transitionSettings,
* });
*
* // Render black overlay that fades out
* <TransitionBlackOverlay isFadingIn={isFadingIn} transitionStyle={transitionStyle} />
*/
export function useBackgroundTransition({
pageSwitch,
fadeIn,
transitionSettings,
}: UseBackgroundTransitionOptions): UseBackgroundTransitionResult {
const [isFadingIn, setIsFadingIn] = useState(false);
// Track previous isSwitching state to detect transition start
const wasSwitchingRef = useRef(false);
// Store transitionSettings in ref to avoid stale closures
const transitionSettingsRef = useRef(transitionSettings);
transitionSettingsRef.current = transitionSettings;
// Timer ref for fade completion
const fadeInTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Track if animation was already completed
const fadeInCompletedRef = useRef(false);
// Track fadeIn config in ref to avoid stale closure issues
const fadeInRef = useRef(fadeIn);
fadeInRef.current = fadeIn;
/**
* Reset fade-in state (for cleanup or cancellation).
*/
const resetFadeIn = useCallback(() => {
setIsFadingIn(false);
fadeInCompletedRef.current = false;
if (fadeInTimerRef.current) {
clearTimeout(fadeInTimerRef.current);
fadeInTimerRef.current = null;
}
}, []);
/**
* Complete fade-in animation.
*/
const completeFadeIn = useCallback(() => {
if (fadeInCompletedRef.current) return;
fadeInCompletedRef.current = true;
if (fadeInTimerRef.current) {
clearTimeout(fadeInTimerRef.current);
fadeInTimerRef.current = null;
}
setIsFadingIn(false);
}, []);
/**
* Effect: Clear previous background overlay after fade completes (direct navigation).
*/
useEffect(() => {
const hasActiveTransition = fadeInRef.current?.hasActiveTransition ?? false;
// Skip clearing during video transitions
if (hasActiveTransition) return;
if (pageSwitch.isSwitching && pageSwitch.isNewBgReady && !isFadingIn) {
pageSwitch.clearPreviousBackground();
}
}, [
pageSwitch.isSwitching,
pageSwitch.isNewBgReady,
pageSwitch.clearPreviousBackground,
isFadingIn,
]);
/**
* Layout effect: Start fade-from-black when switching starts.
* useLayoutEffect runs before paint, ensuring overlay appears immediately.
*/
useLayoutEffect(() => {
const currentFadeIn = fadeInRef.current;
// Skip if fadeIn config was not provided
if (!currentFadeIn) {
wasSwitchingRef.current = pageSwitch.isSwitching;
return;
}
const justStartedSwitching =
pageSwitch.isSwitching && !wasSwitchingRef.current;
wasSwitchingRef.current = pageSwitch.isSwitching;
// Only start fade for NON-transition navigation
if (justStartedSwitching && !currentFadeIn.hasActiveTransition) {
fadeInCompletedRef.current = false;
if (fadeInTimerRef.current) {
clearTimeout(fadeInTimerRef.current);
fadeInTimerRef.current = null;
}
setIsFadingIn(true);
// Timer to end fade after animation duration
const duration = getCrossfadeDuration(
transitionSettingsRef.current?.durationMs,
);
const bufferMs = isSafari() ? 100 : 50;
fadeInTimerRef.current = setTimeout(() => {
fadeInTimerRef.current = null;
completeFadeIn();
}, duration + bufferMs);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageSwitch.isSwitching, completeFadeIn]);
// Cleanup timer on unmount
useEffect(() => {
return () => {
if (fadeInTimerRef.current) {
clearTimeout(fadeInTimerRef.current);
fadeInTimerRef.current = null;
}
};
}, []);
const transitionStyle: React.CSSProperties = {
'--transition-duration': `${transitionSettings?.durationMs ?? 700}ms`,
'--transition-easing': transitionSettings?.easing ?? 'ease-in-out',
'--overlay-color': transitionSettings?.overlayColor ?? '#000000',
} as React.CSSProperties;
return {
isFadingIn,
resetFadeIn,
transitionStyle,
};
}

View File

@ -1,103 +0,0 @@
/**
* useBackgroundUrls Hook
*
* Unified hook for resolving background display URLs.
* Used by both constructor and RuntimePresentation to ensure
* consistent URL resolution and timing.
*
* Priority:
* 1. Local storage paths (for constructor editing) resolved via blob cache
* 2. pageSwitch URLs (for navigation, already resolved)
*
* This ensures both views use the same logic for background URL resolution.
*/
import { useMemo } from 'react';
import type { UsePageSwitchResult } from './usePageSwitch';
/**
* Function type for resolving storage paths to display URLs
*/
export type UrlResolver = (storagePath: string) => string;
export interface UseBackgroundUrlsOptions {
/** Page switch hook result */
pageSwitch: Pick<
UsePageSwitchResult,
'currentBgImageUrl' | 'currentBgVideoUrl' | 'currentBgAudioUrl'
>;
/** URL resolver function (typically from preload orchestrator) */
resolveUrl: UrlResolver;
/** Local storage paths - used for constructor editing override */
localPaths?: {
imageUrl?: string;
videoUrl?: string;
audioUrl?: string;
};
}
export interface UseBackgroundUrlsResult {
/** Resolved display URL for background image */
backgroundImageSrc: string;
/** Resolved display URL for background video */
backgroundVideoSrc: string;
/** Resolved display URL for background audio */
backgroundAudioSrc: string;
}
/**
* Hook for unified background URL resolution.
*
* @example
* // Constructor - with local editing state
* const { backgroundImageSrc, backgroundVideoSrc, backgroundAudioSrc } = useBackgroundUrls({
* pageSwitch,
* resolveUrl: resolveUrlWithBlob,
* localPaths: {
* imageUrl: backgroundImageUrl,
* videoUrl: backgroundVideoUrl,
* audioUrl: backgroundAudioUrl,
* },
* });
*
* @example
* // RuntimePresentation - navigation only
* const { backgroundImageSrc, backgroundVideoSrc, backgroundAudioSrc } = useBackgroundUrls({
* pageSwitch,
* resolveUrl: resolveUrlWithBlob,
* });
*/
export function useBackgroundUrls({
pageSwitch,
resolveUrl,
localPaths,
}: UseBackgroundUrlsOptions): UseBackgroundUrlsResult {
// Memoize resolved URLs to avoid unnecessary recalculations
const backgroundImageSrc = useMemo(() => {
// Priority: local path (editing) > pageSwitch (navigation)
if (localPaths?.imageUrl) {
return resolveUrl(localPaths.imageUrl);
}
return pageSwitch.currentBgImageUrl;
}, [localPaths?.imageUrl, pageSwitch.currentBgImageUrl, resolveUrl]);
const backgroundVideoSrc = useMemo(() => {
if (localPaths?.videoUrl) {
return resolveUrl(localPaths.videoUrl);
}
return pageSwitch.currentBgVideoUrl;
}, [localPaths?.videoUrl, pageSwitch.currentBgVideoUrl, resolveUrl]);
const backgroundAudioSrc = useMemo(() => {
if (localPaths?.audioUrl) {
return resolveUrl(localPaths.audioUrl);
}
return pageSwitch.currentBgAudioUrl;
}, [localPaths?.audioUrl, pageSwitch.currentBgAudioUrl, resolveUrl]);
return {
backgroundImageSrc,
backgroundVideoSrc,
backgroundAudioSrc,
};
}

View File

@ -2,7 +2,7 @@
* useBackgroundVideoPlayback Hook * useBackgroundVideoPlayback Hook
* *
* Manages background video playback with custom start/end times. * Manages background video playback with custom start/end times.
* Follows patterns from useTransitionPlayback for video time control. * Built on top of video primitives for consistent behavior.
* *
* When loop is disabled, videos are tracked per-session so they only play once * When loop is disabled, videos are tracked per-session so they only play once
* and show the last frame on subsequent page visits (until browser refresh). * and show the last frame on subsequent page visits (until browser refresh).
@ -10,6 +10,7 @@
import { useEffect, useRef, useCallback, type RefObject } from 'react'; import { useEffect, useRef, useCallback, type RefObject } from 'react';
import { logger } from '../lib/logger'; import { logger } from '../lib/logger';
import { useVideoEventManager } from './video/useVideoEventManager';
// Session-scoped tracking of videos that have finished playing (when loop=false) // Session-scoped tracking of videos that have finished playing (when loop=false)
// Key: videoUrl, cleared on browser refresh // Key: videoUrl, cleared on browser refresh
@ -30,6 +31,8 @@ export interface UseBackgroundVideoPlaybackOptions {
startTime?: number | null; startTime?: number | null;
/** End time in seconds (default: null = play to end) */ /** End time in seconds (default: null = play to end) */
endTime?: number | null; endTime?: number | null;
/** External pause control (e.g., during page transitions). Takes precedence over autoplay. */
paused?: boolean;
} }
export interface UseBackgroundVideoPlaybackResult { export interface UseBackgroundVideoPlaybackResult {
@ -66,6 +69,7 @@ export function useBackgroundVideoPlayback({
muted = true, muted = true,
startTime = null, startTime = null,
endTime = null, endTime = null,
paused = false,
}: UseBackgroundVideoPlaybackOptions): UseBackgroundVideoPlaybackResult { }: UseBackgroundVideoPlaybackOptions): UseBackgroundVideoPlaybackResult {
const videoRef = useRef<HTMLVideoElement | null>(null); const videoRef = useRef<HTMLVideoElement | null>(null);
@ -76,31 +80,25 @@ export function useBackgroundVideoPlayback({
// Block autoplay if video already played this session (only when loop=false) // Block autoplay if video already played this session (only when loop=false)
const shouldBlockAutoplay = const shouldBlockAutoplay =
!loop && trackingKey ? playedVideos.has(trackingKey) : false; !loop && trackingKey ? playedVideos.has(trackingKey) : false;
// Store current values in refs for event handlers to access // Store current values in refs for event handlers to access
const startTimeRef = useRef(startTime); const startTimeRef = useRef(startTime);
const endTimeRef = useRef(endTime); const endTimeRef = useRef(endTime);
const loopRef = useRef(loop); const loopRef = useRef(loop);
const autoplayRef = useRef(autoplay); const pausedRef = useRef(paused);
const trackingKeyRef = useRef(trackingKey);
// Update refs when values change // Update refs when values change
useEffect(() => { useEffect(() => {
startTimeRef.current = startTime; startTimeRef.current = startTime;
}, [startTime]);
useEffect(() => {
endTimeRef.current = endTime; endTimeRef.current = endTime;
}, [endTime]);
useEffect(() => {
loopRef.current = loop; loopRef.current = loop;
}, [loop]); pausedRef.current = paused;
trackingKeyRef.current = trackingKey;
}, [startTime, endTime, loop, paused, trackingKey]);
useEffect(() => { // Seek to start time when video metadata is loaded
autoplayRef.current = autoplay; const handleLoadedMetadata = useCallback(() => {
}, [autoplay]);
// Seek to start time when specified and video is ready
const seekToStartTime = useCallback(() => {
const video = videoRef.current; const video = videoRef.current;
const st = startTimeRef.current; const st = startTimeRef.current;
if (!video || st == null || st <= 0) return; if (!video || st == null || st <= 0) return;
@ -109,6 +107,57 @@ export function useBackgroundVideoPlayback({
logger.info('Background video: seeking to start time', { startTime: st }); logger.info('Background video: seeking to start time', { startTime: st });
}, []); }, []);
// Handle time update for end time enforcement
const handleTimeUpdate = useCallback(() => {
const video = videoRef.current;
if (!video || pausedRef.current) return;
const currentEndTime = endTimeRef.current;
const currentLoop = loopRef.current;
const currentStartTime = startTimeRef.current;
// Skip if no end time is set
if (currentEndTime == null) return;
// Check if we've reached or passed the end time
if (video.currentTime >= currentEndTime) {
if (currentLoop) {
// Loop back to start time (or beginning)
const loopTarget = currentStartTime ?? 0;
video.currentTime = loopTarget;
logger.info('Background video: looping back', {
from: currentEndTime,
to: loopTarget,
});
} else {
// Pause at end time
video.pause();
logger.info('Background video: paused at end time', {
endTime: currentEndTime,
});
}
}
}, []);
// Handle video ended for play-once tracking
const handleEnded = useCallback(() => {
const key = trackingKeyRef.current;
if (key && !loopRef.current) {
playedVideos.add(key);
}
}, []);
// Use video event manager for event handling
useVideoEventManager({
videoRef,
enabled: Boolean(videoUrl),
handlers: {
onLoadedMetadata: handleLoadedMetadata,
onTimeUpdate: handleTimeUpdate,
onEnded: handleEnded,
},
});
// Handle start time changes - seek immediately when startTime changes // Handle start time changes - seek immediately when startTime changes
useEffect(() => { useEffect(() => {
const video = videoRef.current; const video = videoRef.current;
@ -119,23 +168,19 @@ export function useBackgroundVideoPlayback({
video.currentTime = startTime; video.currentTime = startTime;
logger.info('Background video: seeking to start time', { startTime }); logger.info('Background video: seeking to start time', { startTime });
} }
}, [videoUrl, startTime]);
// Set up listener for initial load (if not loaded yet) // Handle autoplay state changes (respects external pause control)
const handleLoadedMetadata = () => {
seekToStartTime();
};
video.addEventListener('loadedmetadata', handleLoadedMetadata);
return () => {
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
};
}, [videoUrl, startTime, seekToStartTime]);
// Handle autoplay state changes
useEffect(() => { useEffect(() => {
const video = videoRef.current; const video = videoRef.current;
if (!video || !videoUrl) return; if (!video || !videoUrl) return;
// External pause takes precedence over autoplay
if (paused) {
video.pause();
return;
}
if (autoplay) { if (autoplay) {
video.play().catch((error) => { video.play().catch((error) => {
// Autoplay blocked by browser - this is expected behavior // Autoplay blocked by browser - this is expected behavior
@ -146,46 +191,7 @@ export function useBackgroundVideoPlayback({
} else { } else {
video.pause(); video.pause();
} }
}, [videoUrl, autoplay]); }, [videoUrl, autoplay, paused]);
// Handle end time enforcement via timeupdate
useEffect(() => {
const video = videoRef.current;
if (!video || !videoUrl) return;
const handleTimeUpdate = () => {
const currentEndTime = endTimeRef.current;
const currentLoop = loopRef.current;
const currentStartTime = startTimeRef.current;
// Skip if no end time is set
if (currentEndTime == null) return;
// Check if we've reached or passed the end time
if (video.currentTime >= currentEndTime) {
if (currentLoop) {
// Loop back to start time (or beginning)
const loopTarget = currentStartTime ?? 0;
video.currentTime = loopTarget;
logger.info('Background video: looping back', {
from: currentEndTime,
to: loopTarget,
});
} else {
// Pause at end time
video.pause();
logger.info('Background video: paused at end time', {
endTime: currentEndTime,
});
}
}
};
video.addEventListener('timeupdate', handleTimeUpdate);
return () => {
video.removeEventListener('timeupdate', handleTimeUpdate);
};
}, [videoUrl]);
// Handle muted state changes // Handle muted state changes
useEffect(() => { useEffect(() => {
@ -214,13 +220,6 @@ export function useBackgroundVideoPlayback({
} }
return; return;
} }
// Mark video as played when it ends
const handleEnded = () => {
playedVideos.add(trackingKey);
};
video.addEventListener('ended', handleEnded);
return () => video.removeEventListener('ended', handleEnded);
}, [videoUrl, trackingKey, loop]); }, [videoUrl, trackingKey, loop]);
return { videoRef, shouldBlockAutoplay }; return { videoRef, shouldBlockAutoplay };

View File

@ -1,233 +0,0 @@
/**
* useNeighborGraph Hook
*
* Builds a navigation graph from page_links to determine which pages
* are neighbors and should have their assets preloaded.
*
* Uses shared asset discovery from lib/assetCache for consistent extraction.
*/
import { useMemo } from 'react';
import { PRELOAD_CONFIG } from '../config/preload.config';
import {
extractElementAssets,
extractPageBackgroundAssets,
extractTransitionAssets,
toPreloadAssetInfo,
} from '../lib/assetCache';
import type {
PreloadPage,
PreloadPageLink,
PreloadElement,
PreloadAssetInfo,
PreloadNeighborInfo,
} from '../types/preload';
interface UseNeighborGraphOptions {
pages: PreloadPage[];
pageLinks: PreloadPageLink[];
elements: PreloadElement[];
maxDepth?: number;
}
interface NeighborGraphResult {
/**
* Get neighboring page IDs within maxDepth hops
*/
getNeighbors: (
currentPageId: string,
maxDepth?: number,
) => PreloadNeighborInfo[];
/**
* Get all assets that should be preloaded for given pages
*/
getAssetsForPages: (pageIds: string[]) => PreloadAssetInfo[];
/**
* Get prioritized assets for preloading based on current page
*/
getPrioritizedAssets: (
currentPageId: string,
maxDepth?: number,
) => PreloadAssetInfo[];
/**
* Raw adjacency list for debugging
*/
adjacencyList: Map<string, string[]>;
}
export function useNeighborGraph(
options: UseNeighborGraphOptions,
): NeighborGraphResult {
const {
pages,
pageLinks,
elements,
maxDepth = PRELOAD_CONFIG.neighborGraph.maxDepth,
} = options;
// Build adjacency list from page links
const adjacencyList = useMemo(() => {
const adj = new Map<string, string[]>();
// Initialize all pages
pages.forEach((page) => {
adj.set(page.id, []);
});
// Add edges from active page links
const activeLinks = pageLinks.filter((link) => link.is_active !== false);
activeLinks.forEach((link) => {
if (link.from_pageId && link.to_pageId) {
const neighbors = adj.get(link.from_pageId) || [];
if (!neighbors.includes(link.to_pageId)) {
neighbors.push(link.to_pageId);
adj.set(link.from_pageId, neighbors);
}
}
});
return adj;
}, [pages, pageLinks]);
// BFS to find neighbors within depth
const getNeighbors = useMemo(() => {
return (currentPageId: string, depth = maxDepth): PreloadNeighborInfo[] => {
const visited = new Set<string>();
const result: PreloadNeighborInfo[] = [];
const queue: { pageId: string; distance: number }[] = [
{ pageId: currentPageId, distance: 0 },
];
visited.add(currentPageId);
while (queue.length > 0) {
const item = queue.shift();
if (!item) break;
const { pageId, distance } = item;
if (distance > 0) {
result.push({ pageId, distance });
}
if (distance < depth) {
const neighbors = adjacencyList.get(pageId) || [];
for (const neighborId of neighbors) {
if (!visited.has(neighborId)) {
visited.add(neighborId);
queue.push({ pageId: neighborId, distance: distance + 1 });
}
}
}
}
// Sort by distance (closest first)
return result.sort((a, b) => a.distance - b.distance);
};
}, [adjacencyList, maxDepth]);
// Get assets for a set of pages - uses shared extraction from assetDiscovery
const getAssetsForPages = useMemo(() => {
return (pageIds: string[]): PreloadAssetInfo[] => {
const assets: PreloadAssetInfo[] = [];
const seenUrls = new Set<string>();
pageIds.forEach((pageId) => {
// Find the page to get its background assets
const page = pages.find((p) => p.id === pageId);
if (page) {
// Use shared extraction for page backgrounds
const bgAssets = extractPageBackgroundAssets(page);
bgAssets.forEach((asset) => {
if (!seenUrls.has(asset.originalUrl)) {
seenUrls.add(asset.originalUrl);
assets.push(toPreloadAssetInfo(asset));
}
});
}
// Get elements for this page and use shared extraction
const pageElements = elements.filter((el) => el.pageId === pageId);
const elementAssets = extractElementAssets(pageElements, pageId);
elementAssets.forEach((asset) => {
if (!seenUrls.has(asset.originalUrl)) {
seenUrls.add(asset.originalUrl);
assets.push(toPreloadAssetInfo(asset));
}
});
});
// Extract transition videos using shared extraction
pageIds.forEach((pageId) => {
const transitionAssets = extractTransitionAssets(pageLinks, pageId);
transitionAssets.forEach((asset) => {
if (!seenUrls.has(asset.originalUrl)) {
seenUrls.add(asset.originalUrl);
assets.push(toPreloadAssetInfo(asset));
}
});
});
return assets;
};
}, [pages, elements, pageLinks]);
// Get prioritized assets for preloading
const getPrioritizedAssets = useMemo(() => {
return (currentPageId: string, depth = maxDepth): PreloadAssetInfo[] => {
// Get current page assets (highest priority)
const currentPageAssets = getAssetsForPages([currentPageId]).map(
(asset) => ({
...asset,
priority:
PRELOAD_CONFIG.priority.currentPage +
(PRELOAD_CONFIG.priority.assetType[asset.assetType] || 0),
}),
);
// Get neighbor page assets
const neighbors = getNeighbors(currentPageId, depth);
const neighborAssets: PreloadAssetInfo[] = [];
neighbors.forEach(({ pageId, distance }) => {
const assets = getAssetsForPages([pageId]);
assets.forEach((asset) => {
const basePriority = PRELOAD_CONFIG.priority.neighborBase / distance;
const typePriority =
PRELOAD_CONFIG.priority.assetType[asset.assetType] || 0;
neighborAssets.push({
...asset,
priority: basePriority + typePriority,
});
});
});
// Combine and sort by priority (highest first)
const allAssets = [...currentPageAssets, ...neighborAssets];
// Deduplicate by URL, keeping highest priority
const urlToPriority = new Map<string, PreloadAssetInfo>();
allAssets.forEach((asset) => {
const existing = urlToPriority.get(asset.url);
if (!existing || asset.priority > existing.priority) {
urlToPriority.set(asset.url, asset);
}
});
return Array.from(urlToPriority.values()).sort(
(a, b) => b.priority - a.priority,
);
};
}, [getAssetsForPages, getNeighbors, maxDepth]);
return {
getNeighbors,
getAssetsForPages,
getPrioritizedAssets,
adjacencyList,
};
}

View File

@ -1,14 +1,12 @@
/** /**
* useNetworkAware Hook * useNetworkAware Hook
* *
* Monitors network conditions and adapts preloading strategy accordingly. * Monitors network conditions and adapts behavior accordingly.
* Uses the Network Information API where available.
*/ */
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import type { NetworkInfo } from '../types/offline'; import type { NetworkInfo } from '../types/offline';
// Extend Navigator interface for Network Information API
interface NetworkInformation extends EventTarget { interface NetworkInformation extends EventTarget {
readonly effectiveType?: 'slow-2g' | '2g' | '3g' | '4g'; readonly effectiveType?: 'slow-2g' | '2g' | '3g' | '4g';
readonly downlink?: number; readonly downlink?: number;
@ -25,22 +23,11 @@ interface NavigatorWithConnection extends Navigator {
interface UseNetworkAwareResult { interface UseNetworkAwareResult {
networkInfo: NetworkInfo; networkInfo: NetworkInfo;
/**
* Whether preloading should be aggressive (good connection)
*/
shouldPreloadAggressively: boolean; shouldPreloadAggressively: boolean;
/**
* Whether to prefer lower quality variants
*/
preferLowQuality: boolean; preferLowQuality: boolean;
/**
* Recommended concurrent download count based on network
*/
recommendedConcurrency: number; recommendedConcurrency: number;
/**
* Whether offline mode should be suggested to user
*/
suggestOfflineMode: boolean; suggestOfflineMode: boolean;
shouldUseVideoTransitions: boolean;
} }
const getConnection = (): NetworkInformation | null => { const getConnection = (): NetworkInformation | null => {
@ -68,7 +55,6 @@ const getNetworkInfo = (): NetworkInfo => {
export function useNetworkAware(): UseNetworkAwareResult { export function useNetworkAware(): UseNetworkAwareResult {
const [networkInfo, setNetworkInfo] = useState<NetworkInfo>(getNetworkInfo); const [networkInfo, setNetworkInfo] = useState<NetworkInfo>(getNetworkInfo);
// Update network info on changes
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
@ -76,11 +62,9 @@ export function useNetworkAware(): UseNetworkAwareResult {
setNetworkInfo(getNetworkInfo()); setNetworkInfo(getNetworkInfo());
}; };
// Listen for online/offline events
window.addEventListener('online', updateNetworkInfo); window.addEventListener('online', updateNetworkInfo);
window.addEventListener('offline', updateNetworkInfo); window.addEventListener('offline', updateNetworkInfo);
// Listen for connection changes if available
const connection = getConnection(); const connection = getConnection();
if (connection) { if (connection) {
connection.addEventListener('change', updateNetworkInfo); connection.addEventListener('change', updateNetworkInfo);
@ -95,36 +79,28 @@ export function useNetworkAware(): UseNetworkAwareResult {
}; };
}, []); }, []);
// Determine if preloading should be aggressive
const shouldPreloadAggressively = useCallback((): boolean => { const shouldPreloadAggressively = useCallback((): boolean => {
if (!networkInfo.isOnline) return false; if (!networkInfo.isOnline) return false;
if (networkInfo.saveData) return false; if (networkInfo.saveData) return false;
// Good connection: 4g or high downlink
if (networkInfo.effectiveType === '4g') return true; if (networkInfo.effectiveType === '4g') return true;
if (networkInfo.downlink && networkInfo.downlink >= 5) return true; if (networkInfo.downlink && networkInfo.downlink >= 5) return true;
return false; return false;
}, [networkInfo]); }, [networkInfo]);
// Determine if low quality variants should be preferred
const preferLowQuality = useCallback((): boolean => { const preferLowQuality = useCallback((): boolean => {
if (networkInfo.saveData) return true; if (networkInfo.saveData) return true;
if (networkInfo.effectiveType === 'slow-2g') return true; if (networkInfo.effectiveType === 'slow-2g') return true;
if (networkInfo.effectiveType === '2g') return true; if (networkInfo.effectiveType === '2g') return true;
if (networkInfo.downlink && networkInfo.downlink < 1) return true; if (networkInfo.downlink && networkInfo.downlink < 1) return true;
return false; return false;
}, [networkInfo]); }, [networkInfo]);
// Calculate recommended concurrency
const getRecommendedConcurrency = useCallback((): number => { const getRecommendedConcurrency = useCallback((): number => {
if (!networkInfo.isOnline) return 0; if (!networkInfo.isOnline) return 0;
if (networkInfo.saveData) return 1; if (networkInfo.saveData) return 1;
switch (networkInfo.effectiveType) { switch (networkInfo.effectiveType) {
case 'slow-2g': case 'slow-2g':
return 1;
case '2g': case '2g':
return 1; return 1;
case '3g': case '3g':
@ -132,32 +108,40 @@ export function useNetworkAware(): UseNetworkAwareResult {
case '4g': case '4g':
return 3; return 3;
default: default:
// Fall back to downlink-based calculation
if (networkInfo.downlink) { if (networkInfo.downlink) {
if (networkInfo.downlink < 1) return 1; if (networkInfo.downlink < 1) return 1;
if (networkInfo.downlink < 5) return 2; if (networkInfo.downlink < 5) return 2;
return 3; return 3;
} }
return 2; // Default return 2;
} }
}, [networkInfo]); }, [networkInfo]);
// Determine if offline mode should be suggested
const suggestOfflineMode = useCallback((): boolean => { const suggestOfflineMode = useCallback((): boolean => {
// Suggest offline if on poor connection
if (networkInfo.effectiveType === 'slow-2g') return true; if (networkInfo.effectiveType === 'slow-2g') return true;
if (networkInfo.effectiveType === '2g') return true; if (networkInfo.effectiveType === '2g') return true;
if (networkInfo.rtt && networkInfo.rtt > 500) return true; if (networkInfo.rtt && networkInfo.rtt > 500) return true;
if (networkInfo.downlink && networkInfo.downlink < 0.5) return true; if (networkInfo.downlink && networkInfo.downlink < 0.5) return true;
return false; return false;
}, [networkInfo]); }, [networkInfo]);
const shouldUseVideoTransitions = useCallback((): boolean => {
if (!networkInfo.isOnline) return false;
if (networkInfo.saveData) return false;
if (networkInfo.effectiveType === 'slow-2g') return false;
if (networkInfo.effectiveType === '2g') return false;
if (networkInfo.effectiveType === '3g') return false;
if (networkInfo.downlink !== undefined && networkInfo.downlink < 2) return false;
if (networkInfo.rtt !== undefined && networkInfo.rtt > 500) return false;
return true;
}, [networkInfo]);
return { return {
networkInfo, networkInfo,
shouldPreloadAggressively: shouldPreloadAggressively(), shouldPreloadAggressively: shouldPreloadAggressively(),
preferLowQuality: preferLowQuality(), preferLowQuality: preferLowQuality(),
recommendedConcurrency: getRecommendedConcurrency(), recommendedConcurrency: getRecommendedConcurrency(),
suggestOfflineMode: suggestOfflineMode(), suggestOfflineMode: suggestOfflineMode(),
shouldUseVideoTransitions: shouldUseVideoTransitions(),
}; };
} }

View File

@ -0,0 +1,867 @@
/**
* usePageNavigationState Hook
*
* Unified state machine for page navigation, replacing 6+ fragmented hooks.
* Uses useReducer for atomic state transitions, preventing race conditions.
*
* Consolidates:
* - usePageSwitch: URL resolution and switching
* - useBackgroundState: Background ready tracking
* - useBackgroundTransition: Fade-from-black effects
* - useTransitionCleanup: Video cleanup coordination
* - useBackgroundUrls: URL resolution for display
* - pageLoadingUtils: Loading state computation
*
* State Machine Phases:
* - idle: No navigation in progress, elements visible
* - preparing: Navigation triggered, saving previous URLs, resolving new URLs
* - transitioning: Video transition playing
* - transition_done: Video finished, waiting for background to load
* - loading_bg: Direct navigation (no video), waiting for background to load
* - fading_in: Black overlay fading out to reveal new page
*/
import { useReducer, useCallback, useRef, useEffect, useMemo } from 'react';
import {
resolveAssetPlaybackUrl,
markPresignedUrlFailed,
isRelativeStoragePath,
isPresignedUrl,
buildProxyUrl,
} from '../lib/assetUrl';
import {
scheduleAfterPaint,
scheduleAfterPaintSafari,
isSafari,
getCrossfadeDuration,
} from '../lib/browserUtils';
import { logger } from '../lib/logger';
import type { ResolvedTransitionSettings } from '../types/transition';
// ============================================================================
// Types
// ============================================================================
/**
* Navigation phases as a finite state machine
*/
export type NavigationPhase =
| 'idle' // No navigation in progress
| 'preparing' // Resolving URLs, saving previous state
| 'transitioning' // Video transition playing
| 'transition_done' // Video finished, waiting for background
| 'loading_bg' // Direct navigation, waiting for background
| 'fading_in'; // Black overlay fading out
/**
* Minimal page interface for navigation
*/
export interface NavigablePage {
id: string;
background_image_url?: string;
background_video_url?: string;
background_audio_url?: string;
}
/**
* Preload cache provider interface
*/
export interface PreloadCacheProvider {
getReadyBlobUrl?: (url: string) => string | null;
getCachedBlobUrl?: (url: string) => Promise<string | null>;
preloadedUrls?: Set<string>;
}
/**
* Internal state structure
*/
interface NavigationState {
phase: NavigationPhase;
// Current page URLs (resolved for display)
currentImageUrl: string;
currentVideoUrl: string;
currentAudioUrl: string;
// Previous page URLs (for overlay during transition)
previousImageUrl: string;
previousVideoUrl: string;
// Target page ID (during navigation)
targetPageId: string | null;
// Whether current navigation is a back navigation
isBackNavigation: boolean;
// Safari black flash prevention
lastKnownBgUrl: string;
// Video buffering state
isVideoBuffering: boolean;
}
/**
* Actions for the reducer
*/
type NavigationAction =
| {
type: 'START_NAVIGATION';
payload: {
hasTransition: boolean;
targetPageId: string | null;
isBack: boolean;
};
}
| {
type: 'START_TRANSITION';
payload: {
targetPageId: string | null;
isBack: boolean;
};
}
| {
type: 'URLS_RESOLVED';
payload: {
imageUrl: string;
videoUrl: string;
audioUrl: string;
};
}
| { type: 'TRANSITION_STARTED' }
| { type: 'TRANSITION_ENDED' }
| { type: 'BACKGROUND_READY' }
| { type: 'FADE_STARTED' }
| { type: 'FADE_COMPLETED' }
| {
type: 'SET_BACKGROUND_DIRECTLY';
payload: {
imageUrl: string;
videoUrl: string;
audioUrl: string;
};
}
| { type: 'RESET_TO_IDLE' }
| { type: 'SET_VIDEO_BUFFERING'; payload: boolean }
| { type: 'UPDATE_LAST_KNOWN_BG'; payload: string }
| { type: 'CLEAR_PREVIOUS_BACKGROUND' };
// ============================================================================
// Reducer
// ============================================================================
const initialState: NavigationState = {
phase: 'idle',
currentImageUrl: '',
currentVideoUrl: '',
currentAudioUrl: '',
previousImageUrl: '',
previousVideoUrl: '',
targetPageId: null,
isBackNavigation: false,
lastKnownBgUrl: '',
isVideoBuffering: false,
};
function navigationReducer(
state: NavigationState,
action: NavigationAction,
): NavigationState {
// DevTools logging in development
if (process.env.NODE_ENV === 'development') {
logger.info('[NavigationState] Action:', {
type: action.type,
currentPhase: state.phase,
payload: 'payload' in action ? action.payload : undefined,
});
}
switch (action.type) {
case 'START_NAVIGATION':
// ATOMIC: Save previous URLs + set phase in one update
return {
...state,
phase: action.payload.hasTransition ? 'transitioning' : 'loading_bg',
previousImageUrl: state.currentImageUrl,
previousVideoUrl: state.currentVideoUrl,
targetPageId: action.payload.targetPageId,
isBackNavigation: action.payload.isBack,
};
case 'START_TRANSITION':
// Video transition started (before video plays)
// Sets phase to 'transitioning' and saves previous URLs atomically
return {
...state,
phase: 'transitioning',
previousImageUrl: state.currentImageUrl,
previousVideoUrl: state.currentVideoUrl,
targetPageId: action.payload.targetPageId,
isBackNavigation: action.payload.isBack,
};
case 'URLS_RESOLVED':
// URLs resolved, update current URLs
return {
...state,
currentImageUrl: action.payload.imageUrl,
currentVideoUrl: action.payload.videoUrl,
currentAudioUrl: action.payload.audioUrl,
};
case 'TRANSITION_STARTED':
// Video transition has started playing
if (state.phase !== 'transitioning') return state;
return state; // Phase already correct, no change needed
case 'TRANSITION_ENDED':
// Video transition ended, wait for background
if (state.phase !== 'transitioning') return state;
return {
...state,
phase: 'transition_done',
};
case 'BACKGROUND_READY':
// Background loaded, start fade-in (only from certain phases)
if (
state.phase !== 'transition_done' &&
state.phase !== 'loading_bg' &&
state.phase !== 'preparing'
) {
return state;
}
return {
...state,
phase: 'fading_in',
};
case 'FADE_STARTED':
// Fade animation started
if (state.phase !== 'fading_in') return state;
return state;
case 'FADE_COMPLETED':
// Fade animation completed, return to idle
return {
...state,
phase: 'idle',
previousImageUrl: '',
previousVideoUrl: '',
targetPageId: null,
isBackNavigation: false,
};
case 'SET_BACKGROUND_DIRECTLY':
// Direct background update (edit mode) - bypasses navigation flow
return {
...state,
phase: 'idle',
currentImageUrl: action.payload.imageUrl,
currentVideoUrl: action.payload.videoUrl,
currentAudioUrl: action.payload.audioUrl,
previousImageUrl: '',
previousVideoUrl: '',
};
case 'RESET_TO_IDLE':
// Force reset to idle state
return {
...state,
phase: 'idle',
previousImageUrl: '',
previousVideoUrl: '',
targetPageId: null,
isBackNavigation: false,
};
case 'SET_VIDEO_BUFFERING':
return {
...state,
isVideoBuffering: action.payload,
};
case 'UPDATE_LAST_KNOWN_BG':
// Update Safari black flash prevention snapshot
return {
...state,
lastKnownBgUrl: action.payload,
};
case 'CLEAR_PREVIOUS_BACKGROUND':
return {
...state,
previousImageUrl: '',
previousVideoUrl: '',
};
default:
return state;
}
}
// ============================================================================
// Hook Options & Result
// ============================================================================
export interface UsePageNavigationStateOptions {
/** Preload cache provider for blob URL resolution */
preloadCache?: PreloadCacheProvider;
/** Fade duration in milliseconds (default: 700) */
fadeDurationMs?: number;
/** Transition settings for dynamic duration/easing */
transitionSettings?: ResolvedTransitionSettings | null;
}
export interface UsePageNavigationStateResult {
// Current state
phase: NavigationPhase;
state: NavigationState;
// Current page URLs (for display)
currentImageUrl: string;
currentVideoUrl: string;
currentAudioUrl: string;
// Previous page URLs (for overlay)
previousImageUrl: string;
previousVideoUrl: string;
// Safari black flash prevention
lastKnownBgUrl: string;
// Derived states (computed from phase)
isLoading: boolean;
showSpinner: boolean;
showElements: boolean;
showPreviousOverlay: boolean;
showTransitionVideo: boolean;
isFadingIn: boolean;
isSwitching: boolean;
isNewBgReady: boolean;
isBackgroundReady: boolean;
pendingTransitionComplete: boolean;
// Video buffering state
isVideoBuffering: boolean;
// Transition style for CSS
transitionStyle: React.CSSProperties;
// Actions
/** Start navigation to a new page */
navigateToPage: (
targetPage: NavigablePage | null,
options?: {
hasTransition?: boolean;
isBack?: boolean;
onSwitched?: () => void;
},
) => Promise<void>;
/** Signal that background media is ready (call from CanvasBackground.onLoad) */
onBackgroundReady: () => void;
/** Signal that transition video has ended */
onTransitionEnded: () => void;
/** Reset background ready state (call before navigation) */
resetBackgroundReady: () => void;
/** Clear previous background overlay */
clearPreviousBackground: () => void;
/** Direct background update for edit mode (bypasses navigation flow) */
setBackgroundDirectly: (
imageUrl: string,
videoUrl: string,
audioUrl: string,
) => void;
/** Reset to idle state */
resetToIdle: () => void;
/** Video buffering state callback */
onVideoBufferStateChange: (isBuffering: boolean) => void;
/** Mark new background as ready (for compatibility with usePageSwitch) */
markBackgroundReady: () => void;
/** Start a video transition (sets phase to 'transitioning') */
startTransition: (targetPageId: string | null, isBack?: boolean) => void;
}
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Decode an image from URL to ensure it's ready for display.
* Safari-specific: waits extra frame after decode to ensure pixels are painted.
*/
const decodeImage = (url: string): Promise<void> => {
return new Promise((resolve) => {
if (!url) {
resolve();
return;
}
const img = new window.Image();
const safariMode = isSafari();
const onReady = () => {
if (safariMode) {
scheduleAfterPaintSafari(() => resolve());
} else {
scheduleAfterPaint(() => resolve());
}
};
img.onload = () => {
if (typeof img.decode === 'function') {
img.decode().then(onReady).catch(onReady);
} else {
onReady();
}
};
img.onerror = () => onReady();
img.src = url;
});
};
/**
* Load and decode an image with presigned URL fallback.
*/
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) {
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 (!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 {
onImageReady(srcUrl);
}
};
};
tryLoad(url);
});
};
// ============================================================================
// Main Hook
// ============================================================================
export function usePageNavigationState(
options: UsePageNavigationStateOptions = {},
): UsePageNavigationStateResult {
const { preloadCache, transitionSettings } = options;
const fadeDurationMs =
options.fadeDurationMs ?? transitionSettings?.durationMs ?? 700;
const [state, dispatch] = useReducer(navigationReducer, initialState);
// Refs for stable callbacks
const preloadCacheRef = useRef(preloadCache);
preloadCacheRef.current = preloadCache;
const transitionSettingsRef = useRef(transitionSettings);
transitionSettingsRef.current = transitionSettings;
// Track created blob URLs for cleanup
const createdBlobUrlsRef = useRef<Set<string>>(new Set());
// Fade timer ref
const fadeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// ============================================================================
// URL Resolution
// ============================================================================
/**
* Resolve a storage path to a displayable URL.
*/
const resolveToDisplayUrl = useCallback(
async (storagePath: string | undefined): Promise<string> => {
if (!storagePath) return '';
const cache = preloadCacheRef.current;
// 1. Try in-memory blob URL lookup (instant)
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
if (cache?.getCachedBlobUrl) {
try {
const blobUrl = await cache.getCachedBlobUrl(storagePath);
if (blobUrl) {
createdBlobUrlsRef.current.add(blobUrl);
return blobUrl;
}
} catch {
// Fall through
}
}
// 3. Resolve to playback URL
const originalUrl = resolveAssetPlaybackUrl(storagePath);
if (!originalUrl) return '';
// Try blob URL lookup by resolved URL
if (cache?.getReadyBlobUrl) {
const readyUrl = cache.getReadyBlobUrl(originalUrl);
if (readyUrl) {
return readyUrl;
}
}
// Try cached blob URL by resolved URL
if (cache?.getCachedBlobUrl) {
try {
const blobUrl = await cache.getCachedBlobUrl(originalUrl);
if (blobUrl) {
createdBlobUrlsRef.current.add(blobUrl);
return blobUrl;
}
} catch {
// Fall through
}
}
// Load with presigned URL fallback
const storageKey = isRelativeStoragePath(storagePath)
? storagePath
: undefined;
return loadImageWithFallback(originalUrl, storageKey);
},
[],
);
/**
* Resolve video/audio URL.
*/
const resolveMediaUrl = useCallback(
async (storagePath: string | undefined): Promise<string> => {
if (!storagePath) return '';
const cache = preloadCacheRef.current;
// 1. Try in-memory blob URL lookup
if (cache?.getReadyBlobUrl) {
const readyUrl = cache.getReadyBlobUrl(storagePath);
if (readyUrl) return readyUrl;
}
// 2. Try persistent cache
if (cache?.getCachedBlobUrl) {
try {
const blobUrl = await cache.getCachedBlobUrl(storagePath);
if (blobUrl) {
createdBlobUrlsRef.current.add(blobUrl);
return blobUrl;
}
} catch {
// Fall through
}
}
// 3. Resolve URL
const originalUrl = resolveAssetPlaybackUrl(storagePath);
if (!originalUrl) return '';
if (cache?.getReadyBlobUrl) {
const readyUrl = cache.getReadyBlobUrl(originalUrl);
if (readyUrl) return readyUrl;
}
if (cache?.getCachedBlobUrl) {
try {
const blobUrl = await cache.getCachedBlobUrl(originalUrl);
if (blobUrl) {
createdBlobUrlsRef.current.add(blobUrl);
return blobUrl;
}
} catch {
// Fall through
}
}
return originalUrl;
},
[],
);
// ============================================================================
// Actions
// ============================================================================
const navigateToPage = useCallback(
async (
targetPage: NavigablePage | null,
options: {
hasTransition?: boolean;
isBack?: boolean;
onSwitched?: () => void;
} = {},
) => {
const { hasTransition = false, isBack = false, onSwitched } = options;
if (!targetPage) {
dispatch({ type: 'RESET_TO_IDLE' });
onSwitched?.();
return;
}
// Start navigation atomically
dispatch({
type: 'START_NAVIGATION',
payload: {
hasTransition,
targetPageId: targetPage.id,
isBack,
},
});
// Resolve URLs (may be async)
const [imageUrl, videoUrl, audioUrl] = await Promise.all([
resolveToDisplayUrl(targetPage.background_image_url),
resolveMediaUrl(targetPage.background_video_url),
resolveMediaUrl(targetPage.background_audio_url),
]);
// Update current URLs
dispatch({
type: 'URLS_RESOLVED',
payload: { imageUrl, videoUrl, audioUrl },
});
// Notify caller
onSwitched?.();
// For blob URLs, decode image before marking ready
if (!hasTransition && (imageUrl.startsWith('blob:') || !imageUrl)) {
decodeImage(imageUrl).then(() => {
dispatch({ type: 'BACKGROUND_READY' });
});
}
},
[resolveToDisplayUrl, resolveMediaUrl],
);
const onBackgroundReady = useCallback(() => {
dispatch({ type: 'BACKGROUND_READY' });
}, []);
const onTransitionEnded = useCallback(() => {
dispatch({ type: 'TRANSITION_ENDED' });
}, []);
const resetBackgroundReady = useCallback(() => {
// This is called before navigation to reset state
// The actual reset happens in START_NAVIGATION
}, []);
const clearPreviousBackground = useCallback(() => {
dispatch({ type: 'CLEAR_PREVIOUS_BACKGROUND' });
}, []);
const setBackgroundDirectly = useCallback(
(imageUrl: string, videoUrl: string, audioUrl: string) => {
dispatch({
type: 'SET_BACKGROUND_DIRECTLY',
payload: { imageUrl, videoUrl, audioUrl },
});
},
[],
);
const resetToIdle = useCallback(() => {
dispatch({ type: 'RESET_TO_IDLE' });
}, []);
const onVideoBufferStateChange = useCallback((isBuffering: boolean) => {
dispatch({ type: 'SET_VIDEO_BUFFERING', payload: isBuffering });
}, []);
const markBackgroundReady = useCallback(() => {
dispatch({ type: 'BACKGROUND_READY' });
}, []);
const startTransition = useCallback(
(targetPageId: string | null, isBack = false) => {
dispatch({
type: 'START_TRANSITION',
payload: { targetPageId, isBack },
});
},
[],
);
// ============================================================================
// Effects
// ============================================================================
// Update lastKnownBgUrl for Safari black flash prevention
useEffect(() => {
if (state.currentImageUrl) {
dispatch({
type: 'UPDATE_LAST_KNOWN_BG',
payload: state.currentImageUrl,
});
}
}, [state.currentImageUrl]);
// Fade completion timer
useEffect(() => {
if (state.phase === 'fading_in') {
const duration = getCrossfadeDuration(
transitionSettingsRef.current?.durationMs,
);
const bufferMs = isSafari() ? 100 : 50;
fadeTimerRef.current = setTimeout(() => {
fadeTimerRef.current = null;
dispatch({ type: 'FADE_COMPLETED' });
}, duration + bufferMs);
}
return () => {
if (fadeTimerRef.current) {
clearTimeout(fadeTimerRef.current);
fadeTimerRef.current = null;
}
};
}, [state.phase]);
// ============================================================================
// Derived State
// ============================================================================
const derived = useMemo(
() => ({
isLoading: state.phase === 'preparing' || state.phase === 'loading_bg',
// Show spinner when:
// - Preparing navigation (resolving URLs)
// - Loading background (no video transition)
// - Transition video ended, waiting for background
// - Video transition active but buffering (video not playing yet)
showSpinner:
state.phase === 'preparing' ||
state.phase === 'loading_bg' ||
state.phase === 'transition_done' ||
(state.phase === 'transitioning' && state.isVideoBuffering),
showElements: state.phase === 'idle' || state.phase === 'fading_in',
showPreviousOverlay:
state.phase === 'loading_bg' || state.phase === 'transition_done',
// Keep transition video overlay visible through entire video transition flow:
// transitioning → transition_done → loading_bg → fading_in
// The overlay is only rendered when transitionPreview is set, so this won't
// affect direct navigation (no video transition).
showTransitionVideo:
state.phase === 'transitioning' ||
state.phase === 'transition_done' ||
state.phase === 'loading_bg' ||
state.phase === 'fading_in',
isFadingIn: state.phase === 'fading_in',
// Compatibility flags for existing components
isSwitching: state.phase !== 'idle' && state.phase !== 'fading_in',
isNewBgReady: state.phase === 'fading_in' || state.phase === 'idle',
isBackgroundReady: state.phase === 'idle' || state.phase === 'fading_in',
pendingTransitionComplete: state.phase === 'transition_done',
}),
[state.phase, state.isVideoBuffering],
);
// Transition style
const transitionStyle: React.CSSProperties = useMemo(
() =>
({
'--transition-duration': `${transitionSettings?.durationMs ?? 700}ms`,
'--transition-easing': transitionSettings?.easing ?? 'ease-in-out',
'--overlay-color': transitionSettings?.overlayColor ?? '#000000',
}) as React.CSSProperties,
[
transitionSettings?.durationMs,
transitionSettings?.easing,
transitionSettings?.overlayColor,
],
);
return {
// Current state
phase: state.phase,
state,
// Current page URLs
currentImageUrl: state.currentImageUrl,
currentVideoUrl: state.currentVideoUrl,
currentAudioUrl: state.currentAudioUrl,
// Previous page URLs
previousImageUrl: state.previousImageUrl,
previousVideoUrl: state.previousVideoUrl,
// Safari black flash prevention
lastKnownBgUrl: state.lastKnownBgUrl,
// Derived states
...derived,
// Video buffering
isVideoBuffering: state.isVideoBuffering,
// Transition style
transitionStyle,
// Actions
navigateToPage,
onBackgroundReady,
onTransitionEnded,
resetBackgroundReady,
clearPreviousBackground,
setBackgroundDirectly,
resetToIdle,
onVideoBufferStateChange,
markBackgroundReady,
startTransition,
};
}

View File

@ -1,553 +0,0 @@
/**
* 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;
}
/**
* Decode an image from URL to ensure it's ready for display.
* Used for blob URLs that are already loaded but need decoding.
* Returns a promise that resolves when the image is decoded.
*
* Safari-specific: waits extra frame after decode to ensure pixels are painted.
*/
const decodeImage = (url: string): Promise<void> => {
return new Promise((resolve) => {
if (!url) {
resolve();
return;
}
const img = new window.Image();
const safariMode = isSafari();
const onReady = () => {
if (safariMode) {
scheduleAfterPaintSafari(() => resolve());
} else {
// For non-Safari, wait one paint frame after decode
scheduleAfterPaint(() => resolve());
}
};
img.onload = () => {
if (typeof img.decode === 'function') {
img.decode().then(onReady).catch(onReady); // Resolve even on decode error
} else {
onReady();
}
};
img.onerror = () => {
// Resolve even on error to not block navigation
onReady();
};
img.src = url;
});
};
/**
* 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;
}
// CRITICAL: Resolve URLs BEFORE setting isSwitching
// This ensures the new background is ready when the crossfade animation starts.
// If we set isSwitching first, the animation begins with opacity:0 but the new
// content isn't ready yet, causing a shorter/faster perceived animation.
const [imageUrl, videoUrl, audioUrl] = await Promise.all([
resolveToDisplayUrl(targetPage.background_image_url),
resolveMediaUrl(targetPage.background_video_url),
resolveMediaUrl(targetPage.background_audio_url),
]);
// Save current backgrounds as previous for overlay (use refs to avoid dependencies)
if (currentBgImageUrlRef.current) {
setPreviousBgImageUrl(currentBgImageUrlRef.current);
}
if (currentBgVideoUrlRef.current) {
setPreviousBgVideoUrl(currentBgVideoUrlRef.current);
}
// Set new backgrounds BEFORE triggering animation
setCurrentBgImageUrl(imageUrl);
setCurrentBgVideoUrl(videoUrl);
setCurrentBgAudioUrl(audioUrl);
// NOW trigger the crossfade animation
// The new background is already set, so animation shows the actual crossfade
setIsSwitching(true);
setIsNewBgReady(false);
// Notify caller that backgrounds are set
onSwitched?.();
// For blob URLs, decode the image before marking ready
// This ensures the image is actually decoded and ready for display,
// matching the constructor behavior where images have a render cycle head start
if (imageUrl.startsWith('blob:') || !imageUrl) {
decodeImage(imageUrl).then(() => {
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,
};
}

View File

@ -1,13 +1,13 @@
/** /**
* usePreloadOrchestrator Hook * usePreloadOrchestrator Hook
* *
* Main coordinator for online mode asset preloading. * Coordinates asset preloading based on navigation.
* Manages the priority queue and orchestrates downloads based on navigation. * Preloads current page assets and outgoing transition videos.
*/ */
import { useEffect, useRef, useCallback, useState, useMemo } from 'react'; import { useEffect, useRef, useCallback, useState } from 'react';
import { useNeighborGraph } from './useNeighborGraph';
import { useNetworkAware } from './useNetworkAware'; import { useNetworkAware } from './useNetworkAware';
import { extractElementAssets } from '../lib/assetCache';
import { downloadEventBus } from '../lib/offline/DownloadEventBus'; import { downloadEventBus } from '../lib/offline/DownloadEventBus';
import { downloadManager } from '../lib/offline/DownloadManager'; import { downloadManager } from '../lib/offline/DownloadManager';
import { StorageManager } from '../lib/offline/StorageManager'; import { StorageManager } from '../lib/offline/StorageManager';
@ -36,62 +36,44 @@ interface UsePreloadOrchestratorOptions {
currentPageId: string | null; currentPageId: string | null;
pageHistory?: string[]; pageHistory?: string[];
enabled?: boolean; enabled?: boolean;
maxNeighborDepth?: number;
} }
interface PreloadQueueItem { interface PreloadQueueItem {
id: string; id: string;
url: string; url: string;
storageKey?: string; // Original storage key for presigned URL cache invalidation storageKey?: string;
priority: number; priority: number;
assetType: 'image' | 'video' | 'audio' | 'transition' | 'other'; assetType: 'image' | 'video' | 'audio' | 'transition' | 'other';
pageId: string; pageId: string;
} }
/** Preload phase for UI feedback */
export type PreloadPhase = export type PreloadPhase =
| 'idle' | 'idle'
| 'phase1_current_page' | 'phase1_current_page'
| 'phase2_transitions' | 'phase2_transitions'
| 'phase3_neighbors'
| 'complete'; | 'complete';
interface UsePreloadOrchestratorResult { interface UsePreloadOrchestratorResult {
isPreloading: boolean; isPreloading: boolean;
preloadedUrls: Set<string>; preloadedUrls: Set<string>;
queueLength: number; queueLength: number;
/** Version counter that increments when blob URLs become ready (triggers re-renders) */
readyUrlsVersion: number; readyUrlsVersion: number;
preloadAsset: (url: string, priority?: number) => void; preloadAsset: (url: string, priority?: number) => void;
clearQueue: () => void; clearQueue: () => void;
getCachedBlobUrl: (url: string) => Promise<string | null>; getCachedBlobUrl: (url: string) => Promise<string | null>;
isUrlPreloaded: (url: string) => Promise<boolean>; isUrlPreloaded: (url: string) => Promise<boolean>;
/** Instant lookup - returns decoded blob URL or null */
getReadyBlobUrl: (url: string) => string | null; getReadyBlobUrl: (url: string) => string | null;
/** Instant lookup - returns raw Blob for creating fresh blob URLs (used by transitions) */
getReadyBlob: (url: string) => Blob | null; getReadyBlob: (url: string) => Blob | null;
/** Whether all neighbor page backgrounds are ready for instant navigation */
areNeighborBackgroundsReady: boolean;
/** Current preload phase (for UI feedback) */
currentPhase: PreloadPhase; currentPhase: PreloadPhase;
/** Progress within current phase (0-100) */
phaseProgress: number; phaseProgress: number;
/** Whether current page is ready to display */
isCurrentPageReady: boolean; isCurrentPageReady: boolean;
/** Whether outgoing transitions are ready */
areTransitionsReady: boolean; areTransitionsReady: boolean;
} }
/**
* Generate a unique ID for preload jobs
*/
const generateJobId = (): string => { const generateJobId = (): string => {
return `preload-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; return `preload-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}; };
/**
* Map asset type string to AssetType enum expected by DownloadManager
*/
const mapAssetType = ( const mapAssetType = (
assetType: 'image' | 'video' | 'audio' | 'transition' | 'other', assetType: 'image' | 'video' | 'audio' | 'transition' | 'other',
): 'image' | 'video' | 'audio' | 'transition' | 'other' => { ): 'image' | 'video' | 'audio' | 'transition' | 'other' => {
@ -101,22 +83,12 @@ const mapAssetType = (
export function usePreloadOrchestrator( export function usePreloadOrchestrator(
options: UsePreloadOrchestratorOptions, options: UsePreloadOrchestratorOptions,
): UsePreloadOrchestratorResult { ): UsePreloadOrchestratorResult {
const { const { pages, pageLinks, elements, currentPageId, enabled = true } = options;
pages,
pageLinks,
elements,
currentPageId,
enabled = true,
maxNeighborDepth = 1, // Only preload immediate neighbors by default
} = options;
const [isPreloading, setIsPreloading] = useState(false); const [isPreloading, setIsPreloading] = useState(false);
const [preloadedUrls] = useState(() => new Set<string>()); const [preloadedUrls] = useState(() => new Set<string>());
const [queueLength, setQueueLength] = useState(0); const [queueLength, setQueueLength] = useState(0);
// Version counter to trigger re-renders when blob URLs become ready
const [readyUrlsVersion, setReadyUrlsVersion] = useState(0); const [readyUrlsVersion, setReadyUrlsVersion] = useState(0);
// Phase tracking for UI feedback
const [currentPhase, setCurrentPhase] = useState<PreloadPhase>('idle'); const [currentPhase, setCurrentPhase] = useState<PreloadPhase>('idle');
const [phaseProgress, setPhaseProgress] = useState(0); const [phaseProgress, setPhaseProgress] = useState(0);
@ -125,59 +97,14 @@ export function usePreloadOrchestrator(
const lastPreloadedPageRef = useRef<string | null>(null); const lastPreloadedPageRef = useRef<string | null>(null);
const lastPreloadedLinksCountRef = useRef<number>(0); const lastPreloadedLinksCountRef = useRef<number>(0);
// Use neighbor graph for determining what to preload
const neighborGraph = useNeighborGraph({
pages,
pageLinks,
elements,
maxDepth: maxNeighborDepth,
});
// Use network info for adaptive preloading
const { networkInfo } = useNetworkAware(); const { networkInfo } = useNetworkAware();
// Compute whether all neighbor page backgrounds are ready for instant navigation
// Uses readyUrlsVersion to trigger re-computation when blob URLs become ready
const areNeighborBackgroundsReady = useMemo(() => {
if (!currentPageId || !enabled) return true; // Assume ready if disabled
// Use existing neighborGraph infrastructure
const neighbors = neighborGraph.getNeighbors(currentPageId, 1);
if (neighbors.length === 0) return true; // No neighbors = ready
// Check if ALL neighbor background images have READY blob URLs
// IMPORTANT: Use downloadManager.getReadyBlobUrl() NOT preloadedUrls.has()
// preloadedUrls contains URLs that are QUEUED, not URLs that are READY
// We need to check if the blob URL is actually available for instant display
return neighbors.every(({ pageId }) => {
const page = pages.find((p) => p.id === pageId);
if (!page) return true; // Page not found = skip
// If page has background image, check if blob URL is actually ready
if (page.background_image_url) {
const imageKey = extractStoragePath(page.background_image_url);
// Check if blob URL is ready (not just queued)
if (!downloadManager.getReadyBlobUrl(imageKey)) return false;
}
// If page has only video background (no image), it can stream - consider ready
// This allows navigation to video-only pages without blocking
return true;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentPageId, enabled, neighborGraph, pages, readyUrlsVersion]);
// Subscribe to blob URL ready events from DownloadManager
useEffect(() => { useEffect(() => {
const unsubscribe = downloadEventBus.on( const unsubscribe = downloadEventBus.on(
OFFLINE_CONFIG.events.blobUrlReady as Parameters< OFFLINE_CONFIG.events.blobUrlReady as Parameters<
typeof downloadEventBus.on typeof downloadEventBus.on
>[0], >[0],
(data: BlobUrlReadyEvent) => { (data: BlobUrlReadyEvent) => {
logger.info('[PRELOAD] Blob URL ready from DownloadManager', {
storageKey: data.storageKey.slice(-50),
});
preloadedUrls.add(data.storageKey); preloadedUrls.add(data.storageKey);
setReadyUrlsVersion((v) => v + 1); setReadyUrlsVersion((v) => v + 1);
}, },
@ -185,14 +112,12 @@ export function usePreloadOrchestrator(
return unsubscribe; return unsubscribe;
}, [preloadedUrls]); }, [preloadedUrls]);
// Cleanup blob URLs on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
downloadManager.clearBlobUrls(); downloadManager.clearBlobUrls();
}; };
}, []); }, []);
// Process the queue using DownloadManager
const processQueue = useCallback(async () => { const processQueue = useCallback(async () => {
if (isProcessingRef.current) return; if (isProcessingRef.current) return;
if (!networkInfo.isOnline) return; if (!networkInfo.isOnline) return;
@ -204,41 +129,29 @@ export function usePreloadOrchestrator(
isProcessingRef.current = true; isProcessingRef.current = true;
setIsPreloading(true); setIsPreloading(true);
// Process all items in queue
while (queueRef.current.length > 0) { while (queueRef.current.length > 0) {
const item = queueRef.current.shift(); const item = queueRef.current.shift();
if (!item) break; if (!item) break;
setQueueLength(queueRef.current.length); setQueueLength(queueRef.current.length);
// Get canonical storage key
const storageKey = item.storageKey || extractStoragePath(item.url); const storageKey = item.storageKey || extractStoragePath(item.url);
// Skip if already preloaded
if (preloadedUrls.has(storageKey)) { if (preloadedUrls.has(storageKey)) {
continue; continue;
} }
logger.info('[PRELOAD] Queuing with DownloadManager', {
url: item.url.slice(-50),
storageKey: storageKey.slice(-50),
assetType: item.assetType,
priority: item.priority,
});
// Use DownloadManager for unified download and blob URL creation
// DownloadManager automatically handles presigned URL → proxy fallback
downloadManager downloadManager
.addJob({ .addJob({
assetId: item.id, assetId: item.id,
projectId: '', // Not needed for online preload projectId: '',
url: item.url, url: item.url,
filename: item.url.split('/').pop() || 'asset', filename: item.url.split('/').pop() || 'asset',
variantType: 'original', variantType: 'original',
assetType: mapAssetType(item.assetType), assetType: mapAssetType(item.assetType),
priority: item.priority, priority: item.priority,
storageKey, storageKey,
persist: false, // Don't persist for online preload (in-memory only) persist: false,
}) })
.then(() => { .then(() => {
if (isPresignedUrl(item.url)) { if (isPresignedUrl(item.url)) {
@ -259,12 +172,10 @@ export function usePreloadOrchestrator(
isProcessingRef.current = false; isProcessingRef.current = false;
}, [networkInfo.isOnline, preloadedUrls]); }, [networkInfo.isOnline, preloadedUrls]);
// Add item to queue with priority sorting
const addToQueue = useCallback( const addToQueue = useCallback(
(item: PreloadQueueItem) => { (item: PreloadQueueItem) => {
const storageKey = item.storageKey || extractStoragePath(item.url); const storageKey = item.storageKey || extractStoragePath(item.url);
// Skip if already in queue or preloaded
if ( if (
preloadedUrls.has(storageKey) || preloadedUrls.has(storageKey) ||
queueRef.current.some( queueRef.current.some(
@ -274,15 +185,6 @@ export function usePreloadOrchestrator(
return; return;
} }
logger.info('[PRELOAD] Adding to queue', {
url: item.url.slice(-60),
storageKey: storageKey.slice(-50),
assetType: item.assetType,
priority: item.priority,
queueLength: queueRef.current.length + 1,
});
// Insert in priority order (higher priority first)
const insertIndex = queueRef.current.findIndex( const insertIndex = queueRef.current.findIndex(
(q) => q.priority < item.priority, (q) => q.priority < item.priority,
); );
@ -299,7 +201,6 @@ export function usePreloadOrchestrator(
[preloadedUrls, processQueue], [preloadedUrls, processQueue],
); );
// Manual preload function
const preloadAsset = useCallback( const preloadAsset = useCallback(
(url: string, priority = 100) => { (url: string, priority = 100) => {
addToQueue({ addToQueue({
@ -313,14 +214,11 @@ export function usePreloadOrchestrator(
[addToQueue, currentPageId], [addToQueue, currentPageId],
); );
// Clear queue
const clearQueue = useCallback(() => { const clearQueue = useCallback(() => {
queueRef.current = []; queueRef.current = [];
setQueueLength(0); setQueueLength(0);
}, []); }, []);
// Get a cached asset as a blob URL (for video playback)
// StorageManager.getAsset checks both IndexedDB (large files ≥ 5MB) and Cache API (small files)
const getCachedBlobUrl = useCallback( const getCachedBlobUrl = useCallback(
async (url: string): Promise<string | null> => { async (url: string): Promise<string | null> => {
try { try {
@ -336,33 +234,24 @@ export function usePreloadOrchestrator(
[], [],
); );
// Check if URL is preloaded (in cache)
const isUrlPreloaded = useCallback( const isUrlPreloaded = useCallback(
async (url: string): Promise<boolean> => { async (url: string): Promise<boolean> => {
const storageKey = extractStoragePath(url); const storageKey = extractStoragePath(url);
// First check in-memory set
if (preloadedUrls.has(storageKey)) return true; if (preloadedUrls.has(storageKey)) return true;
// Then check via StorageManager
return StorageManager.hasAsset(storageKey); return StorageManager.hasAsset(storageKey);
}, },
[preloadedUrls], [preloadedUrls],
); );
// Instant lookup - returns decoded blob URL or null (O(1) Map lookup)
// Uses DownloadManager's unified blob URL cache
const getReadyBlobUrl = useCallback((url: string): string | null => { const getReadyBlobUrl = useCallback((url: string): string | null => {
return downloadManager.getReadyBlobUrl(url); return downloadManager.getReadyBlobUrl(url);
}, []); }, []);
// Instant lookup - returns raw Blob for creating fresh blob URLs (O(1) Map lookup)
// Used by transitions to avoid decoder state issues with pre-created blob URLs
const getReadyBlob = useCallback((url: string): Blob | null => { const getReadyBlob = useCallback((url: string): Blob | null => {
return downloadManager.getReadyBlob(url); return downloadManager.getReadyBlob(url);
}, []); }, []);
// Initialize ready blob URLs from cache for current page's assets // Initialize ready blob URLs from cache for current page's assets
// This ensures getReadyBlobUrl works on the first render for both backgrounds and element icons
useEffect(() => { useEffect(() => {
if (!currentPageId) return; if (!currentPageId) return;
@ -370,14 +259,12 @@ export function usePreloadOrchestrator(
if (!currentPage) return; if (!currentPage) return;
const initializeFromCache = async () => { const initializeFromCache = async () => {
// Collect background URLs
const bgUrls = [ const bgUrls = [
currentPage.background_image_url, currentPage.background_image_url,
currentPage.background_video_url, currentPage.background_video_url,
currentPage.background_audio_url, currentPage.background_audio_url,
].filter(Boolean) as string[]; ].filter(Boolean) as string[];
// Collect element asset URLs (icons, images, etc.) from current page
const currentPageElements = elements.filter( const currentPageElements = elements.filter(
(el) => el.pageId === currentPageId, (el) => el.pageId === currentPageId,
); );
@ -390,7 +277,6 @@ export function usePreloadOrchestrator(
? JSON.parse(element.content_json) ? JSON.parse(element.content_json)
: element.content_json; : element.content_json;
// Extract URLs from known asset fields
const urlFields = PRELOAD_CONFIG.assetFields.all as readonly string[]; const urlFields = PRELOAD_CONFIG.assetFields.all as readonly string[];
const checkObject = (obj: Record<string, unknown>) => { const checkObject = (obj: Record<string, unknown>) => {
if (!obj || typeof obj !== 'object') return; if (!obj || typeof obj !== 'object') return;
@ -412,20 +298,16 @@ export function usePreloadOrchestrator(
} }
}); });
// Initialize all URLs from cache via DownloadManager
const allUrls = [...bgUrls, ...elementAssetUrls]; const allUrls = [...bgUrls, ...elementAssetUrls];
for (const storagePath of allUrls) { for (const storagePath of allUrls) {
const storageKey = extractStoragePath(storagePath); const storageKey = extractStoragePath(storagePath);
// Skip if already ready
if (downloadManager.getReadyBlobUrl(storageKey)) continue; if (downloadManager.getReadyBlobUrl(storageKey)) continue;
// Check if cached and create blob URL if so
const fullUrl = resolveAssetPlaybackUrl(storagePath); const fullUrl = resolveAssetPlaybackUrl(storagePath);
const hasAsset = await StorageManager.hasAsset(storageKey); const hasAsset = await StorageManager.hasAsset(storageKey);
if (hasAsset) { if (hasAsset) {
// DownloadManager will create blob URL from cached asset
await downloadManager.addJob({ await downloadManager.addJob({
assetId: `init-${storageKey}`, assetId: `init-${storageKey}`,
projectId: '', projectId: '',
@ -443,14 +325,12 @@ export function usePreloadOrchestrator(
initializeFromCache(); initializeFromCache();
}, [currentPageId, pages, elements]); }, [currentPageId, pages, elements]);
// React to page changes - preload neighbors // React to page changes - preload current page assets and transitions
useEffect(() => { useEffect(() => {
if (!enabled || !currentPageId || !networkInfo.isOnline) { if (!enabled || !currentPageId || !networkInfo.isOnline) {
return; return;
} }
// Skip if we already preloaded for this page with the same data
// Re-preload if pageLinks count changed (data just loaded)
const currentLinksCount = pageLinks.length; const currentLinksCount = pageLinks.length;
const samePageAndData = const samePageAndData =
lastPreloadedPageRef.current === currentPageId && lastPreloadedPageRef.current === currentPageId &&
@ -462,26 +342,17 @@ export function usePreloadOrchestrator(
lastPreloadedPageRef.current = currentPageId; lastPreloadedPageRef.current = currentPageId;
lastPreloadedLinksCountRef.current = currentLinksCount; lastPreloadedLinksCountRef.current = currentLinksCount;
logger.info('[PRELOAD] Starting preload for page', {
currentPageId,
maxNeighborDepth,
});
// Get prioritized assets based on current page
const assets = neighborGraph.getPrioritizedAssets(
currentPageId,
maxNeighborDepth,
);
logger.info('[PRELOAD] Found assets from neighbor graph', {
assetCount: assets.length,
assets: assets.map((a) => ({ type: a.assetType, url: a.url.slice(-50) })),
});
// Collect all raw storage paths that need presigning
const storagePaths: string[] = [];
const currentPage = pages.find((p) => p.id === currentPageId); const currentPage = pages.find((p) => p.id === currentPageId);
// Extract current page element assets directly
const currentPageElements = elements.filter(
(el) => el.pageId === currentPageId,
);
const elementAssets = extractElementAssets(currentPageElements, currentPageId);
// Collect storage paths for presigned URL batch request
const storagePaths: string[] = [];
if ( if (
currentPage?.background_image_url && currentPage?.background_image_url &&
isRelativeStoragePath(currentPage.background_image_url) isRelativeStoragePath(currentPage.background_image_url)
@ -501,60 +372,44 @@ export function usePreloadOrchestrator(
storagePaths.push(currentPage.background_audio_url); storagePaths.push(currentPage.background_audio_url);
} }
assets.forEach((asset) => { elementAssets.forEach((asset) => {
if (isRelativeStoragePath(asset.url)) { if (isRelativeStoragePath(asset.storageKey)) {
storagePaths.push(asset.url); storagePaths.push(asset.storageKey);
} }
}); });
// Always collect neighbor background URLs for presigning // Add outgoing transition video URLs (forward and reverse)
// This ensures instant navigation to neighbor pages // Reverse videos are preloaded here so they're cached when user navigates and clicks back
const neighbors = neighborGraph.getNeighbors(currentPageId, 1); const outgoingTransitions = pageLinks.filter(
neighbors.forEach(({ pageId }) => { (link) =>
const page = pages.find((p) => p.id === pageId); link.from_pageId === currentPageId &&
if ( (link.transition?.video_url || link.transition?.reverse_video_url),
page?.background_image_url && );
isRelativeStoragePath(page.background_image_url)
) { outgoingTransitions.forEach((link) => {
storagePaths.push(page.background_image_url); const forwardVideoUrl = link.transition?.video_url;
const reverseVideoUrl = link.transition?.reverse_video_url;
if (forwardVideoUrl && isRelativeStoragePath(forwardVideoUrl)) {
storagePaths.push(forwardVideoUrl);
} }
// Always collect neighbor video URLs for smooth transitions if (reverseVideoUrl && isRelativeStoragePath(reverseVideoUrl)) {
if ( storagePaths.push(reverseVideoUrl);
page?.background_video_url &&
isRelativeStoragePath(page.background_video_url)
) {
storagePaths.push(page.background_video_url);
}
// Also collect neighbor audio URLs
if (
page?.background_audio_url &&
isRelativeStoragePath(page.background_audio_url)
) {
storagePaths.push(page.background_audio_url);
} }
}); });
// Batch fetch presigned URLs, then add to queue
// Helper to resolve URL - prefer presigned if available, else fallback to proxy
const resolveUrl = ( const resolveUrl = (
storageKey: string, storageKey: string,
presignedUrls: Record<string, string>, presignedUrls: Record<string, string>,
): string => { ): string => {
// Use presigned URL if available (will be tested on actual download)
if (presignedUrls[storageKey]) { if (presignedUrls[storageKey]) {
return presignedUrls[storageKey]; return presignedUrls[storageKey];
} }
// Fallback to resolveAssetPlaybackUrl (will use proxy)
return resolveAssetPlaybackUrl(storageKey); return resolveAssetPlaybackUrl(storageKey);
}; };
// Two-phase preloading: current page first, then neighbors
// All assets use full download with blob URLs for reliable playback
// (presigned URL streaming fails on mobile Safari/Chrome)
const addAssetsToQueue = async ( const addAssetsToQueue = async (
presignedUrls: Record<string, string> = {}, presignedUrls: Record<string, string> = {},
) => { ) => {
// Helper to create download job
const createDownloadJob = ( const createDownloadJob = (
id: string, id: string,
storageKey: string, storageKey: string,
@ -568,21 +423,26 @@ export function usePreloadOrchestrator(
? storageKey ? storageKey
: extractStoragePath(resolvedUrl); : extractStoragePath(resolvedUrl);
// Skip if already preloaded // Check if already downloaded (blob exists) or download in progress
if (preloadedUrls.has(normalizedKey)) return null; if (preloadedUrls.has(normalizedKey)) {
// Verify the blob actually exists - if not, allow re-download
const existingBlob = downloadManager.getReadyBlob(normalizedKey);
if (existingBlob) {
return null; // Already cached, skip
}
// Key was in Set but blob doesn't exist - remove and re-download
preloadedUrls.delete(normalizedKey);
}
// Mark as in-progress to prevent duplicate downloads
preloadedUrls.add(normalizedKey); preloadedUrls.add(normalizedKey);
// Enable streaming for media assets (video, audio, transitions)
// This allows playback to start immediately using presigned URL
// while full download continues in background for caching
const enableStreaming = const enableStreaming =
PRELOAD_CONFIG.streaming.enabled && PRELOAD_CONFIG.streaming.enabled &&
(assetType === 'video' || (assetType === 'video' ||
assetType === 'audio' || assetType === 'audio' ||
assetType === 'transition'); assetType === 'transition');
// DownloadManager always creates blob URLs for reliable playback
return downloadManager return downloadManager
.addJob({ .addJob({
assetId: id, assetId: id,
@ -607,6 +467,8 @@ export function usePreloadOrchestrator(
} }
}) })
.catch((err) => { .catch((err) => {
// Download failed - remove from Set so it can be retried
preloadedUrls.delete(normalizedKey);
logger.error('[PRELOAD] Download failed', { logger.error('[PRELOAD] Download failed', {
url: resolvedUrl.slice(-50), url: resolvedUrl.slice(-50),
error: err?.message, error: err?.message,
@ -614,19 +476,14 @@ export function usePreloadOrchestrator(
}); });
}; };
// ═══════════════════════════════════════════════════════════════════ // Phase 1: Current Page Assets (blocking for images only)
// PHASE 1: Current Page Assets (BLOCKING)
// Priority: Images first (small, essential), then videos (can stream)
// ═══════════════════════════════════════════════════════════════════
setCurrentPhase('phase1_current_page'); setCurrentPhase('phase1_current_page');
setPhaseProgress(0); setPhaseProgress(0);
logger.info('[PRELOAD] Phase 1: Loading current page backgrounds');
const phase1Jobs: Promise<void>[] = []; const phase1BlockingJobs: Promise<void>[] = [];
let phase1Total = 0; let phase1Total = 0;
let phase1Completed = 0; let phase1Completed = 0;
// Current page IMAGE background - CRITICAL (wait for this)
if (currentPage?.background_image_url) { if (currentPage?.background_image_url) {
phase1Total++; phase1Total++;
const job = createDownloadJob( const job = createDownloadJob(
@ -636,7 +493,7 @@ export function usePreloadOrchestrator(
'image', 'image',
); );
if (job) { if (job) {
phase1Jobs.push( phase1BlockingJobs.push(
job.then(() => { job.then(() => {
phase1Completed++; phase1Completed++;
setPhaseProgress( setPhaseProgress(
@ -647,231 +504,136 @@ export function usePreloadOrchestrator(
} }
} }
// Current page VIDEO background - start download (can stream) // Current page element images (blocking)
if (currentPage?.background_video_url) { const currentPageImageAssets = elementAssets.filter(
(asset) => asset.assetType === 'image',
);
currentPageImageAssets.forEach((asset) => {
phase1Total++; phase1Total++;
const job = createDownloadJob( const job = createDownloadJob(
`elem-img-${asset.storageKey}`,
asset.storageKey,
PRELOAD_CONFIG.priority.currentPage + PRELOAD_CONFIG.priority.assetType.image,
'image',
);
if (job) {
phase1BlockingJobs.push(
job.then(() => {
phase1Completed++;
setPhaseProgress(
phase1Total > 0 ? (phase1Completed / phase1Total) * 100 : 100,
);
}),
);
}
});
// Non-blocking: videos and audio start downloading but don't wait
if (currentPage?.background_video_url) {
createDownloadJob(
`bg-vid-${currentPageId}`, `bg-vid-${currentPageId}`,
currentPage.background_video_url, currentPage.background_video_url,
PRELOAD_CONFIG.priority.currentPage + 150, PRELOAD_CONFIG.priority.currentPage + 150,
'video', 'video',
); );
if (job) {
phase1Jobs.push(
job.then(() => {
phase1Completed++;
setPhaseProgress(
phase1Total > 0 ? (phase1Completed / phase1Total) * 100 : 100,
);
}),
);
}
} }
// Current page AUDIO background - start download
if (currentPage?.background_audio_url) { if (currentPage?.background_audio_url) {
phase1Total++; createDownloadJob(
const job = 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',
); );
if (job) {
phase1Jobs.push(
job.then(() => {
phase1Completed++;
setPhaseProgress(
phase1Total > 0 ? (phase1Completed / phase1Total) * 100 : 100,
);
}),
);
}
} }
// Wait for Phase 1 to complete if (phase1BlockingJobs.length > 0) {
const phase1Start = Date.now(); await Promise.all(phase1BlockingJobs);
if (phase1Jobs.length > 0) {
logger.info('[PRELOAD] Waiting for current page assets', {
count: phase1Jobs.length,
});
await Promise.all(phase1Jobs);
logger.info('[PRELOAD] Phase 1 complete', {
elapsed: `${Date.now() - phase1Start}ms`,
});
} else {
logger.info('[PRELOAD] Phase 1 complete (no backgrounds)');
} }
// ═══════════════════════════════════════════════════════════════════ // Phase 2: Outgoing Transition Videos (preload for instant playback)
// PHASE 2: Outgoing Transition Videos (BLOCKING)
// Load transitions FROM current page BEFORE neighbors
// ═══════════════════════════════════════════════════════════════════
setCurrentPhase('phase2_transitions'); setCurrentPhase('phase2_transitions');
setPhaseProgress(0); setPhaseProgress(0);
logger.info('[PRELOAD] Phase 2: Loading outgoing transitions');
const phase2Jobs: Promise<void>[] = []; const phase2Jobs: Promise<void>[] = [];
let phase2Total = 0; let phase2Total = 0;
let phase2Completed = 0; let phase2Completed = 0;
// Find all transition videos from current page // Preload outgoing transition videos (forward + reverse)
const outgoingTransitions = pageLinks.filter(
(link) =>
link.from_pageId === currentPageId && link.transition?.video_url,
);
outgoingTransitions.forEach((link) => { outgoingTransitions.forEach((link) => {
const transitionVideoUrl = link.transition?.video_url; const forwardVideoUrl = link.transition?.video_url;
if (!transitionVideoUrl) return; const reverseVideoUrl = link.transition?.reverse_video_url;
phase2Total++;
const job = createDownloadJob( // Preload forward transition video
`trans-${link.from_pageId}-${link.to_pageId}`, if (forwardVideoUrl) {
transitionVideoUrl, phase2Total++;
PRELOAD_CONFIG.priority.currentPage + const job = createDownloadJob(
PRELOAD_CONFIG.priority.assetType.transition, `trans-fwd-${link.from_pageId}-${link.to_pageId}`,
'transition', forwardVideoUrl,
); PRELOAD_CONFIG.priority.currentPage +
if (job) { PRELOAD_CONFIG.priority.assetType.transition,
phase2Jobs.push( 'transition',
job.then(() => {
phase2Completed++;
setPhaseProgress(
phase2Total > 0 ? (phase2Completed / phase2Total) * 100 : 100,
);
}),
); );
if (job) {
phase2Jobs.push(
job.then(() => {
phase2Completed++;
setPhaseProgress(
phase2Total > 0 ? (phase2Completed / phase2Total) * 100 : 100,
);
}),
);
}
}
// Preload reverse transition video (for potential back navigation from target)
if (reverseVideoUrl) {
phase2Total++;
const job = createDownloadJob(
`trans-rev-${link.from_pageId}-${link.to_pageId}`,
reverseVideoUrl,
PRELOAD_CONFIG.priority.currentPage +
PRELOAD_CONFIG.priority.assetType.transition - 10,
'transition',
);
if (job) {
phase2Jobs.push(
job.then(() => {
phase2Completed++;
setPhaseProgress(
phase2Total > 0 ? (phase2Completed / phase2Total) * 100 : 100,
);
}),
);
}
} }
}); });
// Wait for Phase 2 to complete
const phase2Start = Date.now();
if (phase2Jobs.length > 0) { if (phase2Jobs.length > 0) {
logger.info('[PRELOAD] Waiting for transition videos', {
count: phase2Jobs.length,
});
await Promise.all(phase2Jobs); await Promise.all(phase2Jobs);
logger.info('[PRELOAD] Phase 2 complete', {
elapsed: `${Date.now() - phase2Start}ms`,
});
} else {
logger.info('[PRELOAD] Phase 2 complete (no transitions)');
} }
// ═══════════════════════════════════════════════════════════════════
// PHASE 3: Neighbor Pages (NON-BLOCKING)
// Background download for smooth subsequent navigation
// ═══════════════════════════════════════════════════════════════════
setCurrentPhase('phase3_neighbors');
setPhaseProgress(0);
logger.info('[PRELOAD] Phase 3: Preloading neighbors');
// Current page element assets (non-blocking)
const currentPageAssets = assets.filter(
(asset) => asset.pageId === currentPageId,
);
currentPageAssets.forEach((asset) => {
createDownloadJob(
generateJobId(),
asset.url,
asset.priority,
asset.assetType,
);
});
// Neighbor page element assets
const neighborAssets = assets.filter(
(asset) => asset.pageId !== currentPageId,
);
neighborAssets.forEach((asset) => {
createDownloadJob(
generateJobId(),
asset.url,
asset.priority,
asset.assetType,
);
});
// Neighbor background assets
const neighbors = neighborGraph.getNeighbors(currentPageId, 1);
neighbors.forEach(({ pageId }) => {
const page = pages.find((p) => p.id === pageId);
if (page?.background_image_url) {
createDownloadJob(
`bg-img-${pageId}`,
page.background_image_url,
PRELOAD_CONFIG.priority.neighborBase + 100,
'image',
);
}
if (page?.background_video_url) {
createDownloadJob(
`bg-vid-${pageId}`,
page.background_video_url,
PRELOAD_CONFIG.priority.neighborBase + 50,
'video',
);
}
if (page?.background_audio_url) {
createDownloadJob(
`bg-aud-${pageId}`,
page.background_audio_url,
PRELOAD_CONFIG.priority.neighborBase + 30,
'audio',
);
}
});
logger.info('[PRELOAD] Phase 3: Neighbor assets queued');
setCurrentPhase('complete'); setCurrentPhase('complete');
setPhaseProgress(100); setPhaseProgress(100);
}; };
// If there are storage paths to presign, fetch them first
if (storagePaths.length > 0) { if (storagePaths.length > 0) {
logger.info('[PRELOAD] Fetching presigned URLs', {
count: storagePaths.length,
});
queuePresignedUrls(storagePaths) queuePresignedUrls(storagePaths)
.then(async () => { .then(async () => {
logger.info('[PRELOAD] Presigned URLs fetched, adding to queue');
// Note: Don't call markPresignedUrlsVerified() here - it's called after
// first successful download to verify CORS is configured properly
await addAssetsToQueue(); await addAssetsToQueue();
}) })
.catch(async (error) => { .catch(async () => {
logger.error(
'[PRELOAD] Failed to fetch presigned URLs, falling back to proxy',
{
error: error?.message,
},
);
// Fallback: add to queue without presigned URLs (will use backend proxy)
await addAssetsToQueue(); await addAssetsToQueue();
}); });
} else { } else {
// No storage paths to presign, add directly to queue
addAssetsToQueue(); addAssetsToQueue();
} }
}, [ }, [enabled, currentPageId, networkInfo.isOnline, elements, pages, pageLinks]);
enabled,
currentPageId,
networkInfo.isOnline,
neighborGraph,
pages,
pageLinks,
addToQueue,
maxNeighborDepth,
]);
// Compute derived state values for UI feedback
const isCurrentPageReady = const isCurrentPageReady =
currentPhase === 'phase2_transitions' || currentPhase === 'phase2_transitions' || currentPhase === 'complete';
currentPhase === 'phase3_neighbors' ||
currentPhase === 'complete';
const areTransitionsReady = const areTransitionsReady = currentPhase === 'complete';
currentPhase === 'phase3_neighbors' || currentPhase === 'complete';
return { return {
isPreloading, isPreloading,
@ -884,7 +646,6 @@ export function usePreloadOrchestrator(
isUrlPreloaded, isUrlPreloaded,
getReadyBlobUrl, getReadyBlobUrl,
getReadyBlob, getReadyBlob,
areNeighborBackgroundsReady,
currentPhase, currentPhase,
phaseProgress, phaseProgress,
isCurrentPageReady, isCurrentPageReady,

File diff suppressed because it is too large Load Diff

View File

@ -7,6 +7,7 @@
import { useState, useCallback, useMemo } from 'react'; import { useState, useCallback, useMemo } from 'react';
import type { TransitionPreviewState } from '../types/presentation'; import type { TransitionPreviewState } from '../types/presentation';
import { logger } from '../lib/logger';
export type { TransitionPreviewState }; export type { TransitionPreviewState };
@ -128,6 +129,14 @@ export function useTransitionPreview({
isBack: direction === 'back', // Track for history management isBack: direction === 'back', // Track for history management
}; };
logger.info('[TRANSITION-PREVIEW] Setting preview state', {
videoUrl: previewState.videoUrl?.slice(-60),
storageKey: previewState.storageKey?.slice(-60),
reverseMode: previewState.reverseMode,
direction,
isBack: previewState.isBack,
});
setPreview(previewState); setPreview(previewState);
}, },
[isNavigationElementType, onError], [isNavigationElementType, onError],

View File

@ -0,0 +1,59 @@
/**
* Video Hooks Module
*
* Primitive hooks for video playback management.
* These hooks can be composed to build various video playback scenarios.
*/
// Primitive hooks
export {
useVideoEventManager,
type VideoEventType,
type VideoEventHandler,
type VideoEventHandlers,
type UseVideoEventManagerOptions,
} from './useVideoEventManager';
export {
useVideoBufferingState,
type UseVideoBufferingStateOptions,
type UseVideoBufferingStateResult,
} from './useVideoBufferingState';
export {
useVideoBlobUrl,
type PreloadCacheProvider,
type UseVideoBlobUrlOptions,
type UseVideoBlobUrlResult,
} from './useVideoBlobUrl';
export {
useVideoFirstFrame,
type UseVideoFirstFrameOptions,
type UseVideoFirstFrameResult,
} from './useVideoFirstFrame';
export {
useVideoErrorRecovery,
type UseVideoErrorRecoveryOptions,
type UseVideoErrorRecoveryResult,
} from './useVideoErrorRecovery';
export {
useVideoTimeouts,
type UseVideoTimeoutsResult,
} from './useVideoTimeouts';
// Composite hook
export {
useVideoPlaybackCore,
type UseVideoPlaybackCoreOptions,
type UseVideoPlaybackCoreResult,
} from './useVideoPlaybackCore';
// Video player hook for UI elements
export {
useVideoPlayer,
type UseVideoPlayerOptions,
type UseVideoPlayerResult,
} from './useVideoPlayer';

View File

@ -0,0 +1,185 @@
/**
* useVideoBlobUrl Hook
*
* Resolves video URLs with multi-tier fallback:
* 1. Fresh blob URL from cached Blob
* 2. Cached blob URL by storage key
* 3. Proxy URL fallback
*/
import { useState, useCallback, useRef, useEffect } from 'react';
import { logger } from '../../lib/logger';
import { resolveAssetPlaybackUrl } from '../../lib/assetUrl';
export interface PreloadCacheProvider {
getReadyBlob?: (key: string) => Blob | null;
getCachedBlobUrl?: (key: string) => Promise<string | null>;
}
export interface UseVideoBlobUrlOptions {
sourceUrl: string;
storageKey?: string;
preloadCache?: PreloadCacheProvider;
onResolved?: (url: string) => void;
onError?: (error: Error) => void;
}
export interface UseVideoBlobUrlResult {
resolvedUrl: string | null;
isResolving: boolean;
error: Error | null;
resolve: () => Promise<string>;
revoke: () => void;
}
export function useVideoBlobUrl({
sourceUrl,
storageKey,
preloadCache,
onResolved,
onError,
}: UseVideoBlobUrlOptions): UseVideoBlobUrlResult {
const [resolvedUrl, setResolvedUrl] = useState<string | null>(null);
const [isResolving, setIsResolving] = useState(false);
const [error, setError] = useState<Error | null>(null);
const lastLoadedBlobUrlRef = useRef<string | null>(null);
const lastLoadedSourceUrlRef = useRef<string | null>(null);
const onResolvedRef = useRef(onResolved);
const onErrorRef = useRef(onError);
useEffect(() => {
onResolvedRef.current = onResolved;
onErrorRef.current = onError;
});
const revoke = useCallback(() => {
if (lastLoadedBlobUrlRef.current) {
URL.revokeObjectURL(lastLoadedBlobUrlRef.current);
lastLoadedBlobUrlRef.current = null;
}
}, []);
const resolve = useCallback(async (): Promise<string> => {
const { getReadyBlob, getCachedBlobUrl } = preloadCache || {};
// 1. Try raw Blob by storage key
if (getReadyBlob && storageKey) {
const blob = getReadyBlob(storageKey);
if (blob) {
revoke();
const freshBlobUrl = URL.createObjectURL(blob);
lastLoadedBlobUrlRef.current = freshBlobUrl;
lastLoadedSourceUrlRef.current = sourceUrl;
return freshBlobUrl;
}
}
// 2. Try cached blob URL by storage key
if (getCachedBlobUrl && storageKey) {
try {
const cachedBlobUrl = await getCachedBlobUrl(storageKey);
if (cachedBlobUrl) {
lastLoadedBlobUrlRef.current = cachedBlobUrl;
lastLoadedSourceUrlRef.current = sourceUrl;
return cachedBlobUrl;
}
} catch {
// Fall through
}
}
// 3. Reuse cached blob URL if same source
if (
lastLoadedBlobUrlRef.current &&
lastLoadedSourceUrlRef.current === sourceUrl
) {
return lastLoadedBlobUrlRef.current;
}
if (lastLoadedBlobUrlRef.current) {
revoke();
}
// 4. Try raw Blob by source URL
if (getReadyBlob) {
const blob = getReadyBlob(sourceUrl);
if (blob) {
const freshBlobUrl = URL.createObjectURL(blob);
lastLoadedBlobUrlRef.current = freshBlobUrl;
lastLoadedSourceUrlRef.current = sourceUrl;
return freshBlobUrl;
}
}
// 5. Try cached blob URL by source URL
if (getCachedBlobUrl) {
try {
const cachedBlobUrl = await getCachedBlobUrl(sourceUrl);
if (cachedBlobUrl) {
lastLoadedBlobUrlRef.current = cachedBlobUrl;
lastLoadedSourceUrlRef.current = sourceUrl;
return cachedBlobUrl;
}
} catch {
// Fall through
}
}
// 6. Fallback to proxy URL
const downloadKey = storageKey || sourceUrl;
const proxyUrl = resolveAssetPlaybackUrl(downloadKey);
logger.info('[VIDEO-URL] Using proxy URL', {
storageKey: downloadKey.slice(-60),
});
return proxyUrl;
}, [sourceUrl, storageKey, preloadCache, revoke]);
useEffect(() => {
if (!sourceUrl) {
setResolvedUrl(null);
return;
}
let cancelled = false;
setIsResolving(true);
setError(null);
resolve()
.then((url) => {
if (!cancelled) {
setResolvedUrl(url);
setIsResolving(false);
onResolvedRef.current?.(url);
}
})
.catch((err) => {
if (!cancelled) {
const resolveError = err instanceof Error ? err : new Error(String(err));
setError(resolveError);
setIsResolving(false);
onErrorRef.current?.(resolveError);
}
});
return () => {
cancelled = true;
};
}, [sourceUrl, resolve]);
useEffect(() => {
return () => {
revoke();
};
}, [revoke]);
return {
resolvedUrl,
isResolving,
error,
resolve,
revoke,
};
}
export default useVideoBlobUrl;

View File

@ -0,0 +1,216 @@
/**
* useVideoBufferingState Hook
*
* Tracks video buffering state including:
* - Initial buffering (waiting for canplay)
* - Mid-playback buffering (waiting event)
* - Progress-based timeout detection
*/
import { useState, useRef, useCallback, useEffect, type RefObject } from 'react';
import { logger } from '../../lib/logger';
import { TRANSITION_CONFIG } from '../../config/transition.config';
export interface UseVideoBufferingStateOptions {
videoRef: RefObject<HTMLVideoElement | null>;
enabled?: boolean;
/** Custom no-progress timeout in ms (uses config default if not provided) */
noProgressMs?: number;
/** Custom check interval in ms (uses config default if not provided) */
checkIntervalMs?: number;
/** Callback when buffering state changes */
onBufferingChange?: (isBuffering: boolean) => void;
/** Callback when progress timeout occurs */
onProgressTimeout?: () => void;
}
export interface UseVideoBufferingStateResult {
/** True when video is waiting for data (initial load or mid-playback) */
isBuffering: boolean;
/** True specifically when waiting event has fired (mid-playback) */
isWaitingForData: boolean;
/** True when canplay has fired (enough data to start playing) */
isReady: boolean;
/** Reset buffering state (e.g., when loading new source) */
reset: () => void;
/** Start progress monitoring (call after video starts loading) */
startProgressMonitor: () => void;
/** Stop progress monitoring */
stopProgressMonitor: () => void;
/** Update last progress time (call on progress events) */
updateProgressTime: () => void;
}
/**
* Hook for tracking video buffering state.
*
* Handles both initial buffering (before canplay) and mid-playback
* buffering (when video is waiting for more data).
*
* @example
* const { isBuffering, isReady, startProgressMonitor } = useVideoBufferingState({
* videoRef,
* enabled: true,
* onProgressTimeout: () => console.error('Video timed out'),
* });
*/
export function useVideoBufferingState({
videoRef,
enabled = true,
noProgressMs,
checkIntervalMs,
onBufferingChange,
onProgressTimeout,
}: UseVideoBufferingStateOptions): UseVideoBufferingStateResult {
const [isReady, setIsReady] = useState(false);
const [isWaitingForData, setIsWaitingForData] = useState(false);
const isWaitingForDataRef = useRef(false);
const lastProgressTimeRef = useRef<number>(0);
const progressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isMonitoringRef = useRef(false);
const onProgressTimeoutRef = useRef(onProgressTimeout);
const onBufferingChangeRef = useRef(onBufferingChange);
// Update refs on each render
useEffect(() => {
onProgressTimeoutRef.current = onProgressTimeout;
onBufferingChangeRef.current = onBufferingChange;
});
// Calculate actual timeout values
const { progressTimeout } = TRANSITION_CONFIG;
const isMobile =
typeof navigator !== 'undefined' &&
/iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
const actualNoProgressMs =
noProgressMs ??
(isMobile
? progressTimeout.noProgressMs * progressTimeout.mobileMultiplier
: progressTimeout.noProgressMs);
const actualCheckIntervalMs = checkIntervalMs ?? progressTimeout.checkIntervalMs;
const stopProgressMonitor = useCallback(() => {
if (progressTimeoutRef.current) {
clearTimeout(progressTimeoutRef.current);
progressTimeoutRef.current = null;
}
isMonitoringRef.current = false;
}, []);
const updateProgressTime = useCallback(() => {
lastProgressTimeRef.current = Date.now();
}, []);
const startProgressMonitor = useCallback(() => {
if (isMonitoringRef.current) return;
isMonitoringRef.current = true;
lastProgressTimeRef.current = Date.now();
const video = videoRef.current;
const checkProgress = () => {
if (!isMonitoringRef.current) return;
if (!video) return;
const timeSinceProgress = Date.now() - lastProgressTimeRef.current;
// If playing normally (not waiting), continue monitoring
if (!video.paused && !isWaitingForDataRef.current) {
progressTimeoutRef.current = setTimeout(checkProgress, actualCheckIntervalMs);
return;
}
// If waiting but received data recently, keep waiting
if (timeSinceProgress < actualNoProgressMs) {
progressTimeoutRef.current = setTimeout(checkProgress, actualCheckIntervalMs);
return;
}
// No progress for too long while waiting - timeout
logger.error('useVideoBufferingState: No network progress - timing out', {
timeSinceProgress,
currentTime: video.currentTime?.toFixed(2),
isWaiting: isWaitingForDataRef.current,
actualNoProgressMs,
});
onProgressTimeoutRef.current?.();
};
progressTimeoutRef.current = setTimeout(checkProgress, actualCheckIntervalMs);
}, [videoRef, actualNoProgressMs, actualCheckIntervalMs]);
const reset = useCallback(() => {
setIsReady(false);
setIsWaitingForData(false);
isWaitingForDataRef.current = false;
stopProgressMonitor();
}, [stopProgressMonitor]);
// Set up video event listeners
useEffect(() => {
const video = videoRef.current;
if (!video || !enabled) return;
const onCanPlay = () => {
setIsReady(true);
};
const onWaiting = () => {
const bufferedInfo =
video.buffered.length > 0
? `${video.buffered.start(0).toFixed(2)}-${video.buffered.end(video.buffered.length - 1).toFixed(2)}`
: 'none';
logger.info('useVideoBufferingState: Video waiting for data', {
currentTime: video.currentTime.toFixed(2),
buffered: bufferedInfo,
});
setIsWaitingForData(true);
isWaitingForDataRef.current = true;
};
const onPlaying = () => {
// Clear waiting state when playback resumes
if (isWaitingForDataRef.current) {
logger.info('useVideoBufferingState: Resumed playback after buffering');
setIsWaitingForData(false);
isWaitingForDataRef.current = false;
}
};
const onProgress = () => {
updateProgressTime();
};
video.addEventListener('canplay', onCanPlay);
video.addEventListener('waiting', onWaiting);
video.addEventListener('playing', onPlaying);
video.addEventListener('progress', onProgress);
return () => {
video.removeEventListener('canplay', onCanPlay);
video.removeEventListener('waiting', onWaiting);
video.removeEventListener('playing', onPlaying);
video.removeEventListener('progress', onProgress);
stopProgressMonitor();
};
}, [videoRef, enabled, stopProgressMonitor, updateProgressTime]);
// Notify on buffering state change
const isBuffering = !isReady || isWaitingForData;
useEffect(() => {
onBufferingChangeRef.current?.(isBuffering);
}, [isBuffering]);
return {
isBuffering,
isWaitingForData,
isReady,
reset,
startProgressMonitor,
stopProgressMonitor,
updateProgressTime,
};
}
export default useVideoBufferingState;

View File

@ -0,0 +1,174 @@
/**
* useVideoErrorRecovery Hook
*
* Handles video error recovery strategies:
* - Safari decode error retry (error code 3)
* - Proxy URL fallback on presigned URL failure
*/
import { useRef, useCallback, useEffect, type RefObject } from 'react';
import { logger } from '../../lib/logger';
import {
isPresignedUrl,
buildProxyUrl,
markPresignedUrlFailed,
} from '../../lib/assetUrl';
import { TRANSITION_CONFIG } from '../../config/transition.config';
import { isSafari } from '../../lib/browserUtils';
export interface UseVideoErrorRecoveryOptions {
videoRef: RefObject<HTMLVideoElement | null>;
enabled?: boolean;
/** Storage key for proxy URL fallback */
storageKey?: string;
/** Current video source URL */
currentUrl?: string;
/** Max decode retry attempts (default from config) */
maxDecodeRetries?: number;
/** Callback to reload video with new source */
onRetryWithSource?: (newUrl: string) => void;
/** Callback when all recovery attempts fail */
onUnrecoverableError?: (reason: string, error?: MediaError) => void;
}
export interface UseVideoErrorRecoveryResult {
/** Reset error recovery state */
reset: () => void;
/** Manually handle an error (returns true if handled, false if unrecoverable) */
handleError: (video: HTMLVideoElement) => boolean;
}
/**
* Hook for handling video error recovery.
*
* Supports:
* - Safari decode error retry (reloading the video)
* - Proxy URL fallback when presigned URLs fail
*
* @example
* const { reset, handleError } = useVideoErrorRecovery({
* videoRef,
* storageKey: 'assets/video.mp4',
* currentUrl: presignedUrl,
* onRetryWithSource: (newUrl) => {
* video.src = newUrl;
* video.load();
* },
* onUnrecoverableError: (reason) => console.error('Video error:', reason),
* });
*/
export function useVideoErrorRecovery({
videoRef,
enabled = true,
storageKey,
currentUrl,
maxDecodeRetries,
onRetryWithSource,
onUnrecoverableError,
}: UseVideoErrorRecoveryOptions): UseVideoErrorRecoveryResult {
const decodeRetryCountRef = useRef(0);
const didTryProxyRef = useRef(false);
const onRetryWithSourceRef = useRef(onRetryWithSource);
const onUnrecoverableErrorRef = useRef(onUnrecoverableError);
const storageKeyRef = useRef(storageKey);
const currentUrlRef = useRef(currentUrl);
const actualMaxDecodeRetries =
maxDecodeRetries ?? TRANSITION_CONFIG.retry.maxDecodeRetries;
useEffect(() => {
onRetryWithSourceRef.current = onRetryWithSource;
onUnrecoverableErrorRef.current = onUnrecoverableError;
storageKeyRef.current = storageKey;
currentUrlRef.current = currentUrl;
});
const reset = useCallback(() => {
decodeRetryCountRef.current = 0;
didTryProxyRef.current = false;
}, []);
const handleError = useCallback(
(video: HTMLVideoElement): boolean => {
const error = video.error;
if (!error) return false;
const errorCode = error.code;
const errorMessage = (error as MediaError & { message?: string }).message || '';
logger.error('useVideoErrorRecovery: Video error', {
code: errorCode,
message: errorMessage,
currentSrc: video.currentSrc?.slice(-50),
readyState: video.readyState,
networkState: video.networkState,
});
// Safari decode error (error code 3) - try reload
if (
isSafari() &&
errorCode === 3 &&
decodeRetryCountRef.current < actualMaxDecodeRetries
) {
decodeRetryCountRef.current++;
logger.info('useVideoErrorRecovery: Safari decode error, attempting reload', {
attempt: decodeRetryCountRef.current,
maxAttempts: actualMaxDecodeRetries,
});
video.load();
video.play().catch(() => {
/* ignore play errors during recovery */
});
return true;
}
// Network error with presigned URL - try proxy
const currentStorageKey = storageKeyRef.current;
const url = currentUrlRef.current;
if (
currentStorageKey &&
url &&
isPresignedUrl(url) &&
!didTryProxyRef.current
) {
didTryProxyRef.current = true;
logger.info('useVideoErrorRecovery: Presigned URL failed, retrying with proxy', {
storageKey: currentStorageKey.slice(-40),
});
markPresignedUrlFailed(currentStorageKey);
const proxyUrl = buildProxyUrl(currentStorageKey);
onRetryWithSourceRef.current?.(proxyUrl);
return true;
}
// Unrecoverable error
onUnrecoverableErrorRef.current?.('video-error', error);
return false;
},
[actualMaxDecodeRetries],
);
// Set up error event listener
useEffect(() => {
const video = videoRef.current;
if (!video || !enabled) return;
const onError = () => {
handleError(video);
};
video.addEventListener('error', onError);
return () => {
video.removeEventListener('error', onError);
};
}, [videoRef, enabled, handleError]);
return {
reset,
handleError,
};
}
export default useVideoErrorRecovery;

View File

@ -0,0 +1,114 @@
/**
* useVideoEventManager Hook
*
* Manages video element event listener setup and cleanup.
* Provides a declarative API for subscribing to video events.
*/
import { useEffect, type RefObject } from 'react';
export type VideoEventType =
| 'loadedmetadata'
| 'loadeddata'
| 'canplay'
| 'canplaythrough'
| 'playing'
| 'pause'
| 'ended'
| 'timeupdate'
| 'seeking'
| 'seeked'
| 'waiting'
| 'progress'
| 'stalled'
| 'error'
| 'abort';
export type VideoEventHandler = (event: Event) => void;
export interface VideoEventHandlers {
onLoadedMetadata?: VideoEventHandler;
onLoadedData?: VideoEventHandler;
onCanPlay?: VideoEventHandler;
onCanPlayThrough?: VideoEventHandler;
onPlaying?: VideoEventHandler;
onPause?: VideoEventHandler;
onEnded?: VideoEventHandler;
onTimeUpdate?: VideoEventHandler;
onSeeking?: VideoEventHandler;
onSeeked?: VideoEventHandler;
onWaiting?: VideoEventHandler;
onProgress?: VideoEventHandler;
onStalled?: VideoEventHandler;
onError?: VideoEventHandler;
onAbort?: VideoEventHandler;
}
export interface UseVideoEventManagerOptions {
videoRef: RefObject<HTMLVideoElement | null>;
enabled?: boolean;
handlers: VideoEventHandlers;
}
const EVENT_MAP: Record<keyof VideoEventHandlers, VideoEventType> = {
onLoadedMetadata: 'loadedmetadata',
onLoadedData: 'loadeddata',
onCanPlay: 'canplay',
onCanPlayThrough: 'canplaythrough',
onPlaying: 'playing',
onPause: 'pause',
onEnded: 'ended',
onTimeUpdate: 'timeupdate',
onSeeking: 'seeking',
onSeeked: 'seeked',
onWaiting: 'waiting',
onProgress: 'progress',
onStalled: 'stalled',
onError: 'error',
onAbort: 'abort',
};
/**
* Hook for managing video element event listeners.
*
* @example
* useVideoEventManager({
* videoRef,
* enabled: true,
* handlers: {
* onPlaying: () => console.log('Video started playing'),
* onWaiting: () => console.log('Video is buffering'),
* onEnded: () => console.log('Video ended'),
* },
* });
*/
export function useVideoEventManager({
videoRef,
enabled = true,
handlers,
}: UseVideoEventManagerOptions): void {
useEffect(() => {
const video = videoRef.current;
if (!video || !enabled) return;
const boundHandlers: Array<[VideoEventType, VideoEventHandler]> = [];
// Set up event listeners
for (const [handlerKey, eventType] of Object.entries(EVENT_MAP)) {
const handler = handlers[handlerKey as keyof VideoEventHandlers];
if (handler) {
video.addEventListener(eventType, handler);
boundHandlers.push([eventType, handler]);
}
}
// Cleanup
return () => {
for (const [eventType, handler] of boundHandlers) {
video.removeEventListener(eventType, handler);
}
};
}, [videoRef, enabled, handlers]);
}
export default useVideoEventManager;

View File

@ -0,0 +1,123 @@
/**
* useVideoFirstFrame Hook
*
* Detects when the first video frame is painted using:
* - requestVideoFrameCallback (modern browsers, Safari 15.4+)
* - requestAnimationFrame fallback (older browsers)
*/
import { useState, useRef, useCallback, useEffect, type RefObject } from 'react';
import { logger } from '../../lib/logger';
import { scheduleAfterPaint } from '../../lib/browserUtils';
export interface UseVideoFirstFrameOptions {
videoRef: RefObject<HTMLVideoElement | null>;
enabled?: boolean;
/** Callback when first frame is painted */
onFirstFrame?: () => void;
}
export interface UseVideoFirstFrameResult {
/** True when first frame has been painted */
isFirstFramePainted: boolean;
/** Reset first frame state */
reset: () => void;
}
/**
* Hook for detecting when the first video frame is painted.
*
* Uses requestVideoFrameCallback when available (Safari 15.4+, Chrome, Firefox)
* for precise frame-level detection. Falls back to requestAnimationFrame
* with scheduleAfterPaint for older browsers.
*
* @example
* const { isFirstFramePainted, reset } = useVideoFirstFrame({
* videoRef,
* enabled: true,
* onFirstFrame: () => console.log('First frame painted'),
* });
*/
export function useVideoFirstFrame({
videoRef,
enabled = true,
onFirstFrame,
}: UseVideoFirstFrameOptions): UseVideoFirstFrameResult {
const [isFirstFramePainted, setIsFirstFramePainted] = useState(false);
const callbackIdRef = useRef<number | null>(null);
const onFirstFrameRef = useRef(onFirstFrame);
const didFireRef = useRef(false);
useEffect(() => {
onFirstFrameRef.current = onFirstFrame;
});
const reset = useCallback(() => {
setIsFirstFramePainted(false);
didFireRef.current = false;
// Cancel pending callback
if (callbackIdRef.current !== null) {
const video = videoRef.current;
if (video && 'cancelVideoFrameCallback' in video) {
(video as HTMLVideoElement & { cancelVideoFrameCallback: (id: number) => void })
.cancelVideoFrameCallback(callbackIdRef.current);
}
callbackIdRef.current = null;
}
}, [videoRef]);
// Set up first frame detection when video starts playing
useEffect(() => {
const video = videoRef.current;
if (!video || !enabled) return;
const handlePlaying = () => {
if (didFireRef.current) return;
// Use requestVideoFrameCallback for precise frame-level timing (Safari 15.4+)
if ('requestVideoFrameCallback' in video) {
const rvfc = (video as HTMLVideoElement & {
requestVideoFrameCallback: (
callback: (now: number, metadata: VideoFrameCallbackMetadata) => void
) => number;
}).requestVideoFrameCallback.bind(video);
// First callback: frame is composited, safe to show overlay
callbackIdRef.current = rvfc((_now, _metadata) => {
if (!didFireRef.current) {
didFireRef.current = true;
setIsFirstFramePainted(true);
logger.info('useVideoFirstFrame: First frame painted (rvfc)');
onFirstFrameRef.current?.();
}
callbackIdRef.current = null;
});
} else {
// Fallback for older browsers without requestVideoFrameCallback
scheduleAfterPaint(() => {
if (!didFireRef.current) {
didFireRef.current = true;
setIsFirstFramePainted(true);
logger.info('useVideoFirstFrame: First frame painted (rAF fallback)');
onFirstFrameRef.current?.();
}
});
}
};
video.addEventListener('playing', handlePlaying);
return () => {
video.removeEventListener('playing', handlePlaying);
reset();
};
}, [videoRef, enabled, reset]);
return {
isFirstFramePainted,
reset,
};
}
export default useVideoFirstFrame;

View File

@ -0,0 +1,337 @@
/**
* useVideoPlaybackCore Hook
*
* Composite hook that combines all video primitives into a unified
* playback controller. Used as the foundation for both transition
* and background video playback.
*/
import { useCallback, useEffect, useRef, useState, type RefObject } from 'react';
import { logger } from '../../lib/logger';
import { useVideoBlobUrl, type PreloadCacheProvider } from './useVideoBlobUrl';
import { useVideoBufferingState } from './useVideoBufferingState';
import { useVideoFirstFrame } from './useVideoFirstFrame';
import { useVideoErrorRecovery } from './useVideoErrorRecovery';
import { useVideoTimeouts } from './useVideoTimeouts';
export interface UseVideoPlaybackCoreOptions {
videoRef: RefObject<HTMLVideoElement | null>;
/** Source URL or storage path */
sourceUrl?: string;
/** Storage key for cache lookup */
storageKey?: string;
/** Preload cache provider */
preloadCache?: PreloadCacheProvider;
/** Whether to autoplay when ready */
autoplay?: boolean;
/** Whether video is muted */
muted?: boolean;
/** External pause control */
paused?: boolean;
/** Timeout for playback start (ms) */
playbackStartTimeoutMs?: number;
/** Callback when video is ready (first frame painted) */
onReady?: () => void;
/** Callback on error */
onError?: (reason: string) => void;
/** Callback when buffering state changes */
onBufferingChange?: (isBuffering: boolean) => void;
/** Callback when video ends */
onEnded?: () => void;
}
export interface UseVideoPlaybackCoreResult {
/** True when first frame has been painted */
isReady: boolean;
/** True during buffering (initial load or mid-playback) */
isBuffering: boolean;
/** True specifically when waiting for network data mid-playback */
isWaitingForData: boolean;
/** Resolved playable URL */
resolvedUrl: string | null;
/** Whether URL resolution is in progress */
isResolving: boolean;
/** Start playback */
play: () => Promise<void>;
/** Pause playback */
pause: () => void;
/** Reset all state */
reset: () => void;
}
const DEFAULT_PLAYBACK_START_TIMEOUT_MS = 3000;
/**
* Core video playback hook that composes all video primitives.
*
* Provides unified handling for:
* - Multi-tier URL resolution (blob, cached, streaming, proxy)
* - Buffering state detection (initial and mid-playback)
* - First frame detection (rvfc with fallback)
* - Error recovery (Safari decode error, proxy fallback)
* - Timer management
*
* @example
* const {
* isReady,
* isBuffering,
* resolvedUrl,
* play,
* pause,
* } = useVideoPlaybackCore({
* videoRef,
* sourceUrl: 'assets/video.mp4',
* storageKey: 'assets/video.mp4',
* autoplay: true,
* onReady: () => console.log('Video ready'),
* onError: (reason) => console.error('Error:', reason),
* });
*/
export function useVideoPlaybackCore({
videoRef,
sourceUrl,
storageKey,
preloadCache,
autoplay = false,
muted = true,
paused = false,
playbackStartTimeoutMs = DEFAULT_PLAYBACK_START_TIMEOUT_MS,
onReady,
onError,
onBufferingChange,
onEnded,
}: UseVideoPlaybackCoreOptions): UseVideoPlaybackCoreResult {
const [isSourceLoaded, setIsSourceLoaded] = useState(false);
const didStartPlaybackRef = useRef(false);
const currentSourceRef = useRef<string | null>(null);
const onReadyRef = useRef(onReady);
const onErrorRef = useRef(onError);
const onBufferingChangeRef = useRef(onBufferingChange);
const onEndedRef = useRef(onEnded);
useEffect(() => {
onReadyRef.current = onReady;
onErrorRef.current = onError;
onBufferingChangeRef.current = onBufferingChange;
onEndedRef.current = onEnded;
});
// Timer management
const { setTimer, clearTimer, clearAllTimers } = useVideoTimeouts();
// URL resolution
const {
resolvedUrl,
isResolving,
revoke: revokeBlobUrl,
} = useVideoBlobUrl({
sourceUrl: sourceUrl || '',
storageKey,
preloadCache,
onError: (error) => {
logger.error('useVideoPlaybackCore: URL resolution failed', { error });
onErrorRef.current?.('source-resolution-failed');
},
});
// Buffering state
const {
isBuffering: isBufferingFromState,
isWaitingForData,
isReady: isBufferReady,
reset: resetBufferingState,
startProgressMonitor,
} = useVideoBufferingState({
videoRef,
enabled: Boolean(sourceUrl),
onBufferingChange: (buffering) => {
onBufferingChangeRef.current?.(buffering);
},
onProgressTimeout: () => {
onErrorRef.current?.('no-progress-timeout');
},
});
// First frame detection
const { isFirstFramePainted, reset: resetFirstFrame } = useVideoFirstFrame({
videoRef,
enabled: Boolean(sourceUrl),
onFirstFrame: () => {
logger.info('useVideoPlaybackCore: First frame painted');
onReadyRef.current?.();
},
});
// Error recovery
const { reset: resetErrorRecovery } = useVideoErrorRecovery({
videoRef,
enabled: Boolean(sourceUrl),
storageKey,
currentUrl: resolvedUrl || undefined,
onRetryWithSource: (newUrl) => {
const video = videoRef.current;
if (video) {
video.src = newUrl;
video.load();
video.play().catch(() => {
/* ignore play errors during recovery */
});
}
},
onUnrecoverableError: (reason) => {
onErrorRef.current?.(reason);
},
});
// Combined ready state
const isReady = isFirstFramePainted && isBufferReady;
const isBuffering = !isReady || isBufferingFromState || isWaitingForData;
// Play function
const play = useCallback(async () => {
const video = videoRef.current;
if (!video) return;
try {
await video.play();
didStartPlaybackRef.current = true;
clearTimer('playbackStart');
} catch (playError) {
logger.warn('useVideoPlaybackCore: Play failed', { playError });
}
}, [videoRef, clearTimer]);
// Pause function
const pause = useCallback(() => {
const video = videoRef.current;
if (video) {
video.pause();
}
}, [videoRef]);
// Reset function
const reset = useCallback(() => {
clearAllTimers();
resetBufferingState();
resetFirstFrame();
resetErrorRecovery();
revokeBlobUrl();
setIsSourceLoaded(false);
didStartPlaybackRef.current = false;
currentSourceRef.current = null;
}, [
clearAllTimers,
resetBufferingState,
resetFirstFrame,
resetErrorRecovery,
revokeBlobUrl,
]);
// Load and play when URL is resolved
useEffect(() => {
const video = videoRef.current;
if (!video || !resolvedUrl || isResolving) return;
// Skip if already loaded this source
if (currentSourceRef.current === resolvedUrl && isSourceLoaded) return;
logger.info('useVideoPlaybackCore: Loading video', {
url: resolvedUrl.slice(-50),
autoplay,
paused,
});
currentSourceRef.current = resolvedUrl;
setIsSourceLoaded(false);
didStartPlaybackRef.current = false;
// Set video source
video.src = resolvedUrl;
video.muted = muted;
video.load();
// Start progress monitoring for streaming
startProgressMonitor();
setIsSourceLoaded(true);
// Autoplay if not externally paused
if (autoplay && !paused) {
play();
// Set playback start watchdog
setTimer(
'playbackStart',
() => {
if (!didStartPlaybackRef.current) {
logger.warn('useVideoPlaybackCore: Playback start timeout, retrying');
play();
}
},
playbackStartTimeoutMs,
);
}
}, [
videoRef,
resolvedUrl,
isResolving,
autoplay,
paused,
muted,
play,
setTimer,
startProgressMonitor,
playbackStartTimeoutMs,
isSourceLoaded,
]);
// Handle external pause control
useEffect(() => {
const video = videoRef.current;
if (!video || !isSourceLoaded) return;
if (paused) {
video.pause();
} else if (autoplay && !video.paused) {
// Already playing, do nothing
} else if (autoplay) {
play();
}
}, [videoRef, paused, autoplay, play, isSourceLoaded]);
// Handle video ended
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const handleEnded = () => {
onEndedRef.current?.();
};
video.addEventListener('ended', handleEnded);
return () => {
video.removeEventListener('ended', handleEnded);
};
}, [videoRef]);
// Cleanup on unmount or source change
useEffect(() => {
return () => {
reset();
};
}, [sourceUrl, reset]);
return {
isReady,
isBuffering,
isWaitingForData,
resolvedUrl,
isResolving,
play,
pause,
reset,
};
}
export default useVideoPlaybackCore;

View File

@ -0,0 +1,245 @@
/**
* useVideoPlayer Hook
*
* Video player hook for UI elements (VideoPlayerElement).
* Built on video primitives for consistent behavior:
* - Multi-tier URL resolution (blob cached presigned proxy)
* - Safari decode error recovery
* - Optional buffering state tracking
*
* Designed for video player UI elements embedded in pages,
* not for transition or background videos which have their own hooks.
*/
import { useRef, useCallback, useEffect, useState, type RefObject } from 'react';
import { logger } from '../../lib/logger';
import { useVideoBlobUrl, type PreloadCacheProvider } from './useVideoBlobUrl';
import { useVideoErrorRecovery } from './useVideoErrorRecovery';
export interface UseVideoPlayerOptions {
/** Source URL or storage path */
sourceUrl?: string;
/** Storage key for cache lookup (defaults to sourceUrl) */
storageKey?: string;
/** Preload cache provider for blob URL resolution */
preloadCache?: PreloadCacheProvider;
/** Whether to autoplay */
autoplay?: boolean;
/** Whether to loop */
loop?: boolean;
/** Whether to mute */
muted?: boolean;
/** Track buffering state for loading indicator (default: false) */
trackBuffering?: boolean;
/** Callback when video starts playing */
onPlay?: () => void;
/** Callback when video pauses */
onPause?: () => void;
/** Callback when video ends */
onEnded?: () => void;
/** Callback on error */
onError?: (reason: string) => void;
}
export interface UseVideoPlayerResult {
/** Ref to attach to video element */
videoRef: RefObject<HTMLVideoElement | null>;
/** Resolved playable URL */
resolvedUrl: string | null;
/** Whether URL is being resolved */
isResolving: boolean;
/** Whether video is buffering (only tracked if trackBuffering=true) */
isBuffering: boolean;
/** Whether video has loaded and can play */
isReady: boolean;
/** Play the video */
play: () => Promise<void>;
/** Pause the video */
pause: () => void;
}
/**
* Hook for video player UI elements.
*
* Provides unified video handling with:
* - Multi-tier URL resolution via preload cache
* - Safari decode error recovery
* - Optional buffering state tracking
*
* @example
* const {
* videoRef,
* resolvedUrl,
* isBuffering,
* isReady,
* } = useVideoPlayer({
* sourceUrl: element.mediaUrl,
* preloadCache,
* autoplay: element.mediaAutoplay,
* loop: element.mediaLoop,
* muted: element.mediaMuted,
* trackBuffering: true,
* });
*/
export function useVideoPlayer({
sourceUrl,
storageKey,
preloadCache,
autoplay = false,
loop = false,
muted = true,
trackBuffering = false,
onPlay,
onPause,
onEnded,
onError,
}: UseVideoPlayerOptions): UseVideoPlayerResult {
const videoRef = useRef<HTMLVideoElement | null>(null);
const [isBuffering, setIsBuffering] = useState(false);
const [isReady, setIsReady] = useState(false);
// Refs for callbacks
const onPlayRef = useRef(onPlay);
const onPauseRef = useRef(onPause);
const onEndedRef = useRef(onEnded);
const onErrorRef = useRef(onError);
useEffect(() => {
onPlayRef.current = onPlay;
onPauseRef.current = onPause;
onEndedRef.current = onEnded;
onErrorRef.current = onError;
});
// URL resolution via video primitives
const {
resolvedUrl,
isResolving,
revoke: revokeBlobUrl,
} = useVideoBlobUrl({
sourceUrl: sourceUrl || '',
storageKey: storageKey || sourceUrl,
preloadCache,
onError: (error) => {
logger.error('useVideoPlayer: URL resolution failed', { error });
onErrorRef.current?.('source-resolution-failed');
},
});
// Error recovery (Safari decode errors, proxy fallback)
const { reset: resetErrorRecovery } = useVideoErrorRecovery({
videoRef,
enabled: Boolean(sourceUrl),
storageKey: storageKey || sourceUrl,
currentUrl: resolvedUrl || undefined,
onRetryWithSource: (newUrl) => {
const video = videoRef.current;
if (video) {
video.src = newUrl;
video.load();
if (autoplay) {
video.play().catch(() => {
/* ignore play errors during recovery */
});
}
}
},
onUnrecoverableError: (reason) => {
onErrorRef.current?.(reason);
},
});
// Play function
const play = useCallback(async () => {
const video = videoRef.current;
if (!video) return;
try {
await video.play();
} catch (playError) {
logger.warn('useVideoPlayer: Play failed', { playError });
}
}, []);
// Pause function
const pause = useCallback(() => {
const video = videoRef.current;
if (video) {
video.pause();
}
}, []);
// Event handlers
useEffect(() => {
const video = videoRef.current;
if (!video || !resolvedUrl) return;
const handleCanPlay = () => {
setIsReady(true);
if (trackBuffering) {
setIsBuffering(false);
}
};
const handleWaiting = () => {
if (trackBuffering) {
setIsBuffering(true);
}
};
const handlePlaying = () => {
if (trackBuffering) {
setIsBuffering(false);
}
onPlayRef.current?.();
};
const handlePause = () => {
onPauseRef.current?.();
};
const handleEnded = () => {
onEndedRef.current?.();
};
video.addEventListener('canplay', handleCanPlay);
video.addEventListener('waiting', handleWaiting);
video.addEventListener('playing', handlePlaying);
video.addEventListener('pause', handlePause);
video.addEventListener('ended', handleEnded);
return () => {
video.removeEventListener('canplay', handleCanPlay);
video.removeEventListener('waiting', handleWaiting);
video.removeEventListener('playing', handlePlaying);
video.removeEventListener('pause', handlePause);
video.removeEventListener('ended', handleEnded);
};
}, [resolvedUrl, trackBuffering]);
// Reset state when source changes
useEffect(() => {
setIsReady(false);
setIsBuffering(false);
resetErrorRecovery();
}, [sourceUrl, resetErrorRecovery]);
// Cleanup on unmount
useEffect(() => {
return () => {
revokeBlobUrl();
};
}, [revokeBlobUrl]);
return {
videoRef,
resolvedUrl,
isResolving,
isBuffering,
isReady,
play,
pause,
};
}
export default useVideoPlayer;

View File

@ -0,0 +1,93 @@
/**
* useVideoTimeouts Hook
*
* Manages video-related timers including:
* - Playback start watchdog
* - Finish timer (duration-based)
* - Custom timers
*/
import { useRef, useCallback, useEffect } from 'react';
export interface UseVideoTimeoutsResult {
/** Set a named timer */
setTimer: (name: string, callback: () => void, delayMs: number) => void;
/** Clear a named timer */
clearTimer: (name: string) => void;
/** Clear all timers */
clearAllTimers: () => void;
/** Check if a timer is active */
isTimerActive: (name: string) => boolean;
}
/**
* Hook for managing video-related timers.
*
* Provides a clean API for setting, clearing, and tracking
* multiple named timers. All timers are automatically cleaned
* up on unmount.
*
* @example
* const { setTimer, clearTimer, clearAllTimers } = useVideoTimeouts();
*
* // Start playback watchdog
* setTimer('watchdog', () => {
* console.log('Playback did not start in time');
* }, 3000);
*
* // Clear when playback starts
* clearTimer('watchdog');
*/
export function useVideoTimeouts(): UseVideoTimeoutsResult {
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
const clearTimer = useCallback((name: string) => {
const timer = timersRef.current.get(name);
if (timer) {
clearTimeout(timer);
timersRef.current.delete(name);
}
}, []);
const clearAllTimers = useCallback(() => {
timersRef.current.forEach((timer) => {
clearTimeout(timer);
});
timersRef.current.clear();
}, []);
const setTimer = useCallback(
(name: string, callback: () => void, delayMs: number) => {
// Clear existing timer with same name
clearTimer(name);
const timer = setTimeout(() => {
timersRef.current.delete(name);
callback();
}, delayMs);
timersRef.current.set(name, timer);
},
[clearTimer],
);
const isTimerActive = useCallback((name: string) => {
return timersRef.current.has(name);
}, []);
// Cleanup on unmount
useEffect(() => {
return () => {
clearAllTimers();
};
}, [clearAllTimers]);
return {
setTimer,
clearTimer,
clearAllTimers,
isTimerActive,
};
}
export default useVideoTimeouts;

View File

@ -161,16 +161,26 @@ export function extractPageLinksAndElements(
} }
if (resolvedTargetPageId && resolvedTargetPageId !== page.id) { if (resolvedTargetPageId && resolvedTargetPageId !== page.id) {
const hasTransitionVideo =
el.transitionVideoUrl && typeof el.transitionVideoUrl === 'string';
const hasReverseVideo =
el.reverseVideoUrl && typeof el.reverseVideoUrl === 'string';
pageLinks.push({ pageLinks.push({
id: `synthetic-${page.id}-${el.id || preloadElements.length}`, id: `synthetic-${page.id}-${el.id || preloadElements.length}`,
from_pageId: page.id, from_pageId: page.id,
to_pageId: resolvedTargetPageId, to_pageId: resolvedTargetPageId,
is_active: true, is_active: true,
transition: transition:
el.transitionVideoUrl && typeof el.transitionVideoUrl === 'string' hasTransitionVideo || hasReverseVideo
? { ? {
id: `transition-${el.id || preloadElements.length}`, id: `transition-${el.id || preloadElements.length}`,
video_url: el.transitionVideoUrl, video_url: hasTransitionVideo
? (el.transitionVideoUrl as string)
: undefined,
reverse_video_url: hasReverseVideo
? (el.reverseVideoUrl as string)
: undefined,
} }
: undefined, : undefined,
}); });
@ -237,16 +247,26 @@ export function extractPageLinksOnly(
} }
if (resolvedTargetPageId && resolvedTargetPageId !== page.id) { if (resolvedTargetPageId && resolvedTargetPageId !== page.id) {
const hasTransitionVideo =
el.transitionVideoUrl && typeof el.transitionVideoUrl === 'string';
const hasReverseVideo =
el.reverseVideoUrl && typeof el.reverseVideoUrl === 'string';
pageLinks.push({ pageLinks.push({
id: `synthetic-${page.id}-${el.id || Math.random().toString(36).slice(2)}`, id: `synthetic-${page.id}-${el.id || Math.random().toString(36).slice(2)}`,
from_pageId: page.id, from_pageId: page.id,
to_pageId: resolvedTargetPageId, to_pageId: resolvedTargetPageId,
is_active: true, is_active: true,
transition: transition:
el.transitionVideoUrl && typeof el.transitionVideoUrl === 'string' hasTransitionVideo || hasReverseVideo
? { ? {
id: `transition-${el.id || Math.random().toString(36).slice(2)}`, id: `transition-${el.id || Math.random().toString(36).slice(2)}`,
video_url: el.transitionVideoUrl, video_url: hasTransitionVideo
? (el.transitionVideoUrl as string)
: undefined,
reverse_video_url: hasReverseVideo
? (el.reverseVideoUrl as string)
: undefined,
} }
: undefined, : undefined,
}); });

View File

@ -146,8 +146,7 @@ class DownloadManagerClass {
? { ? {
enabled: true, enabled: true,
minBufferBytes: minBufferBytes:
params.streamingMode.minBufferBytes ?? params.streamingMode.minBufferBytes ?? this.getMinBufferBytes(),
this.getMinBufferBytes(),
streamingUrl: params.url, streamingUrl: params.url,
didSignalReady: false, didSignalReady: false,
} }
@ -769,9 +768,7 @@ class DownloadManagerClass {
*/ */
private getMinBufferBytes(): number { private getMinBufferBytes(): number {
if (this.isMobile()) { if (this.isMobile()) {
return ( return PRELOAD_CONFIG.streaming.mobile?.minBufferBytes || 2 * 1024 * 1024;
PRELOAD_CONFIG.streaming.mobile?.minBufferBytes || 2 * 1024 * 1024
);
} }
return PRELOAD_CONFIG.streaming.minBufferBytes; return PRELOAD_CONFIG.streaming.minBufferBytes;
} }

View File

@ -1,5 +1,4 @@
import { mdiContentSave, mdiExitToApp, mdiPlus } from '@mdi/js'; import { mdiContentSave, mdiExitToApp, mdiPlus } from '@mdi/js';
import axios from 'axios';
import Head from 'next/head'; import Head from 'next/head';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React, { import React, {
@ -24,11 +23,9 @@ import { BackdropPortalProvider } from '../components/BackdropPortal';
import { getPageTitle } from '../config'; import { getPageTitle } from '../config';
import LayoutAuthenticated from '../layouts/Authenticated'; import LayoutAuthenticated from '../layouts/Authenticated';
import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator'; import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
import { usePageSwitch } from '../hooks/usePageSwitch'; import { usePageNavigationState } from '../hooks/usePageNavigationState';
import { usePageNavigation } from '../hooks/usePageNavigation'; import { usePageNavigation } from '../hooks/usePageNavigation';
import { useTransitionPlayback } from '../hooks/useTransitionPlayback'; import { useTransitionPlayback } from '../hooks/useTransitionPlayback';
import { useBackgroundTransition } from '../hooks/useBackgroundTransition';
import { useBackgroundUrls } from '../hooks/useBackgroundUrls';
import { useTransitionSettings } from '../hooks/useTransitionSettings'; import { useTransitionSettings } from '../hooks/useTransitionSettings';
import { useAppDispatch, useAppSelector } from '../stores/hooks'; import { useAppDispatch, useAppSelector } from '../stores/hooks';
import { fetch as fetchGlobalTransitionDefaults } from '../stores/global_transition_defaults/globalTransitionDefaultsSlice'; import { fetch as fetchGlobalTransitionDefaults } from '../stores/global_transition_defaults/globalTransitionDefaultsSlice';
@ -42,7 +39,9 @@ import {
extractElementTransitionSettings, extractElementTransitionSettings,
} from '../types/transition'; } from '../types/transition';
import { logger } from '../lib/logger'; import { logger } from '../lib/logger';
import { isSafari } from '../lib/browserUtils';
import { resolveAssetPlaybackUrl } from '../lib/assetUrl'; import { resolveAssetPlaybackUrl } from '../lib/assetUrl';
import { downloadManager } from '../lib/offline/DownloadManager';
import { parseJsonObject } from '../lib/parseJson'; import { parseJsonObject } from '../lib/parseJson';
import { import {
resolveNavigationTarget, resolveNavigationTarget,
@ -68,7 +67,6 @@ import type {
CanvasElementType, CanvasElementType,
CanvasElement, CanvasElement,
ConstructorSchema, ConstructorSchema,
ConstructorAsset as ProjectAsset,
EditorMenuItem, EditorMenuItem,
GalleryCard, GalleryCard,
GalleryInfoSpan, GalleryInfoSpan,
@ -95,7 +93,6 @@ import { useConstructorElements } from '../hooks/useConstructorElements';
import { usePageBackground } from '../hooks/usePageBackground'; import { usePageBackground } from '../hooks/usePageBackground';
import { useConstructorData } from '../hooks/useConstructorData'; import { useConstructorData } from '../hooks/useConstructorData';
import { useAssetOptions } from '../hooks/useAssetOptions'; import { useAssetOptions } from '../hooks/useAssetOptions';
import { useTransitionCreation } from '../hooks/useTransitionCreation';
import { usePublishStatus } from '../hooks/usePublishStatus'; import { usePublishStatus } from '../hooks/usePublishStatus';
import { import {
ConstructorProvider, ConstructorProvider,
@ -104,14 +101,7 @@ import {
} from '../context/ConstructorContext'; } from '../context/ConstructorContext';
import { useCanvasScale } from '../hooks/useCanvasScale'; import { useCanvasScale } from '../hooks/useCanvasScale';
import { useVideoSoundControl } from '../hooks/useVideoSoundControl'; import { useVideoSoundControl } from '../hooks/useVideoSoundControl';
import { CANVAS_CONFIG } from '../config/canvas.config'; import { useNetworkAware } from '../hooks/useNetworkAware';
// Constructor helpers (extracted utilities)
import {
getAssetLabel,
getAssetSourceValue,
isBackgroundImageAsset,
} from '../lib/constructorHelpers';
// TourPage type is imported from '../types/entities' // TourPage type is imported from '../types/entities'
// NavigationElementType is imported from '../context/ConstructorContext' // NavigationElementType is imported from '../context/ConstructorContext'
@ -234,6 +224,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
videoUrl: backgroundVideoUrl, // Track video changes for page navigation reset videoUrl: backgroundVideoUrl, // Track video changes for page navigation reset
}); });
// Network-aware transitions: skip video on slow networks, use CSS fade instead
const { shouldUseVideoTransitions, networkInfo } = useNetworkAware();
// Fetch global transition defaults on mount // Fetch global transition defaults on mount
useEffect(() => { useEffect(() => {
dispatch(fetchGlobalTransitionDefaults()); dispatch(fetchGlobalTransitionDefaults());
@ -288,13 +281,6 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
elementId: string; elementId: string;
initialIndex: number; initialIndex: number;
} | null>(null); } | null>(null);
// Track background ready state for smooth video transition completion
const [isBackgroundReady, setIsBackgroundReady] = useState(true);
const [pendingTransitionComplete, setPendingTransitionComplete] =
useState(false);
// Track background video buffering state for loading indicator
const [isBackgroundVideoBuffering, setIsBackgroundVideoBuffering] =
useState(false);
// Current element transition settings (for CSS transitions when no video) // Current element transition settings (for CSS transitions when no video)
const [ const [
currentElementTransitionSettings, currentElementTransitionSettings,
@ -424,8 +410,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
}); });
// Preload orchestrator for better DX when previewing pages // Preload orchestrator for better DX when previewing pages
// Preloads neighbor page assets and transition videos // Preloads current page + transition videos only
// Uses allPagesPreloadElements (extracted in loadData) for proper neighbor preloading // STREAM-FIRST: Constructor always uses online mode
// Transition videos stream on-demand, then cache for replay
const preloadOrchestrator = usePreloadOrchestrator({ const preloadOrchestrator = usePreloadOrchestrator({
pages: pages.map((p) => ({ pages: pages.map((p) => ({
id: p.id, id: p.id,
@ -434,29 +421,12 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
background_audio_url: p.background_audio_url, background_audio_url: p.background_audio_url,
})), })),
pageLinks, pageLinks,
elements: allPagesPreloadElements, // Use elements from ALL pages for proper neighbor preloading elements: allPagesPreloadElements,
currentPageId: activePageId, currentPageId: activePageId,
pageHistory,
enabled: !isLoading && !!activePageId, enabled: !isLoading && !!activePageId,
// maxNeighborDepth defaults to 1 - only preload immediate neighbors
}); });
// Compute page loading state for UI feedback
const isPagePreloading =
preloadOrchestrator?.currentPhase === 'phase1_current_page';
// Page switch hook for smooth background transitions (uses blob URLs from preload cache)
const pageSwitch = usePageSwitch({
preloadCache: preloadOrchestrator
? {
getReadyBlobUrl: preloadOrchestrator.getReadyBlobUrl,
getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl,
preloadedUrls: preloadOrchestrator.preloadedUrls,
}
: undefined,
});
// Destructure stable callback reference to avoid infinite loops in useEffect deps
const pageSwitchToPage = pageSwitch.switchToPage;
// Resolve transition settings using cascade: element → project → global // Resolve transition settings using cascade: element → project → global
const transitionSettings = useTransitionSettings({ const transitionSettings = useTransitionSettings({
globalDefaults: globalTransitionDefaults, globalDefaults: globalTransitionDefaults,
@ -464,59 +434,53 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
elementSettings: currentElementTransitionSettings, elementSettings: currentElementTransitionSettings,
}); });
// Use shared background transition hook for fade-from-black effect // Unified page navigation state machine (replaces 6+ separate hooks)
// Black overlay fades out when page switches // Uses useReducer for atomic state transitions, preventing race conditions
const { isFadingIn, transitionStyle } = useBackgroundTransition({ const navState = usePageNavigationState({
pageSwitch, preloadCache: preloadOrchestrator
? {
getReadyBlobUrl: preloadOrchestrator.getReadyBlobUrl,
getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl,
preloadedUrls: preloadOrchestrator.preloadedUrls,
}
: undefined,
transitionSettings, transitionSettings,
fadeIn: {
hasActiveTransition: Boolean(transitionPreview),
},
}); });
// Video transition overlay removal - instant (no fade) when background is ready // Destructure for convenience (matches previous hook interfaces)
// Uses double RAF to ensure browser has painted the new background before removing overlay // showElements/showSpinner are derived from the unified state machine phase:
useEffect(() => { // - showElements: true when phase is 'idle' or 'fading_in'
if (pendingTransitionComplete && isBackgroundReady) { // - showSpinner: true when phase is 'preparing', 'loading_bg', or 'transition_done'
// Wait for paint cycle to complete before removing overlay const {
// Double RAF ensures the new background is fully rendered currentImageUrl: navCurrentBgImageUrl,
requestAnimationFrame(() => { currentVideoUrl: navCurrentBgVideoUrl,
requestAnimationFrame(() => { currentAudioUrl: navCurrentBgAudioUrl,
const video = transitionVideoRef.current; previousImageUrl: navPreviousBgImageUrl,
if (video) { previousVideoUrl: navPreviousBgVideoUrl,
video.removeAttribute('src'); isSwitching: navIsSwitching,
video.load(); isNewBgReady: navIsNewBgReady,
} pendingTransitionComplete,
closeTransitionPreview(); isFadingIn,
setPendingTransitionComplete(false); showElements: navShowElements,
}); showSpinner: navShowSpinner,
}); showTransitionVideo,
} transitionStyle,
}, [pendingTransitionComplete, isBackgroundReady, closeTransitionPreview]); lastKnownBgUrl,
onBackgroundReady: navOnBackgroundReady,
onVideoBufferStateChange,
onTransitionEnded,
navigateToPage: navNavigateToPage,
resetToIdle: navResetToIdle,
startTransition,
} = navState;
// Handle background ready state for pages without any background // Reset navigation state when starting a new transition
useEffect(() => { const resetFadeIn = useCallback(() => {
// Only mark ready immediately if there's no background media at all. navResetToIdle();
// For pages with image or video, CanvasBackground will call onBackgroundReady }, [navResetToIdle]);
// after the media's first frame is painted (using scheduleAfterPaint or requestVideoFrameCallback).
if (
!activePage?.background_image_url &&
!activePage?.background_video_url
) {
setIsBackgroundReady(true);
}
}, [activePage?.background_image_url, activePage?.background_video_url]);
// Reset pending state when starting a new transition
useEffect(() => {
if (transitionPreview) {
setPendingTransitionComplete(false);
}
}, [transitionPreview]);
// Helper to switch pages without flash // Helper to switch pages without flash
// Uses usePageSwitch hook to resolve blob URLs from preload cache // Uses unified navigation state machine for blob URL resolution
// Also updates storage path state for editing/saving purposes
// isBack parameter indicates this is a back navigation (pops history instead of pushing) // isBack parameter indicates this is a back navigation (pops history instead of pushing)
const switchToPage = useCallback( const switchToPage = useCallback(
async (page: TourPage | null, isBack = false) => { async (page: TourPage | null, isBack = false) => {
@ -525,12 +489,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
lastInitializedPageIdRef.current = page.id; lastInitializedPageIdRef.current = page.id;
} }
// Update consolidated background state (replaces 8 separate setters) // Use unified navigation state machine for atomic state transitions
updateBackgroundFromPage(page); await navNavigateToPage(
// Use hook to resolve and set blob URLs for display
// Fade-from-black starts automatically when page switches
await pageSwitchToPage(
page page
? { ? {
id: page.id, id: page.id,
@ -539,18 +499,22 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
background_audio_url: page.background_audio_url, background_audio_url: page.background_audio_url,
} }
: null, : null,
() => { {
if (page) { hasTransition: false, // No video transition for direct navigation
// Use applyPageSelection for proper history management (pops on back) isBack,
applyPageSelection(page.id, isBack); onSwitched: () => {
} if (page) {
// Use applyPageSelection for proper history management (pops on back)
applyPageSelection(page.id, isBack);
}
},
}, },
); );
}, },
[pageSwitchToPage, updateBackgroundFromPage, applyPageSelection], [navNavigateToPage, applyPageSelection],
); );
const { isBuffering: isReverseBuffering } = useTransitionPlayback({ const { isBuffering: isTransitionBuffering, isVideoReady: isTransitionVideoReady } = useTransitionPlayback({
videoRef: transitionVideoRef, videoRef: transitionVideoRef,
transition: transitionPreview transition: transitionPreview
? { ? {
@ -560,6 +524,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
reverseVideoUrl: transitionPreview.reverseVideoUrl reverseVideoUrl: transitionPreview.reverseVideoUrl
? resolveAssetPlaybackUrl(transitionPreview.reverseVideoUrl) ? resolveAssetPlaybackUrl(transitionPreview.reverseVideoUrl)
: undefined, : undefined,
reverseStorageKey: transitionPreview.reverseStorageKey, // Raw path for cache lookup
durationSec: transitionPreview.durationSec, durationSec: transitionPreview.durationSec,
targetPageId: pendingNavigationPageId || undefined, targetPageId: pendingNavigationPageId || undefined,
displayName: transitionPreview.title, displayName: transitionPreview.title,
@ -567,24 +532,26 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
} }
: null, : null,
onComplete: async (targetPageId, isBack) => { onComplete: async (targetPageId, isBack) => {
// Resume background downloads now that transition is complete
downloadManager.resumeAll();
const video = transitionVideoRef.current; const video = transitionVideoRef.current;
if (targetPageId) { if (targetPageId) {
const targetPage = pages.find((p) => p.id === targetPageId) || null; const targetPage = pages.find((p) => p.id === targetPageId) || null;
// Use switchToPage which resolves blob URLs via usePageSwitch // Signal that transition video has ended
// Pass isBack flag for proper history management (pops on back) // State machine transitions to 'transition_done', waiting for background
onTransitionEnded();
// DON'T close preview here - it stays visible until background is ready
// The useEffect watching showTransitionVideo will close it
// Navigate to target page - state machine handles ready state
await switchToPage(targetPage, isBack ?? false); await switchToPage(targetPage, isBack ?? false);
clearSelection(); clearSelection();
setSelectedMenuItem('none'); setSelectedMenuItem('none');
setErrorMessage(''); setErrorMessage('');
setIsBackgroundReady(false);
// Signal that transition video completed - wait for background to load
// Overlay will be removed instantly when isBackgroundReady becomes true
setPendingTransitionComplete(true);
} else { } else {
video?.removeAttribute('src'); video?.removeAttribute('src');
video?.load(); video?.load();
closeTransitionPreview(); closeTransitionPreview();
setPendingTransitionComplete(false); navResetToIdle();
} }
}, },
timeouts: { timeouts: {
@ -593,7 +560,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
}, },
features: { features: {
useBlobUrl: true, useBlobUrl: true,
preDecodeImages: false, // We handle image loading via usePageSwitch preDecodeImages: false, // We handle image loading via navigation state machine
}, },
preload: { preload: {
preloadedUrls: preloadOrchestrator.preloadedUrls, preloadedUrls: preloadOrchestrator.preloadedUrls,
@ -603,6 +570,22 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
}, },
}); });
// Sync transition video buffering state with navigation state machine
// This enables unified showSpinner logic in the state machine
useEffect(() => {
const isBuffering = Boolean(transitionPreview) && isTransitionBuffering;
onVideoBufferStateChange(isBuffering);
}, [transitionPreview, isTransitionBuffering, onVideoBufferStateChange]);
// Clean up transition preview when state machine says video overlay should be hidden
// showTransitionVideo is true during 'transitioning', 'transition_done', and 'fading_in' phases
// During 'fading_in', the overlay fades out (isFadingOut=true), then removed when phase goes to 'idle'
useEffect(() => {
if (transitionPreview && !showTransitionVideo) {
closeTransitionPreview();
}
}, [transitionPreview, showTransitionVideo, closeTransitionPreview]);
const iconPreloadTargets = useMemo(() => { const iconPreloadTargets = useMemo(() => {
const preloadableTypes: CanvasElementType[] = [ const preloadableTypes: CanvasElementType[] = [
'navigation_next', 'navigation_next',
@ -1084,12 +1067,29 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
// If current selection is still valid, do nothing (keep current) // If current selection is still valid, do nothing (keep current)
// Update consolidated background state (replaces 8 separate setters) // Update consolidated background state (replaces 8 separate setters)
updateBackgroundFromPage(activePage); updateBackgroundFromPage(activePage);
}, [
activePage,
elementIdFromRoute,
uiElementDefaultsByType,
clearSelection,
selectElement,
setElements,
updateBackgroundFromPage,
]);
// Resolve blob URLs via hook for display (handles initial load and route changes) // Separate effect for initial background loading (matches RuntimePresentation pattern)
// Only call if this page wasn't already initialized via switchToPage function // This effect ONLY handles initial page load when backgrounds are empty.
if (lastInitializedPageIdRef.current !== activePage.id) { // switchToPage handles all subsequent navigation by calling navNavigateToPage directly.
// Keeping this separate prevents race conditions where state updates trigger
// this effect before activePageId has been updated via applyPageSelection.
useEffect(() => {
if (!activePage || lastInitializedPageIdRef.current === activePage.id)
return;
// Only initialize when backgrounds are EMPTY (initial load)
if (!navCurrentBgImageUrl && !navCurrentBgVideoUrl) {
lastInitializedPageIdRef.current = activePage.id; lastInitializedPageIdRef.current = activePage.id;
pageSwitchToPage({ navNavigateToPage({
id: activePage.id, id: activePage.id,
background_image_url: activePage.background_image_url, background_image_url: activePage.background_image_url,
background_video_url: activePage.background_video_url, background_video_url: activePage.background_video_url,
@ -1098,13 +1098,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
} }
}, [ }, [
activePage, activePage,
elementIdFromRoute, navCurrentBgImageUrl,
uiElementDefaultsByType, navCurrentBgVideoUrl,
pageSwitchToPage, navNavigateToPage,
clearSelection,
selectElement,
setElements,
updateBackgroundFromPage,
]); ]);
useEffect(() => { useEffect(() => {
@ -1206,23 +1202,15 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
if (!isConstructorEditMode) { if (!isConstructorEditMode) {
if (isNavigationElementType(element.type)) { if (isNavigationElementType(element.type)) {
// Disable navigation while transition is playing or buffering // Disable navigation while transition is playing or buffering
if (transitionPreview || isReverseBuffering) { if (transitionPreview || isTransitionBuffering) {
return; return;
} }
if (element.navDisabled) { if (element.navDisabled) {
return; return;
} }
// DISABLED: Block forward navigation if neighbor backgrounds not yet preloaded // Cancel any pending fade to prevent stale fade state across navigations
// Back navigation is always allowed (previous pages are already visited) resetFadeIn();
if (
false &&
!isBackNavigation(element) &&
!preloadOrchestrator.areNeighborBackgroundsReady
) {
logger.info('Navigation blocked - neighbors not preloaded');
return;
}
// Use shared navigation helpers // Use shared navigation helpers
const direction = getNavigationDirection(element); const direction = getNavigationDirection(element);
@ -1279,10 +1267,48 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
setCurrentElementTransitionSettings(elementSettings); setCurrentElementTransitionSettings(elementSettings);
}); });
// Note: Background ready state is reset atomically by navigateToPage/switchToPage
// Check if transition can be played using shared helper // Check if transition can be played using shared helper
if (!hasPlayableTransition(transitionSource, direction)) { const canPlayTransition = hasPlayableTransition(transitionSource, direction);
// Check if video is already cached (use video even on slow network if cached)
const transitionUrl = transitionSource.transitionVideoUrl;
const isTransitionCached =
transitionUrl && preloadOrchestrator.getReadyBlobUrl(transitionUrl);
// Use video if: has playable transition AND (cached OR good network)
const useVideoTransition =
canPlayTransition && (isTransitionCached || shouldUseVideoTransitions);
if (!useVideoTransition) {
closeTransitionPreview(); closeTransitionPreview();
// Use switchToPage which resolves blob URLs via usePageSwitch (reduces flash)
// Log when skipping video due to slow network
if (canPlayTransition && !shouldUseVideoTransitions && transitionUrl) {
logger.info(
'[NAVIGATION] Skipping video transition due to slow network, downloading in background',
{
effectiveType: networkInfo.effectiveType,
downlink: networkInfo.downlink,
rtt: networkInfo.rtt,
},
);
// Start background download of transition video for future use (low priority)
downloadManager.addJob({
assetId: `transition-bg-${transitionUrl}`,
projectId: 'transition-preload',
url: resolveAssetPlaybackUrl(transitionUrl),
filename: transitionUrl.split('/').pop() || 'transition.mp4',
variantType: 'original',
assetType: 'video',
priority: 10, // Low priority - background preload
storageKey: transitionUrl,
});
}
// Use switchToPage which resolves blob URLs via navigation state machine (reduces flash)
// Pass isBack flag for proper history management // Pass isBack flag for proper history management
switchToPage(navTarget.page, navTarget.isBack).then(() => { switchToPage(navTarget.page, navTarget.isBack).then(() => {
clearSelection(); clearSelection();
@ -1292,6 +1318,18 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
return; return;
} }
// Signal navigation state machine that video transition is starting
// This sets phase to 'transitioning' so spinner shows during buffering
logger.info('[TRANSITION-START] 🚀 Starting transition', {
targetPageId: navTarget.pageId,
direction,
isBack: navTarget.isBack,
transitionVideoUrl: transitionSource.transitionVideoUrl?.slice(-60),
reverseVideoUrl: transitionSource.reverseVideoUrl?.slice(-60),
});
// Pause background downloads to give transition video exclusive bandwidth
downloadManager.pauseAll();
startTransition(navTarget.pageId, navTarget.isBack);
openPreviewWithTarget(transitionSource, direction, navTarget.pageId); openPreviewWithTarget(transitionSource, direction, navTarget.pageId);
} }
return; return;
@ -1364,20 +1402,30 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
element.appearDurationSec, element.appearDurationSec,
); );
const isElementReadyForCanvasRender = (element: CanvasElement) => { // Check if a single element's icon is ready (used for individual element visibility)
const isPreloadableIconElement = const isElementIconReady = useCallback(
(isNavigationElementType(element.type) || (element: CanvasElement) => {
isTooltipElementType(element.type) || const isPreloadableIconElement =
isDescriptionElementType(element.type)) && (isNavigationElementType(element.type) ||
Boolean(element.iconUrl); isTooltipElementType(element.type) ||
isDescriptionElementType(element.type)) &&
Boolean(element.iconUrl);
if (!isPreloadableIconElement) return true; if (!isPreloadableIconElement) return true;
const playbackUrl = resolveAssetPlaybackUrl(element.iconUrl); const playbackUrl = resolveAssetPlaybackUrl(element.iconUrl);
if (!playbackUrl) return true; if (!playbackUrl) return true;
return Boolean(preloadedIconUrlMap[playbackUrl]); return Boolean(preloadedIconUrlMap[playbackUrl]);
}; },
[preloadedIconUrlMap],
);
// Check if ALL element icons are ready - used to show all elements together
// This prevents staggered element appearance where some elements show before others
const areAllElementIconsReady = useMemo(() => {
return elements.every((element) => isElementIconReady(element));
}, [elements, isElementIconReady]);
// URL resolver that uses preloaded blob URLs when available // URL resolver that uses preloaded blob URLs when available
// Depends on readyUrlsVersion to re-render when blob URLs become ready after preload // Depends on readyUrlsVersion to re-render when blob URLs become ready after preload
@ -1400,18 +1448,44 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
const canvasBackgroundStyle: React.CSSProperties = {}; const canvasBackgroundStyle: React.CSSProperties = {};
// Unified background URL resolution via shared hook // Unified background URL resolution
// Priority: local paths (editing) > pageSwitch (navigation) // Edit mode: use local storage paths (for immediate editing feedback)
const { backgroundImageSrc, backgroundVideoSrc, backgroundAudioSrc } = // Interact mode: use nav state values (blob URLs from preload cache)
useBackgroundUrls({ const backgroundImageSrc = useMemo(() => {
pageSwitch, if (isConstructorEditMode && backgroundImageUrl) {
resolveUrl: resolveUrlWithBlob, return resolveUrlWithBlob(backgroundImageUrl);
localPaths: { }
imageUrl: backgroundImageUrl, return navCurrentBgImageUrl;
videoUrl: backgroundVideoUrl, }, [
audioUrl: backgroundAudioUrl, isConstructorEditMode,
}, backgroundImageUrl,
}); navCurrentBgImageUrl,
resolveUrlWithBlob,
]);
const backgroundVideoSrc = useMemo(() => {
if (isConstructorEditMode && backgroundVideoUrl) {
return resolveUrlWithBlob(backgroundVideoUrl);
}
return navCurrentBgVideoUrl;
}, [
isConstructorEditMode,
backgroundVideoUrl,
navCurrentBgVideoUrl,
resolveUrlWithBlob,
]);
const backgroundAudioSrc = useMemo(() => {
if (isConstructorEditMode && backgroundAudioUrl) {
return resolveUrlWithBlob(backgroundAudioUrl);
}
return navCurrentBgAudioUrl;
}, [
isConstructorEditMode,
backgroundAudioUrl,
navCurrentBgAudioUrl,
resolveUrlWithBlob,
]);
const hasEditorSelection = const hasEditorSelection =
isConstructorEditMode && isConstructorEditMode &&
@ -1692,6 +1766,23 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
}} }}
> >
<BackdropPortalProvider> <BackdropPortalProvider>
{/* Safari Black Flash Prevention (video transitions only):
Persistent snapshot layer shown ONLY during video transitions.
z-[1] keeps it behind backgrounds (z-5) but above the black container. */}
{lastKnownBgUrl &&
isSafari() &&
(transitionPreview || pendingTransitionComplete) && (
<div
className='absolute inset-0 z-[1] pointer-events-none'
style={{
backgroundImage: `url("${lastKnownBgUrl}")`,
backgroundSize: 'contain',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
}}
/>
)}
{/* Background wrapper - z-5 keeps it BELOW carousel slide (z-10). {/* Background wrapper - z-5 keeps it BELOW carousel slide (z-10).
Previous background overlay shows during loading. Previous background overlay shows during loading.
Black overlay for fade effect is rendered separately at z-[100]. */} Black overlay for fade effect is rendered separately at z-[100]. */}
@ -1700,15 +1791,12 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
backgroundImageUrl={backgroundImageSrc} backgroundImageUrl={backgroundImageSrc}
backgroundVideoUrl={backgroundVideoSrc} backgroundVideoUrl={backgroundVideoSrc}
backgroundAudioUrl={backgroundAudioSrc} backgroundAudioUrl={backgroundAudioSrc}
previousBgImageUrl={pageSwitch.previousBgImageUrl} previousBgImageUrl={navPreviousBgImageUrl}
previousBgVideoUrl={pageSwitch.previousBgVideoUrl} previousBgVideoUrl={navPreviousBgVideoUrl}
isSwitching={pageSwitch.isSwitching} isSwitching={navIsSwitching}
isNewBgReady={pageSwitch.isNewBgReady} isNewBgReady={navIsNewBgReady}
onBackgroundReady={() => { onBackgroundReady={navOnBackgroundReady}
pageSwitch.markBackgroundReady(); onVideoBufferStateChange={onVideoBufferStateChange}
setIsBackgroundReady(true);
}}
onVideoBufferStateChange={setIsBackgroundVideoBuffering}
videoAutoplay={backgroundVideoAutoplay} videoAutoplay={backgroundVideoAutoplay}
videoLoop={backgroundVideoLoop} videoLoop={backgroundVideoLoop}
videoMuted={soundControl.isMuted} videoMuted={soundControl.isMuted}
@ -1717,94 +1805,92 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
videoStoragePath={ videoStoragePath={
backgroundVideoUrl || activePage?.background_video_url backgroundVideoUrl || activePage?.background_video_url
} }
pauseVideo={
Boolean(transitionPreview) ||
pendingTransitionComplete ||
navIsSwitching
}
/> />
</div> </div>
{/* Page loading spinner - shows during Phase 1 (current page loading) */} {/* Page loading spinner - from unified navigation state machine.
{isPagePreloading && ( navShowSpinner is true when:
<CanvasLoadingSpinner - Phase is 'preparing', 'loading_bg', 'transition_done', OR
isVisible={true} - Video transition is active but buffering
message='Loading page...' Additionally for constructor: show while element icons are loading.
progress={preloadOrchestrator?.phaseProgress} Only show in Interact mode - Edit mode doesn't need loading indicators.
zIndex={100} Skip when video transition overlay is active - it has its own spinner. */}
/> {!isConstructorEditMode &&
)} !transitionPreview &&
(navShowSpinner ||
(navShowElements && !areAllElementIconsReady)) && (
<CanvasLoadingSpinner isVisible={true} zIndex={100} />
)}
{/* Elements container - z-[46] keeps it ABOVE carousel slide (z-10) AND carousel controls (z-45). {/* Elements container - z-[46] keeps it ABOVE carousel slide (z-10) AND carousel controls (z-45).
UI controls (z-50) remain on top. UI controls (z-50) remain on top.
No fade animation - elements switch instantly behind the black overlay. */} No fade animation - elements switch instantly behind the black overlay.
<div className='absolute inset-0 z-[46]'> Shows when phase is 'idle' or 'fading_in' (navShowElements) AND all element icons are preloaded.
{isLoading ? ( This ensures all elements appear together (no staggered appearance).
<div className='absolute inset-0 flex items-center justify-center'> Exception: Edit mode always shows elements for editing (no icon preload wait). */}
<p className='text-sm text-gray-500'> {(isConstructorEditMode ||
Loading constructor... (navShowElements && areAllElementIconsReady)) && (
</p> <div className='absolute inset-0 z-[46]'>
</div> {!isLoading && pages.length === 0 ? (
) : pages.length === 0 ? ( <div className='absolute inset-0 flex items-center justify-center'>
<div className='absolute inset-0 flex items-center justify-center'> <BaseButton
<BaseButton color='info'
color='info' label={
label={isCreatingPage ? 'Creating...' : 'Create First Page'} isCreatingPage ? 'Creating...' : 'Create First Page'
icon={mdiPlus}
onClick={createPage}
disabled={isCreatingPage}
/>
</div>
) : (
elements.map((element) => {
const shouldRender =
selectedElementId === element.id ||
(isElementVisibleOnCanvas(element) &&
isElementReadyForCanvasRender(element));
if (!shouldRender) return null;
// Compute disabled state for navigation elements
// Disabled when:
// - Element explicitly disabled (navDisabled)
// - Transition is playing or buffering
// - DISABLED: Neighbor backgrounds not yet preloaded (forward only)
const isForwardNav =
isNavigationElementType(element.type) &&
!isBackNavigation(element);
const isNavDisabled =
isNavigationElementType(element.type) &&
(element.navDisabled ||
Boolean(transitionPreview) ||
isReverseBuffering ||
(false &&
isForwardNav &&
!preloadOrchestrator.areNeighborBackgroundsReady));
return (
<CanvasElementComponent
key={element.id}
element={element}
isSelected={selectedElementId === element.id}
isEditMode={isConstructorEditMode}
isDisabled={isNavDisabled}
onClick={() => onCanvasElementClick(element)}
onMouseDown={(event) =>
onElementMouseDown(event, element.id)
} }
resolveUrl={resolveUrlWithBlob} icon={mdiPlus}
onGalleryCardClick={(cardIndex) => onClick={createPage}
handleGalleryCardClick(element, cardIndex) disabled={isCreatingPage}
}
onCarouselButtonPositionChange={(button, x, y) =>
handleCarouselButtonPositionChange(
element.id,
button,
x,
y,
)
}
letterboxStyles={letterboxStyles}
pageTransitionSettings={transitionSettings}
/> />
); </div>
}) ) : (
)} elements.map((element) => {
</div> // Icon preloading is now handled at container level (areAllElementIconsReady)
// Here we only check visibility based on appear delay/duration timing
const shouldRender =
selectedElementId === element.id ||
isElementVisibleOnCanvas(element);
if (!shouldRender) return null;
return (
<CanvasElementComponent
key={element.id}
element={element}
isSelected={selectedElementId === element.id}
isEditMode={isConstructorEditMode}
onClick={() => onCanvasElementClick(element)}
onMouseDown={(event) =>
onElementMouseDown(event, element.id)
}
resolveUrl={resolveUrlWithBlob}
onGalleryCardClick={(cardIndex) =>
handleGalleryCardClick(element, cardIndex)
}
onCarouselButtonPositionChange={(button, x, y) =>
handleCarouselButtonPositionChange(
element.id,
button,
x,
y,
)
}
letterboxStyles={letterboxStyles}
pageTransitionSettings={transitionSettings}
preloadCache={{
getReadyBlob: preloadOrchestrator.getReadyBlob,
getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl,
}}
/>
);
})
)}
</div>
)}
{/* Overlay for fade-through-color transition - z-[100] is ABOVE elements (z-[46]). {/* Overlay for fade-through-color transition - z-[100] is ABOVE elements (z-[46]).
This covers the elements during page transition to hide the instant switch. This covers the elements during page transition to hide the instant switch.
@ -1834,11 +1920,13 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
<TransitionPreviewOverlay <TransitionPreviewOverlay
videoKey={transitionPreview?.videoUrl} videoKey={transitionPreview?.videoUrl}
videoRef={transitionVideoRef} videoRef={transitionVideoRef}
isActive={Boolean(transitionPreview)} isActive={Boolean(transitionPreview) && showTransitionVideo}
isBuffering={isReverseBuffering} isBuffering={isTransitionBuffering}
isVideoReady={isTransitionVideoReady}
showSpinner={true} showSpinner={true}
spinnerMessage='Preparing transition...'
letterboxStyles={letterboxStyles} letterboxStyles={letterboxStyles}
isFadingOut={isFadingIn}
fadeOutDuration={transitionSettings.durationMs}
/> />
{/* Gallery Carousel Overlay */} {/* Gallery Carousel Overlay */}

View File

@ -14,6 +14,7 @@ import type { PrecacheEntry, SerwistGlobalConfig } from 'serwist';
import { import {
CacheFirst, CacheFirst,
NetworkFirst, NetworkFirst,
NetworkOnly,
Serwist, Serwist,
StaleWhileRevalidate, StaleWhileRevalidate,
} from 'serwist'; } from 'serwist';
@ -85,6 +86,12 @@ const isCacheableRequest = (request: Request): boolean => {
return false; return false;
}; };
// Check if request has sw-bypass flag (skip SW caching)
const hasBypassFlag = (request: Request): boolean => {
const url = new URL(request.url);
return url.searchParams.get('sw-bypass') === '1';
};
// Check if request is a video // Check if request is a video
const isVideoRequest = (request: Request): boolean => { const isVideoRequest = (request: Request): boolean => {
const url = new URL(request.url); const url = new URL(request.url);
@ -171,6 +178,12 @@ const serwist = new Serwist({
clientsClaim: true, clientsClaim: true,
navigationPreload: true, navigationPreload: true,
runtimeCaching: [ runtimeCaching: [
// BYPASS HANDLER - Must be FIRST to intercept sw-bypass=1 requests
// These requests go directly to network without any caching or URL transformation
{
matcher: ({ request }) => hasBypassFlag(request),
handler: new NetworkOnly(),
},
// Static assets (images, fonts, css, js) - Cache First // Static assets (images, fonts, css, js) - Cache First
// Uses 'assets' cache to match preloading (usePreloadOrchestrator stores here) // Uses 'assets' cache to match preloading (usePreloadOrchestrator stores here)
{ {
@ -212,6 +225,7 @@ const serwist = new Serwist({
}), }),
}, },
// Videos - Cache First with Range Request support and storage key mapping // Videos - Cache First with Range Request support and storage key mapping
// Note: Bypass requests are already handled by the NetworkOnly handler above
{ {
matcher: ({ request }) => isVideoRequest(request), matcher: ({ request }) => isVideoRequest(request),
handler: new CacheFirst({ handler: new CacheFirst({
@ -289,6 +303,7 @@ const serwist = new Serwist({
}), }),
}, },
// Audio - Cache First with Range Request support and storage key mapping // Audio - Cache First with Range Request support and storage key mapping
// Note: Bypass requests are already handled by the NetworkOnly handler above
{ {
matcher: ({ request }) => isAudioRequest(request), matcher: ({ request }) => isAudioRequest(request),
handler: new CacheFirst({ handler: new CacheFirst({
@ -369,6 +384,7 @@ const serwist = new Serwist({
}, },
// Dynamic assets (other cacheable, excluding video and audio) - 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)
// Note: Bypass requests are already handled by the NetworkOnly handler above
{ {
matcher: ({ request }) => matcher: ({ request }) =>
isCacheableRequest(request) && isCacheableRequest(request) &&

View File

@ -25,6 +25,7 @@ export interface PreloadPageLink {
transition?: { transition?: {
id: string; id: string;
video_url?: string; video_url?: string;
reverse_video_url?: string;
}; };
} }