/** * CanvasBackground Component * * Background image, video, and audio for the constructor canvas. * Handles blob URLs, Next.js Image optimization, and previous background overlay. * Supports custom video playback settings (autoplay, loop, muted, start/end time). */ import React, { useRef, useEffect, useState, useMemo, useCallback, } from 'react'; import NextImage from 'next/image'; import { useBackgroundVideoPlayback } from '../../hooks/useBackgroundVideoPlayback'; import PreviousBackgroundOverlay from '../PreviousBackgroundOverlay'; import { scheduleAfterPaint } from '../../lib/browserUtils'; import { baseURLApi } from '../../config'; // Type for requestVideoFrameCallback (Safari 15.4+, Chrome 83+) // The callback receives (now: DOMHighResTimeStamp, metadata: VideoFrameCallbackMetadata) // but we ignore them since we only need to know the frame was painted interface HTMLVideoElementWithRVFC extends HTMLVideoElement { requestVideoFrameCallback: ( callback: ( now: DOMHighResTimeStamp, metadata: VideoFrameCallbackMetadata, ) => void, ) => number; } interface CanvasBackgroundProps { backgroundImageUrl?: string; backgroundVideoUrl?: string; backgroundAudioUrl?: string; previousBgImageUrl?: string; previousBgVideoUrl?: string; isSwitching?: boolean; isNewBgReady?: boolean; onBackgroundReady?: () => void; // Video playback settings videoAutoplay?: boolean; videoLoop?: boolean; videoMuted?: boolean; videoStartTime?: number | null; videoEndTime?: number | null; /** Original storage path for video - used for play-once tracking (not the resolved blob URL) */ videoStoragePath?: string; } const CanvasBackground: React.FC = ({ backgroundImageUrl, backgroundVideoUrl, backgroundAudioUrl, previousBgImageUrl, previousBgVideoUrl, isSwitching = false, isNewBgReady = false, onBackgroundReady, videoAutoplay = true, videoLoop = true, videoMuted = true, videoStartTime = null, videoEndTime = null, videoStoragePath, }) => { // Use background video playback hook for custom start/end time handling // Use storagePath for play-once tracking (falls back to videoUrl if not provided) const { videoRef, shouldBlockAutoplay } = useBackgroundVideoPlayback({ videoUrl: backgroundVideoUrl, videoStoragePath: videoStoragePath || backgroundVideoUrl, autoplay: videoAutoplay, loop: videoLoop, muted: videoMuted, startTime: videoStartTime, endTime: videoEndTime, }); // Block autoplay if video already played this session (when loop=false) const effectiveAutoplay = videoAutoplay && !shouldBlockAutoplay; // Video error state for fallback to proxy URL const [videoError, setVideoError] = useState(false); // Fallback to proxy URL if presigned URL fails (e.g., CORS, expiration) const videoSrc = useMemo(() => { if (!backgroundVideoUrl) return undefined; if (videoError && videoStoragePath) { // Fallback to backend proxy (bypasses CORS issues) return `${baseURLApi}/file/download?privateUrl=${encodeURIComponent(videoStoragePath)}`; } return backgroundVideoUrl; }, [backgroundVideoUrl, videoStoragePath, videoError]); // Reset error state when video URL changes useEffect(() => { setVideoError(false); }, [backgroundVideoUrl]); const handleVideoError = useCallback(() => { if (!videoError && videoStoragePath) { // eslint-disable-next-line no-console console.warn('[CanvasBackground] Video error, falling back to proxy URL'); setVideoError(true); } }, [videoError, videoStoragePath]); const handleLoad = () => { // Wait for paint to ensure background is actually rendered before reporting ready. // This prevents the transition overlay from being removed before the background is visible. scheduleAfterPaint(() => { onBackgroundReady?.(); }); }; const handleError = () => { onBackgroundReady?.(); }; // Track if we've already called onBackgroundReady to avoid double calls const didReportReadyRef = useRef(false); // Reset ready flag when video URL changes useEffect(() => { didReportReadyRef.current = false; }, [backgroundVideoUrl]); // Handle video first frame ready using requestVideoFrameCallback // This ensures the video's first frame is actually painted before we report ready useEffect(() => { const video = videoRef.current; if (!backgroundVideoUrl || !video || didReportReadyRef.current) return; const reportVideoReady = () => { if (didReportReadyRef.current) return; didReportReadyRef.current = true; onBackgroundReady?.(); }; // Timeout fallback - report ready after 5 seconds even if video hasn't started // Prevents infinite loading on slow networks or video initialization failures const timeout = setTimeout(() => { // eslint-disable-next-line no-console console.warn( '[CanvasBackground] Video ready timeout, reporting ready anyway', ); reportVideoReady(); }, 5000); // Use requestVideoFrameCallback for precise frame-level timing (Safari 15.4+, Chrome 83+) const videoWithRVFC = video as HTMLVideoElementWithRVFC; if (typeof videoWithRVFC.requestVideoFrameCallback === 'function') { videoWithRVFC.requestVideoFrameCallback(() => { clearTimeout(timeout); reportVideoReady(); }); } else { // Fallback: use playing event + scheduleAfterPaint const onPlaying = () => { clearTimeout(timeout); scheduleAfterPaint(() => { reportVideoReady(); }); }; video.addEventListener('playing', onPlaying, { once: true }); return () => { clearTimeout(timeout); video.removeEventListener('playing', onPlaying); }; } return () => clearTimeout(timeout); }, [backgroundVideoUrl, onBackgroundReady]); // When endTime is set, we disable native loop and handle it via the hook const useNativeLoop = videoEndTime == null ? videoLoop : false; return ( <> {/* Background image - z-1 keeps it below backdrop blur layer (z-5) */} {backgroundImageUrl && (
{backgroundImageUrl.startsWith('blob:') ? ( // eslint-disable-next-line @next/next/no-img-element Background ) : ( )}
)} {/* Previous background overlay - shows during loading (z-2) above new background (z-1). Black overlay for fade effect is rendered separately at higher z-index (TransitionBlackOverlay). */} {/* Background video - z-1 keeps it below backdrop blur layer (z-5) Note: muted attribute is always true for iOS autoplay compatibility. 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. */} {backgroundVideoUrl && (