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 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
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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') || ''
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user