improved video processing, pages navigation and assets preloading
This commit is contained in:
parent
ba813d2602
commit
06a29dbf6a
@ -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
|
||||||
|
|||||||
@ -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
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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 */}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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} />;
|
||||||
|
|||||||
@ -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)}
|
||||||
|
|||||||
@ -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]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
163
frontend/src/context/PageNavigationContext.tsx
Normal file
163
frontend/src/context/PageNavigationContext.tsx
Normal 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';
|
||||||
@ -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';
|
||||||
|
|||||||
@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -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 };
|
||||||
|
|||||||
@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -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(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
867
frontend/src/hooks/usePageNavigationState.ts
Normal file
867
frontend/src/hooks/usePageNavigationState.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -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
@ -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],
|
||||||
|
|||||||
59
frontend/src/hooks/video/index.ts
Normal file
59
frontend/src/hooks/video/index.ts
Normal 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';
|
||||||
185
frontend/src/hooks/video/useVideoBlobUrl.ts
Normal file
185
frontend/src/hooks/video/useVideoBlobUrl.ts
Normal 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;
|
||||||
216
frontend/src/hooks/video/useVideoBufferingState.ts
Normal file
216
frontend/src/hooks/video/useVideoBufferingState.ts
Normal 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;
|
||||||
174
frontend/src/hooks/video/useVideoErrorRecovery.ts
Normal file
174
frontend/src/hooks/video/useVideoErrorRecovery.ts
Normal 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;
|
||||||
114
frontend/src/hooks/video/useVideoEventManager.ts
Normal file
114
frontend/src/hooks/video/useVideoEventManager.ts
Normal 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;
|
||||||
123
frontend/src/hooks/video/useVideoFirstFrame.ts
Normal file
123
frontend/src/hooks/video/useVideoFirstFrame.ts
Normal 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;
|
||||||
337
frontend/src/hooks/video/useVideoPlaybackCore.ts
Normal file
337
frontend/src/hooks/video/useVideoPlaybackCore.ts
Normal 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;
|
||||||
245
frontend/src/hooks/video/useVideoPlayer.ts
Normal file
245
frontend/src/hooks/video/useVideoPlayer.ts
Normal 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;
|
||||||
93
frontend/src/hooks/video/useVideoTimeouts.ts
Normal file
93
frontend/src/hooks/video/useVideoTimeouts.ts
Normal 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;
|
||||||
@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 */}
|
||||||
|
|||||||
@ -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) &&
|
||||||
|
|||||||
@ -25,6 +25,7 @@ export interface PreloadPageLink {
|
|||||||
transition?: {
|
transition?: {
|
||||||
id: string;
|
id: string;
|
||||||
video_url?: string;
|
video_url?: string;
|
||||||
|
reverse_video_url?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user