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 { useBackgroundVideoPlayback } from '../../hooks/useBackgroundVideoPlayback';
import PreviousBackgroundOverlay from '../PreviousBackgroundOverlay';
import CanvasLoadingSpinner from '../CanvasLoadingSpinner';
import { scheduleAfterPaint } from '../../lib/browserUtils';
import { baseURLApi } from '../../config';
@ -40,6 +41,8 @@ interface CanvasBackgroundProps {
isSwitching?: boolean;
isNewBgReady?: boolean;
onBackgroundReady?: () => void;
/** Callback when video buffer state changes (true = buffering, false = ready) */
onVideoBufferStateChange?: (isBuffering: boolean) => void;
// Video playback settings
videoAutoplay?: boolean;
videoLoop?: boolean;
@ -59,6 +62,7 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
isSwitching = false,
isNewBgReady = false,
onBackgroundReady,
onVideoBufferStateChange,
videoAutoplay = true,
videoLoop = true,
videoMuted = true,
@ -84,6 +88,40 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
// Video error state for fallback to proxy URL
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)
const videoSrc = useMemo(() => {
if (!backgroundVideoUrl) return undefined;
@ -180,9 +218,18 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
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 && (
<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:') ? (
// eslint-disable-next-line @next/next/no-img-element
<img
@ -225,12 +272,18 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
Actual muted state is controlled via useBackgroundVideoPlayback hook
which sets video.muted property via JavaScript (useEffect).
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 && (
<video
ref={videoRef}
key={`bg_video_${backgroundVideoUrl}`}
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}
preload='metadata'
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 */}
{backgroundAudioUrl && (
<audio

View File

@ -10,6 +10,7 @@
*/
import React from 'react';
import CanvasLoadingSpinner from '../CanvasLoadingSpinner';
interface TransitionPreviewOverlayProps {
/** Reference to the video element - useTransitionPlayback manages src and playback */
@ -18,6 +19,10 @@ interface TransitionPreviewOverlayProps {
isActive: boolean;
/** Whether the video is currently buffering (used to hide video during load) */
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 */
letterboxStyles?: React.CSSProperties;
/** Video object-fit mode (default: 'contain' to match backgrounds) */
@ -32,6 +37,8 @@ const TransitionPreviewOverlay: React.FC<TransitionPreviewOverlayProps> = ({
videoRef,
isActive,
isBuffering = false,
showSpinner = false,
spinnerMessage = 'Preparing transition...',
letterboxStyles,
videoFit = 'contain',
opacity,
@ -47,28 +54,43 @@ const TransitionPreviewOverlay: React.FC<TransitionPreviewOverlayProps> = ({
// Outer: full viewport, transparent background
// Transparent ensures if Safari clears video frame when paused,
// the new page background shows through instead of black flash
<div
className='fixed inset-0 z-50 overflow-hidden pointer-events-none'
style={{ opacity: containerOpacity }}
>
{/* 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 className='fixed inset-0 z-50 overflow-hidden pointer-events-none'>
{/* Loading spinner during buffering - provides user feedback */}
{isBuffering && showSpinner && (
<CanvasLoadingSpinner
isVisible={true}
message={spinnerMessage}
size='lg'
zIndex={60}
/>
)}
{/* 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>
);

View File

@ -25,6 +25,7 @@ import GalleryCarouselOverlay from './UiElements/GalleryCarouselOverlay';
import { BackdropPortalProvider } from './BackdropPortal';
import { RotatePrompt } from './RotatePrompt';
import CanvasBackground from './Constructor/CanvasBackground';
import CanvasLoadingSpinner from './CanvasLoadingSpinner';
import TransitionBlackOverlay from './TransitionBlackOverlay';
import { useCanvasScale } from '../hooks/useCanvasScale';
import { CANVAS_CONFIG } from '../config/canvas.config';
@ -185,6 +186,10 @@ export default function RuntimePresentation({
// 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 lastInitializedPageIdRef = useRef<string | null>(null);
@ -493,6 +498,12 @@ export default function RuntimePresentation({
const areNeighborBackgroundsReady =
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
// DISABLED: Allow navigation even if neighbors not preloaded
const isForwardNavDisabled = false && !areNeighborBackgroundsReady;
@ -769,6 +780,7 @@ export default function RuntimePresentation({
setIsBackgroundReady(true);
pageSwitch.markBackgroundReady();
}}
onVideoBufferStateChange={setIsBackgroundVideoBuffering}
videoAutoplay={videoAutoplay}
videoLoop={videoLoop}
videoMuted={soundControl.isMuted}
@ -779,6 +791,16 @@ export default function RuntimePresentation({
</div>
{/* 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).
UI controls (z-50) remain on top.
No fade animation - elements switch instantly behind the black overlay. */}
@ -831,6 +853,8 @@ export default function RuntimePresentation({
transitionPhase === 'preparing' ||
isBuffering
}
showSpinner={true}
spinnerMessage='Preparing transition...'
letterboxStyles={letterboxStyles}
opacity={1}
/>

View File

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

View File

@ -66,6 +66,25 @@ export const PRELOAD_CONFIG = {
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)
assetFields: {
// All asset URL fields for preloading extraction

View File

@ -48,6 +48,14 @@ interface PreloadQueueItem {
pageId: string;
}
/** Preload phase for UI feedback */
export type PreloadPhase =
| 'idle'
| 'phase1_current_page'
| 'phase2_transitions'
| 'phase3_neighbors'
| 'complete';
interface UsePreloadOrchestratorResult {
isPreloading: boolean;
preloadedUrls: Set<string>;
@ -64,6 +72,14 @@ interface UsePreloadOrchestratorResult {
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;
/** 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
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 isProcessingRef = useRef(false);
const lastPreloadedPageRef = useRef<string | null>(null);
@ -553,6 +573,15 @@ export function usePreloadOrchestrator(
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
return downloadManager
.addJob({
@ -565,6 +594,12 @@ export function usePreloadOrchestrator(
priority,
storageKey: normalizedKey,
persist: false,
streamingMode: enableStreaming
? {
enabled: true,
minBufferBytes: PRELOAD_CONFIG.streaming.minBufferBytes,
}
: undefined,
})
.then(() => {
if (isPresignedUrl(resolvedUrl)) {
@ -579,72 +614,159 @@ export function usePreloadOrchestrator(
});
};
// ============================================
// PHASE 1: Load current page IMAGE backgrounds only and WAIT
// Video/audio backgrounds stream on their own - don't block on them
// ============================================
// ═══════════════════════════════════════════════════════════════════
// PHASE 1: Current Page Assets (BLOCKING)
// Priority: Images first (small, essential), then videos (can stream)
// ═══════════════════════════════════════════════════════════════════
setCurrentPhase('phase1_current_page');
setPhaseProgress(0);
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) {
phase1Total++;
const job = createDownloadJob(
`bg-img-${currentPageId}`,
currentPage.background_image_url,
PRELOAD_CONFIG.priority.currentPage + 200,
'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)
// These are started but not awaited - video player buffers on its own
// Current page VIDEO background - start download (can stream)
if (currentPage?.background_video_url) {
createDownloadJob(
phase1Total++;
const job = createDownloadJob(
`bg-vid-${currentPageId}`,
currentPage.background_video_url,
PRELOAD_CONFIG.priority.currentPage + 150,
'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) {
createDownloadJob(
phase1Total++;
const job = createDownloadJob(
`bg-aud-${currentPageId}`,
currentPage.background_audio_url,
PRELOAD_CONFIG.priority.currentPage + 100,
'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)
// Video/audio can stream - don't block the page
// Wait for Phase 1 to complete
const phase1Start = Date.now();
if (currentPageImageJobs.length > 0) {
logger.info('[PRELOAD] Waiting for current page image backgrounds', {
count: currentPageImageJobs.length,
if (phase1Jobs.length > 0) {
logger.info('[PRELOAD] Waiting for current page assets', {
count: phase1Jobs.length,
});
await Promise.all(currentPageImageJobs);
await Promise.all(phase1Jobs);
logger.info('[PRELOAD] Phase 1 complete', {
elapsed: `${Date.now() - phase1Start}ms`,
});
} 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)
// - Current page element assets
// - Neighbor page backgrounds
// - Neighbor page element assets
// - Transition videos from page links
// All assets use full download with blob URLs for mobile compatibility
// ============================================
logger.info('[PRELOAD] Phase 2: Preloading neighbors and transitions');
// ═══════════════════════════════════════════════════════════════════
// PHASE 2: Outgoing Transition Videos (BLOCKING)
// Load transitions FROM current page BEFORE neighbors
// ═══════════════════════════════════════════════════════════════════
setCurrentPhase('phase2_transitions');
setPhaseProgress(0);
logger.info('[PRELOAD] Phase 2: Loading outgoing 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(
(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
@ -740,6 +864,15 @@ export function usePreloadOrchestrator(
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 {
isPreloading,
preloadedUrls,
@ -752,5 +885,9 @@ export function usePreloadOrchestrator(
getReadyBlobUrl,
getReadyBlob,
areNeighborBackgroundsReady,
currentPhase,
phaseProgress,
isCurrentPageReady,
areTransitionsReady,
};
}

View File

@ -26,6 +26,7 @@ import {
import { downloadManager } from '../lib/offline/DownloadManager';
import { isSafari, isFirefox, scheduleAfterPaint } from '../lib/browserUtils';
import { TRANSITION_CONFIG } from '../config/transition.config';
import { PRELOAD_CONFIG } from '../config/preload.config';
export type ReverseMode = 'none' | 'separate';
@ -498,21 +499,50 @@ export function useTransitionPlayback(
return cachedBlobUrl;
}
} catch (cacheError) {
logger.warn('Cache lookup failed, falling back to fetch', {
logger.warn('Cache lookup failed, falling back to streaming', {
cacheError,
});
}
}
// 6. Fetch video as blob
logger.info('Fetching video as blob', {
isBack: currentTransition.isBack,
});
// 6. Use presigned URL for streaming (browser handles buffering)
// Start background download for caching
const freshUrl = currentStorageKey
? resolveAssetPlaybackUrl(currentStorageKey)
: 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 =
typeof window !== 'undefined'
? localStorage.getItem('token') || ''

View File

@ -15,6 +15,7 @@ import type {
ProjectDownloadProgressEvent,
ProjectDownloadCompleteEvent,
BlobUrlReadyEvent,
StreamingReadyEvent,
} from '../../types/offline';
type EventMap = {
@ -26,6 +27,7 @@ type EventMap = {
[OFFLINE_CONFIG.events.projectDownloadComplete]: ProjectDownloadCompleteEvent;
[OFFLINE_CONFIG.events.queueUpdate]: void;
[OFFLINE_CONFIG.events.blobUrlReady]: BlobUrlReadyEvent;
[OFFLINE_CONFIG.events.streamingReady]: StreamingReadyEvent;
};
type EventCallback<T> = (data: T) => void;
@ -182,6 +184,13 @@ class DownloadEventBusClass {
emitBlobUrlReady(data: BlobUrlReadyEvent): void {
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

View File

@ -45,6 +45,14 @@ interface DownloadJob {
abortController?: AbortController;
resolve?: () => void;
reject?: (error: Error) => void;
/** Streaming mode state */
streamingMode?: {
enabled: boolean;
minBufferBytes: number;
streamingUrl: string;
didSignalReady: boolean;
};
}
class DownloadManagerClass {
@ -82,6 +90,11 @@ class DownloadManagerClass {
priority?: number;
storageKey?: string; // Optional, will extract if not provided
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> {
const storageKey = params.storageKey || extractStoragePath(params.url);
@ -128,6 +141,17 @@ class DownloadManagerClass {
persist: params.persist ?? true,
resolve,
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)
@ -260,6 +284,30 @@ class DownloadManagerClass {
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
if (job.persist !== false) {
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

View File

@ -13,6 +13,7 @@ import React, {
import { flushSync } from 'react-dom';
import BaseButton from '../components/BaseButton';
import CanvasBackground from '../components/Constructor/CanvasBackground';
import CanvasLoadingSpinner from '../components/CanvasLoadingSpinner';
import TransitionBlackOverlay from '../components/TransitionBlackOverlay';
import ConstructorToolbar from '../components/Constructor/ConstructorToolbar';
import TransitionPreviewOverlay from '../components/Constructor/TransitionPreviewOverlay';
@ -291,6 +292,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
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)
const [
currentElementTransitionSettings,
@ -436,6 +440,10 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
// 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
@ -1700,6 +1708,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
pageSwitch.markBackgroundReady();
setIsBackgroundReady(true);
}}
onVideoBufferStateChange={setIsBackgroundVideoBuffering}
videoAutoplay={backgroundVideoAutoplay}
videoLoop={backgroundVideoLoop}
videoMuted={soundControl.isMuted}
@ -1711,6 +1720,16 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
/>
</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).
UI controls (z-50) remain on top.
No fade animation - elements switch instantly behind the black overlay. */}
@ -1817,6 +1836,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
videoRef={transitionVideoRef}
isActive={Boolean(transitionPreview)}
isBuffering={isReverseBuffering}
showSpinner={true}
spinnerMessage='Preparing transition...'
letterboxStyles={letterboxStyles}
/>

View File

@ -188,3 +188,12 @@ export interface BlobUrlReadyEvent {
storageKey: 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;
}