improved preloading functionality
This commit is contained in:
parent
f06a2b2c97
commit
ba813d2602
91
frontend/src/components/CanvasLoadingSpinner.tsx
Normal file
91
frontend/src/components/CanvasLoadingSpinner.tsx
Normal 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;
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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') || ''
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user