improved preloading functionality

This commit is contained in:
Dmitri 2026-05-06 10:04:42 +02:00
parent f06a2b2c97
commit ba813d2602
12 changed files with 574 additions and 61 deletions

View File

@ -0,0 +1,91 @@
/**
* CanvasLoadingSpinner Component
*
* Loading spinner overlay for canvas contexts.
* Shows during video preparation/buffering.
* Includes delay to avoid flashing for quick operations.
*/
import React, { useState, useEffect } from 'react';
import { PRELOAD_CONFIG } from '../config/preload.config';
interface CanvasLoadingSpinnerProps {
/** Whether the spinner is visible */
isVisible: boolean;
/** Loading message to display */
message?: string;
/** Spinner size */
size?: 'sm' | 'md' | 'lg';
/** Loading progress (0-100) */
progress?: number;
/** Z-index for stacking (default: 100) */
zIndex?: number;
}
const CanvasLoadingSpinner: React.FC<CanvasLoadingSpinnerProps> = ({
isVisible,
message = 'Loading...',
size = 'md',
progress,
zIndex = 100,
}) => {
// Delayed visibility - only show after SPINNER_DELAY_MS
const [showSpinner, setShowSpinner] = useState(false);
useEffect(() => {
if (isVisible) {
// Start timer to show spinner after delay
const timer = setTimeout(() => {
setShowSpinner(true);
}, PRELOAD_CONFIG.ui.spinnerDelayMs);
return () => clearTimeout(timer);
} else {
// Hide immediately when loading completes
setShowSpinner(false);
}
}, [isVisible]);
if (!showSpinner) return null;
const sizeClasses = {
sm: 'w-8 h-8 border-2',
md: 'w-12 h-12 border-3',
lg: 'w-16 h-16 border-4',
};
return (
<div
className='absolute inset-0 flex flex-col items-center justify-center transition-opacity duration-300'
style={{
// 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 ring */}
<div
className={`${sizeClasses[size]} rounded-full border-white/30 border-t-white animate-spin`}
style={{ borderStyle: 'solid' }}
/>
{/* Progress indicator (optional) */}
{progress !== undefined && (
<div className='absolute inset-0 flex items-center justify-center'>
<span className='text-white text-xs font-medium'>
{Math.round(progress)}%
</span>
</div>
)}
</div>
{message && (
<p className='mt-3 text-white/90 text-sm font-medium'>{message}</p>
)}
</div>
);
};
export default CanvasLoadingSpinner;

View File

@ -16,6 +16,7 @@ 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 { scheduleAfterPaint } from '../../lib/browserUtils';
import { baseURLApi } from '../../config'; import { baseURLApi } from '../../config';
@ -40,6 +41,8 @@ interface CanvasBackgroundProps {
isSwitching?: boolean; isSwitching?: boolean;
isNewBgReady?: boolean; isNewBgReady?: boolean;
onBackgroundReady?: () => void; onBackgroundReady?: () => void;
/** Callback when video buffer state changes (true = buffering, false = ready) */
onVideoBufferStateChange?: (isBuffering: boolean) => void;
// Video playback settings // Video playback settings
videoAutoplay?: boolean; videoAutoplay?: boolean;
videoLoop?: boolean; videoLoop?: boolean;
@ -59,6 +62,7 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
isSwitching = false, isSwitching = false,
isNewBgReady = false, isNewBgReady = false,
onBackgroundReady, onBackgroundReady,
onVideoBufferStateChange,
videoAutoplay = true, videoAutoplay = true,
videoLoop = true, videoLoop = true,
videoMuted = true, videoMuted = true,
@ -84,6 +88,40 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
// 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);
// Video buffering state for loading indicator
const [isVideoBuffering, setIsVideoBuffering] = useState(true);
// Track video buffering via canplay/waiting events
useEffect(() => {
const video = videoRef.current;
if (!backgroundVideoUrl || !video) {
setIsVideoBuffering(false);
return;
}
// Start as buffering for new video
setIsVideoBuffering(true);
onVideoBufferStateChange?.(true);
const handleCanPlay = () => {
setIsVideoBuffering(false);
onVideoBufferStateChange?.(false);
};
const handleWaiting = () => {
setIsVideoBuffering(true);
onVideoBufferStateChange?.(true);
};
video.addEventListener('canplay', handleCanPlay);
video.addEventListener('waiting', handleWaiting);
return () => {
video.removeEventListener('canplay', handleCanPlay);
video.removeEventListener('waiting', handleWaiting);
};
}, [backgroundVideoUrl, onVideoBufferStateChange]);
// 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 (!backgroundVideoUrl) return undefined;
@ -180,9 +218,18 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
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).
Image layer stays visible while video buffers (fallback behavior).
When video is ready, image fades out via opacity transition. */}
{backgroundImageUrl && ( {backgroundImageUrl && (
<div className='pointer-events-none absolute inset-0 z-1 h-full w-full select-none'> <div
className='pointer-events-none absolute inset-0 z-1 h-full w-full select-none'
style={{
// When video exists and is ready, hide image layer
opacity: backgroundVideoUrl && !isVideoBuffering ? 0 : 1,
transition: 'opacity 300ms ease-out',
}}
>
{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
@ -225,12 +272,18 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
Actual muted state is controlled via useBackgroundVideoPlayback hook Actual muted state is controlled via useBackgroundVideoPlayback hook
which sets video.muted property via JavaScript (useEffect). which sets video.muted property via JavaScript (useEffect).
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). */}
{backgroundVideoUrl && ( {backgroundVideoUrl && (
<video <video
ref={videoRef} ref={videoRef}
key={`bg_video_${backgroundVideoUrl}`} key={`bg_video_${backgroundVideoUrl}`}
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='metadata'
autoPlay={effectiveAutoplay} autoPlay={effectiveAutoplay}
@ -242,6 +295,17 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
/> />
)} )}
{/* Loading spinner for video-only pages (no image fallback).
Shows while video is buffering, provides user feedback. */}
{backgroundVideoUrl && !backgroundImageUrl && isVideoBuffering && (
<CanvasLoadingSpinner
isVisible={true}
message='Loading video...'
size='md'
zIndex={4}
/>
)}
{/* Background audio */} {/* Background audio */}
{backgroundAudioUrl && ( {backgroundAudioUrl && (
<audio <audio

View File

@ -10,6 +10,7 @@
*/ */
import React from 'react'; import React from 'react';
import CanvasLoadingSpinner from '../CanvasLoadingSpinner';
interface TransitionPreviewOverlayProps { interface TransitionPreviewOverlayProps {
/** Reference to the video element - useTransitionPlayback manages src and playback */ /** Reference to the video element - useTransitionPlayback manages src and playback */
@ -18,6 +19,10 @@ interface TransitionPreviewOverlayProps {
isActive: boolean; isActive: boolean;
/** Whether the video is currently buffering (used to hide video during load) */ /** Whether the video is currently buffering (used to hide video during load) */
isBuffering?: boolean; isBuffering?: boolean;
/** Show loading spinner during buffering (default: false for backward compat) */
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) */
@ -32,6 +37,8 @@ const TransitionPreviewOverlay: React.FC<TransitionPreviewOverlayProps> = ({
videoRef, videoRef,
isActive, isActive,
isBuffering = false, isBuffering = false,
showSpinner = false,
spinnerMessage = 'Preparing transition...',
letterboxStyles, letterboxStyles,
videoFit = 'contain', videoFit = 'contain',
opacity, opacity,
@ -47,28 +54,43 @@ const TransitionPreviewOverlay: React.FC<TransitionPreviewOverlayProps> = ({
// 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 <div className='fixed inset-0 z-50 overflow-hidden pointer-events-none'>
className='fixed inset-0 z-50 overflow-hidden pointer-events-none' {/* Loading spinner during buffering - provides user feedback */}
style={{ opacity: containerOpacity }} {isBuffering && showSpinner && (
> <CanvasLoadingSpinner
{/* Inner: respects letterbox dimensions when provided */} isVisible={true}
<div message={spinnerMessage}
className='overflow-hidden' size='lg'
style={letterboxStyles || { position: 'absolute', inset: 0 }} zIndex={60}
>
{/* Video element - container handles visibility, video is always opaque */}
{/* key forces React to remount the video element when URL changes, clearing decoder state */}
<video
key={videoKey}
ref={videoRef}
className={`absolute inset-0 h-full w-full ${
videoFit === 'cover' ? 'object-cover' : 'object-contain'
}`}
muted
playsInline
preload='auto'
disablePictureInPicture
/> />
)}
{/* Video container - hidden while buffering */}
<div
style={{
opacity: containerOpacity,
transition: 'opacity 150ms ease-out',
}}
>
{/* Inner: respects letterbox dimensions when provided */}
<div
className='overflow-hidden'
style={letterboxStyles || { position: 'absolute', inset: 0 }}
>
{/* Video element - container handles visibility, video is always opaque */}
{/* key forces React to remount the video element when URL changes, clearing decoder state */}
<video
key={videoKey}
ref={videoRef}
className={`absolute inset-0 h-full w-full ${
videoFit === 'cover' ? 'object-cover' : 'object-contain'
}`}
muted
playsInline
preload='auto'
disablePictureInPicture
/>
</div>
</div> </div>
</div> </div>
); );

View File

@ -25,6 +25,7 @@ import GalleryCarouselOverlay from './UiElements/GalleryCarouselOverlay';
import { BackdropPortalProvider } from './BackdropPortal'; import { BackdropPortalProvider } from './BackdropPortal';
import { RotatePrompt } from './RotatePrompt'; import { RotatePrompt } from './RotatePrompt';
import CanvasBackground from './Constructor/CanvasBackground'; import CanvasBackground from './Constructor/CanvasBackground';
import CanvasLoadingSpinner from './CanvasLoadingSpinner';
import TransitionBlackOverlay from './TransitionBlackOverlay'; import TransitionBlackOverlay from './TransitionBlackOverlay';
import { useCanvasScale } from '../hooks/useCanvasScale'; import { useCanvasScale } from '../hooks/useCanvasScale';
import { CANVAS_CONFIG } from '../config/canvas.config'; import { CANVAS_CONFIG } from '../config/canvas.config';
@ -185,6 +186,10 @@ export default function RuntimePresentation({
// Only shown during video transitions to prevent black flashes. // Only shown during video transitions to prevent black flashes.
const [lastKnownBgUrl, setLastKnownBgUrl] = useState<string>(''); 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);
@ -493,6 +498,12 @@ export default function RuntimePresentation({
const areNeighborBackgroundsReady = const areNeighborBackgroundsReady =
preloadOrchestrator?.areNeighborBackgroundsReady ?? true; preloadOrchestrator?.areNeighborBackgroundsReady ?? 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 // Compute disabled state for forward navigation elements
// DISABLED: Allow navigation even if neighbors not preloaded // DISABLED: Allow navigation even if neighbors not preloaded
const isForwardNavDisabled = false && !areNeighborBackgroundsReady; const isForwardNavDisabled = false && !areNeighborBackgroundsReady;
@ -769,6 +780,7 @@ export default function RuntimePresentation({
setIsBackgroundReady(true); setIsBackgroundReady(true);
pageSwitch.markBackgroundReady(); pageSwitch.markBackgroundReady();
}} }}
onVideoBufferStateChange={setIsBackgroundVideoBuffering}
videoAutoplay={videoAutoplay} videoAutoplay={videoAutoplay}
videoLoop={videoLoop} videoLoop={videoLoop}
videoMuted={soundControl.isMuted} videoMuted={soundControl.isMuted}
@ -779,6 +791,16 @@ export default function RuntimePresentation({
</div> </div>
{/* End page background wrapper */} {/* End page background wrapper */}
{/* Page loading spinner - shows during Phase 1 (current page loading) */}
{isPageLoading && (
<CanvasLoadingSpinner
isVisible={true}
message='Loading page...'
progress={preloadOrchestrator?.phaseProgress}
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. */}
@ -831,6 +853,8 @@ export default function RuntimePresentation({
transitionPhase === 'preparing' || transitionPhase === 'preparing' ||
isBuffering isBuffering
} }
showSpinner={true}
spinnerMessage='Preparing transition...'
letterboxStyles={letterboxStyles} letterboxStyles={letterboxStyles}
opacity={1} opacity={1}
/> />

View File

@ -26,6 +26,7 @@ export const OFFLINE_CONFIG = {
projectDownloadComplete: 'project-download-complete', projectDownloadComplete: 'project-download-complete',
queueUpdate: 'queue-update', queueUpdate: 'queue-update',
blobUrlReady: 'blob-url-ready', blobUrlReady: 'blob-url-ready',
streamingReady: 'streaming-ready',
}, },
// Service worker settings // Service worker settings

View File

@ -66,6 +66,25 @@ export const PRELOAD_CONFIG = {
transitionMaxBytes: 3 * 1024 * 1024, // 3MB (~3 seconds of transition video) transitionMaxBytes: 3 * 1024 * 1024, // 3MB (~3 seconds of transition video)
}, },
// 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: {
enabled: true, // Enable streaming for faster first playback
minBufferBytes: 3 * 1024 * 1024, // 3MB minimum before signaling ready
videoBufferTarget: 5, // seconds of video to buffer before playback
audioBufferTarget: 3, // seconds of audio to buffer before playback
mobile: {
minBufferBytes: 2 * 1024 * 1024, // 2MB on mobile (lower memory)
},
},
// UI feedback settings
ui: {
spinnerDelayMs: 500, // Delay before showing loading spinner (avoids flash)
},
// Asset URL field names in element content_json (camelCase) // Asset URL field names in element content_json (camelCase)
assetFields: { assetFields: {
// All asset URL fields for preloading extraction // All asset URL fields for preloading extraction

View File

@ -48,6 +48,14 @@ interface PreloadQueueItem {
pageId: string; pageId: string;
} }
/** Preload phase for UI feedback */
export type PreloadPhase =
| 'idle'
| 'phase1_current_page'
| 'phase2_transitions'
| 'phase3_neighbors'
| 'complete';
interface UsePreloadOrchestratorResult { interface UsePreloadOrchestratorResult {
isPreloading: boolean; isPreloading: boolean;
preloadedUrls: Set<string>; preloadedUrls: Set<string>;
@ -64,6 +72,14 @@ interface UsePreloadOrchestratorResult {
getReadyBlob: (url: string) => Blob | null; getReadyBlob: (url: string) => Blob | null;
/** Whether all neighbor page backgrounds are ready for instant navigation */ /** Whether all neighbor page backgrounds are ready for instant navigation */
areNeighborBackgroundsReady: boolean; areNeighborBackgroundsReady: boolean;
/** Current preload phase (for UI feedback) */
currentPhase: PreloadPhase;
/** Progress within current phase (0-100) */
phaseProgress: number;
/** Whether current page is ready to display */
isCurrentPageReady: boolean;
/** Whether outgoing transitions are ready */
areTransitionsReady: boolean;
} }
/** /**
@ -100,6 +116,10 @@ export function usePreloadOrchestrator(
// Version counter to trigger re-renders when blob URLs become ready // 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 [phaseProgress, setPhaseProgress] = useState(0);
const queueRef = useRef<PreloadQueueItem[]>([]); const queueRef = useRef<PreloadQueueItem[]>([]);
const isProcessingRef = useRef(false); const isProcessingRef = useRef(false);
const lastPreloadedPageRef = useRef<string | null>(null); const lastPreloadedPageRef = useRef<string | null>(null);
@ -553,6 +573,15 @@ export function usePreloadOrchestrator(
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 =
PRELOAD_CONFIG.streaming.enabled &&
(assetType === 'video' ||
assetType === 'audio' ||
assetType === 'transition');
// DownloadManager always creates blob URLs for reliable playback // DownloadManager always creates blob URLs for reliable playback
return downloadManager return downloadManager
.addJob({ .addJob({
@ -565,6 +594,12 @@ export function usePreloadOrchestrator(
priority, priority,
storageKey: normalizedKey, storageKey: normalizedKey,
persist: false, persist: false,
streamingMode: enableStreaming
? {
enabled: true,
minBufferBytes: PRELOAD_CONFIG.streaming.minBufferBytes,
}
: undefined,
}) })
.then(() => { .then(() => {
if (isPresignedUrl(resolvedUrl)) { if (isPresignedUrl(resolvedUrl)) {
@ -579,72 +614,159 @@ export function usePreloadOrchestrator(
}); });
}; };
// ============================================ // ═══════════════════════════════════════════════════════════════════
// PHASE 1: Load current page IMAGE backgrounds only and WAIT // PHASE 1: Current Page Assets (BLOCKING)
// Video/audio backgrounds stream on their own - don't block on them // Priority: Images first (small, essential), then videos (can stream)
// ============================================ // ═══════════════════════════════════════════════════════════════════
setCurrentPhase('phase1_current_page');
setPhaseProgress(0);
logger.info('[PRELOAD] Phase 1: Loading current page backgrounds'); logger.info('[PRELOAD] Phase 1: Loading current page backgrounds');
const currentPageImageJobs: Promise<void>[] = []; const phase1Jobs: Promise<void>[] = [];
let phase1Total = 0;
let phase1Completed = 0;
// Current page IMAGE background - WAIT for this (essential for visual) // Current page IMAGE background - CRITICAL (wait for this)
if (currentPage?.background_image_url) { if (currentPage?.background_image_url) {
phase1Total++;
const job = createDownloadJob( const job = createDownloadJob(
`bg-img-${currentPageId}`, `bg-img-${currentPageId}`,
currentPage.background_image_url, currentPage.background_image_url,
PRELOAD_CONFIG.priority.currentPage + 200, PRELOAD_CONFIG.priority.currentPage + 200,
'image', 'image',
); );
if (job) currentPageImageJobs.push(job); if (job) {
phase1Jobs.push(
job.then(() => {
phase1Completed++;
setPhaseProgress(
phase1Total > 0 ? (phase1Completed / phase1Total) * 100 : 100,
);
}),
);
}
} }
// Current page VIDEO/AUDIO backgrounds - DON'T wait (they can stream) // Current page VIDEO background - start download (can stream)
// These are started but not awaited - video player buffers on its own
if (currentPage?.background_video_url) { if (currentPage?.background_video_url) {
createDownloadJob( phase1Total++;
const job = 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',
); );
// Not pushed to awaited jobs - video streams on its own 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) {
createDownloadJob( phase1Total++;
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',
); );
// Not pushed to awaited jobs - audio streams on its own if (job) {
phase1Jobs.push(
job.then(() => {
phase1Completed++;
setPhaseProgress(
phase1Total > 0 ? (phase1Completed / phase1Total) * 100 : 100,
);
}),
);
}
} }
// Wait ONLY for IMAGE backgrounds (they're small and essential) // Wait for Phase 1 to complete
// Video/audio can stream - don't block the page
const phase1Start = Date.now(); const phase1Start = Date.now();
if (currentPageImageJobs.length > 0) { if (phase1Jobs.length > 0) {
logger.info('[PRELOAD] Waiting for current page image backgrounds', { logger.info('[PRELOAD] Waiting for current page assets', {
count: currentPageImageJobs.length, count: phase1Jobs.length,
}); });
await Promise.all(currentPageImageJobs); await Promise.all(phase1Jobs);
logger.info('[PRELOAD] Phase 1 complete', { logger.info('[PRELOAD] Phase 1 complete', {
elapsed: `${Date.now() - phase1Start}ms`, elapsed: `${Date.now() - phase1Start}ms`,
}); });
} else { } else {
logger.info('[PRELOAD] Phase 1 complete (no image backgrounds)'); logger.info('[PRELOAD] Phase 1 complete (no backgrounds)');
} }
// ============================================ // ═══════════════════════════════════════════════════════════════════
// PHASE 2: Preload everything else (don't wait) // PHASE 2: Outgoing Transition Videos (BLOCKING)
// - Current page element assets // Load transitions FROM current page BEFORE neighbors
// - Neighbor page backgrounds // ═══════════════════════════════════════════════════════════════════
// - Neighbor page element assets setCurrentPhase('phase2_transitions');
// - Transition videos from page links setPhaseProgress(0);
// All assets use full download with blob URLs for mobile compatibility logger.info('[PRELOAD] Phase 2: Loading outgoing transitions');
// ============================================
logger.info('[PRELOAD] Phase 2: Preloading neighbors and transitions');
// Current page element assets (moved from Phase 1 for faster startup) const phase2Jobs: Promise<void>[] = [];
let phase2Total = 0;
let phase2Completed = 0;
// Find all transition videos from current page
const outgoingTransitions = pageLinks.filter(
(link) =>
link.from_pageId === currentPageId && link.transition?.video_url,
);
outgoingTransitions.forEach((link) => {
const transitionVideoUrl = link.transition?.video_url;
if (!transitionVideoUrl) return;
phase2Total++;
const job = createDownloadJob(
`trans-${link.from_pageId}-${link.to_pageId}`,
transitionVideoUrl,
PRELOAD_CONFIG.priority.currentPage +
PRELOAD_CONFIG.priority.assetType.transition,
'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) {
logger.info('[PRELOAD] Waiting for transition videos', {
count: phase2Jobs.length,
});
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( const currentPageAssets = assets.filter(
(asset) => asset.pageId === currentPageId, (asset) => asset.pageId === currentPageId,
); );
@ -700,7 +822,9 @@ export function usePreloadOrchestrator(
} }
}); });
logger.info('[PRELOAD] Phase 2: Neighbor assets queued'); logger.info('[PRELOAD] Phase 3: Neighbor assets queued');
setCurrentPhase('complete');
setPhaseProgress(100);
}; };
// If there are storage paths to presign, fetch them first // If there are storage paths to presign, fetch them first
@ -740,6 +864,15 @@ export function usePreloadOrchestrator(
maxNeighborDepth, maxNeighborDepth,
]); ]);
// Compute derived state values for UI feedback
const isCurrentPageReady =
currentPhase === 'phase2_transitions' ||
currentPhase === 'phase3_neighbors' ||
currentPhase === 'complete';
const areTransitionsReady =
currentPhase === 'phase3_neighbors' || currentPhase === 'complete';
return { return {
isPreloading, isPreloading,
preloadedUrls, preloadedUrls,
@ -752,5 +885,9 @@ export function usePreloadOrchestrator(
getReadyBlobUrl, getReadyBlobUrl,
getReadyBlob, getReadyBlob,
areNeighborBackgroundsReady, areNeighborBackgroundsReady,
currentPhase,
phaseProgress,
isCurrentPageReady,
areTransitionsReady,
}; };
} }

View File

@ -26,6 +26,7 @@ import {
import { downloadManager } from '../lib/offline/DownloadManager'; import { downloadManager } from '../lib/offline/DownloadManager';
import { isSafari, isFirefox, scheduleAfterPaint } from '../lib/browserUtils'; import { isSafari, isFirefox, scheduleAfterPaint } from '../lib/browserUtils';
import { TRANSITION_CONFIG } from '../config/transition.config'; import { TRANSITION_CONFIG } from '../config/transition.config';
import { PRELOAD_CONFIG } from '../config/preload.config';
export type ReverseMode = 'none' | 'separate'; export type ReverseMode = 'none' | 'separate';
@ -498,21 +499,50 @@ export function useTransitionPlayback(
return cachedBlobUrl; return cachedBlobUrl;
} }
} catch (cacheError) { } catch (cacheError) {
logger.warn('Cache lookup failed, falling back to fetch', { logger.warn('Cache lookup failed, falling back to streaming', {
cacheError, cacheError,
}); });
} }
} }
// 6. Fetch video as blob // 6. Use presigned URL for streaming (browser handles buffering)
logger.info('Fetching video as blob', { // Start background download for caching
isBack: currentTransition.isBack,
});
const freshUrl = currentStorageKey const freshUrl = currentStorageKey
? resolveAssetPlaybackUrl(currentStorageKey) ? resolveAssetPlaybackUrl(currentStorageKey)
: sourceUrl; : sourceUrl;
// If streaming is enabled, use presigned URL directly and start background download
if (currentStorageKey && PRELOAD_CONFIG.streaming.enabled) {
logger.info('Using streaming URL, starting background download', {
url: freshUrl.slice(0, 60),
});
// Start background download for caching (non-blocking)
downloadManager
.addJob({
assetId: `stream-${currentStorageKey}`,
projectId: '',
url: freshUrl,
filename: 'video.mp4',
variantType: 'original',
assetType: 'transition',
storageKey: currentStorageKey,
persist: false,
streamingMode: { enabled: true },
})
.catch(() => {
/* ignore background download errors */
});
// Return presigned URL for immediate playback
return freshUrl;
}
// Legacy: Fetch video as blob (when streaming disabled)
logger.info('Fetching video as blob', {
isBack: currentTransition.isBack,
});
const token = const token =
typeof window !== 'undefined' typeof window !== 'undefined'
? localStorage.getItem('token') || '' ? localStorage.getItem('token') || ''

View File

@ -15,6 +15,7 @@ import type {
ProjectDownloadProgressEvent, ProjectDownloadProgressEvent,
ProjectDownloadCompleteEvent, ProjectDownloadCompleteEvent,
BlobUrlReadyEvent, BlobUrlReadyEvent,
StreamingReadyEvent,
} from '../../types/offline'; } from '../../types/offline';
type EventMap = { type EventMap = {
@ -26,6 +27,7 @@ type EventMap = {
[OFFLINE_CONFIG.events.projectDownloadComplete]: ProjectDownloadCompleteEvent; [OFFLINE_CONFIG.events.projectDownloadComplete]: ProjectDownloadCompleteEvent;
[OFFLINE_CONFIG.events.queueUpdate]: void; [OFFLINE_CONFIG.events.queueUpdate]: void;
[OFFLINE_CONFIG.events.blobUrlReady]: BlobUrlReadyEvent; [OFFLINE_CONFIG.events.blobUrlReady]: BlobUrlReadyEvent;
[OFFLINE_CONFIG.events.streamingReady]: StreamingReadyEvent;
}; };
type EventCallback<T> = (data: T) => void; type EventCallback<T> = (data: T) => void;
@ -182,6 +184,13 @@ class DownloadEventBusClass {
emitBlobUrlReady(data: BlobUrlReadyEvent): void { emitBlobUrlReady(data: BlobUrlReadyEvent): void {
this.emit(OFFLINE_CONFIG.events.blobUrlReady as keyof EventMap, data); this.emit(OFFLINE_CONFIG.events.blobUrlReady as keyof EventMap, data);
} }
/**
* Emit streaming ready event (minimum buffer downloaded for playback)
*/
emitStreamingReady(data: StreamingReadyEvent): void {
this.emit(OFFLINE_CONFIG.events.streamingReady as keyof EventMap, data);
}
} }
// Singleton instance // Singleton instance

View File

@ -45,6 +45,14 @@ interface DownloadJob {
abortController?: AbortController; abortController?: AbortController;
resolve?: () => void; resolve?: () => void;
reject?: (error: Error) => void; reject?: (error: Error) => void;
/** Streaming mode state */
streamingMode?: {
enabled: boolean;
minBufferBytes: number;
streamingUrl: string;
didSignalReady: boolean;
};
} }
class DownloadManagerClass { class DownloadManagerClass {
@ -82,6 +90,11 @@ class DownloadManagerClass {
priority?: number; priority?: number;
storageKey?: string; // Optional, will extract if not provided storageKey?: string; // Optional, will extract if not provided
persist?: boolean; // Persist to IndexedDB for resume (default: true) persist?: boolean; // Persist to IndexedDB for resume (default: true)
/** Enable streaming mode - signal ready after minimum buffer downloaded */
streamingMode?: {
enabled: boolean;
minBufferBytes?: number;
};
}): Promise<void> { }): Promise<void> {
const storageKey = params.storageKey || extractStoragePath(params.url); const storageKey = params.storageKey || extractStoragePath(params.url);
@ -128,6 +141,17 @@ class DownloadManagerClass {
persist: params.persist ?? true, persist: params.persist ?? true,
resolve, resolve,
reject, reject,
// Initialize streaming mode if enabled
streamingMode: params.streamingMode?.enabled
? {
enabled: true,
minBufferBytes:
params.streamingMode.minBufferBytes ??
this.getMinBufferBytes(),
streamingUrl: params.url,
didSignalReady: false,
}
: undefined,
}; };
// Persist to IndexedDB for resume capability (default true) // Persist to IndexedDB for resume capability (default true)
@ -260,6 +284,30 @@ class DownloadManagerClass {
totalBytes: job.totalBytes, totalBytes: job.totalBytes,
}); });
// Check if streaming mode and minimum buffer reached
if (
job.streamingMode?.enabled &&
!job.streamingMode.didSignalReady &&
job.bytesLoaded >= job.streamingMode.minBufferBytes
) {
job.streamingMode.didSignalReady = true;
logger.info('[DownloadManager] Streaming ready', {
storageKey: job.storageKey.slice(-50),
bytesLoaded: job.bytesLoaded,
minBuffer: job.streamingMode.minBufferBytes,
});
// Emit streaming ready event
downloadEventBus.emitStreamingReady({
jobId: job.id,
storageKey: job.storageKey,
streamingUrl: job.streamingMode.streamingUrl,
bytesLoaded: job.bytesLoaded,
totalBytes: job.totalBytes,
});
}
// Only update queue progress if persisting // Only update queue progress if persisting
if (job.persist !== false) { if (job.persist !== false) {
await OfflineDbManager.updateQueueProgress( await OfflineDbManager.updateQueueProgress(
@ -707,6 +755,44 @@ class DownloadManagerClass {
} }
}); });
} }
/**
* Check if device is mobile (for streaming buffer size)
*/
private isMobile(): boolean {
if (typeof navigator === 'undefined') return false;
return /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
}
/**
* Get minimum buffer bytes based on device type
*/
private getMinBufferBytes(): number {
if (this.isMobile()) {
return (
PRELOAD_CONFIG.streaming.mobile?.minBufferBytes || 2 * 1024 * 1024
);
}
return PRELOAD_CONFIG.streaming.minBufferBytes;
}
/**
* Check if asset is cached or get streaming URL for first playback.
* Returns cached blob URL for instant O(1) playback, or presigned URL for streaming.
*/
getStreamingUrlIfNeeded(
storageKey: string,
presignedUrl: string,
): { url: string; isFromCache: boolean } {
// Fully cached - use blob URL
const cachedUrl = this.readyBlobUrls.get(storageKey);
if (cachedUrl) {
return { url: cachedUrl, isFromCache: true };
}
// Not cached - use presigned URL for streaming
return { url: presignedUrl, isFromCache: false };
}
} }
// Singleton instance // Singleton instance

View File

@ -13,6 +13,7 @@ import React, {
import { flushSync } from 'react-dom'; import { flushSync } from 'react-dom';
import BaseButton from '../components/BaseButton'; import BaseButton from '../components/BaseButton';
import CanvasBackground from '../components/Constructor/CanvasBackground'; import CanvasBackground from '../components/Constructor/CanvasBackground';
import CanvasLoadingSpinner from '../components/CanvasLoadingSpinner';
import TransitionBlackOverlay from '../components/TransitionBlackOverlay'; import TransitionBlackOverlay from '../components/TransitionBlackOverlay';
import ConstructorToolbar from '../components/Constructor/ConstructorToolbar'; import ConstructorToolbar from '../components/Constructor/ConstructorToolbar';
import TransitionPreviewOverlay from '../components/Constructor/TransitionPreviewOverlay'; import TransitionPreviewOverlay from '../components/Constructor/TransitionPreviewOverlay';
@ -291,6 +292,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
const [isBackgroundReady, setIsBackgroundReady] = useState(true); const [isBackgroundReady, setIsBackgroundReady] = useState(true);
const [pendingTransitionComplete, setPendingTransitionComplete] = const [pendingTransitionComplete, setPendingTransitionComplete] =
useState(false); 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,
@ -436,6 +440,10 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
// maxNeighborDepth defaults to 1 - only preload immediate neighbors // 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) // Page switch hook for smooth background transitions (uses blob URLs from preload cache)
const pageSwitch = usePageSwitch({ const pageSwitch = usePageSwitch({
preloadCache: preloadOrchestrator preloadCache: preloadOrchestrator
@ -1700,6 +1708,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
pageSwitch.markBackgroundReady(); pageSwitch.markBackgroundReady();
setIsBackgroundReady(true); setIsBackgroundReady(true);
}} }}
onVideoBufferStateChange={setIsBackgroundVideoBuffering}
videoAutoplay={backgroundVideoAutoplay} videoAutoplay={backgroundVideoAutoplay}
videoLoop={backgroundVideoLoop} videoLoop={backgroundVideoLoop}
videoMuted={soundControl.isMuted} videoMuted={soundControl.isMuted}
@ -1711,6 +1720,16 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
/> />
</div> </div>
{/* Page loading spinner - shows during Phase 1 (current page loading) */}
{isPagePreloading && (
<CanvasLoadingSpinner
isVisible={true}
message='Loading page...'
progress={preloadOrchestrator?.phaseProgress}
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. */}
@ -1817,6 +1836,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
videoRef={transitionVideoRef} videoRef={transitionVideoRef}
isActive={Boolean(transitionPreview)} isActive={Boolean(transitionPreview)}
isBuffering={isReverseBuffering} isBuffering={isReverseBuffering}
showSpinner={true}
spinnerMessage='Preparing transition...'
letterboxStyles={letterboxStyles} letterboxStyles={letterboxStyles}
/> />

View File

@ -188,3 +188,12 @@ export interface BlobUrlReadyEvent {
storageKey: string; storageKey: string;
blobUrl: string; blobUrl: string;
} }
// Streaming ready event - emitted when minimum buffer is downloaded for streaming playback
export interface StreamingReadyEvent {
jobId: string;
storageKey: string;
streamingUrl: string;
bytesLoaded: number;
totalBytes: number;
}