diff --git a/frontend/src/components/CanvasLoadingSpinner.tsx b/frontend/src/components/CanvasLoadingSpinner.tsx new file mode 100644 index 0000000..c6c8785 --- /dev/null +++ b/frontend/src/components/CanvasLoadingSpinner.tsx @@ -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 = ({ + 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 ( +
+
+ {/* Spinner ring */} +
+ {/* Progress indicator (optional) */} + {progress !== undefined && ( +
+ + {Math.round(progress)}% + +
+ )} +
+ {message && ( +

{message}

+ )} +
+ ); +}; + +export default CanvasLoadingSpinner; diff --git a/frontend/src/components/Constructor/CanvasBackground.tsx b/frontend/src/components/Constructor/CanvasBackground.tsx index 7f65680..f9cdfba 100644 --- a/frontend/src/components/Constructor/CanvasBackground.tsx +++ b/frontend/src/components/Constructor/CanvasBackground.tsx @@ -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 = ({ isSwitching = false, isNewBgReady = false, onBackgroundReady, + onVideoBufferStateChange, videoAutoplay = true, videoLoop = true, videoMuted = true, @@ -84,6 +88,40 @@ const CanvasBackground: React.FC = ({ // 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 = ({ 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.startsWith('blob:') ? ( // eslint-disable-next-line @next/next/no-img-element = ({ 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 && (
+ {/* Page loading spinner - shows during Phase 1 (current page loading) */} + {isPagePreloading && ( + + )} + {/* 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} /> diff --git a/frontend/src/types/offline.ts b/frontend/src/types/offline.ts index 372c50a..9cf3f2d 100644 --- a/frontend/src/types/offline.ts +++ b/frontend/src/types/offline.ts @@ -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; +}