/** * 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 } from 'react'; import NextImage from 'next/image'; import { useBackgroundVideoPlayback } from '../../hooks/useBackgroundVideoPlayback'; import PreviousBackgroundOverlay from '../PreviousBackgroundOverlay'; import { scheduleAfterPaint } from '../../lib/browserUtils'; // 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; isFadingIn?: 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, isFadingIn = 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; 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?.(); }; // Use requestVideoFrameCallback for precise frame-level timing (Safari 15.4+, Chrome 83+) const videoWithRVFC = video as HTMLVideoElementWithRVFC; if (typeof videoWithRVFC.requestVideoFrameCallback === 'function') { videoWithRVFC.requestVideoFrameCallback(() => { reportVideoReady(); }); } else { // Fallback: use playing event + scheduleAfterPaint const onPlaying = () => { scheduleAfterPaint(() => { reportVideoReady(); }); }; video.addEventListener('playing', onPlaying, { once: true }); return () => { video.removeEventListener('playing', onPlaying); }; } }, [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 overlays - show during loading AND crossfade. Uses CSS animation for fade-out effect during crossfade. z-0 keeps them BELOW new backgrounds (z-1). */} {/* Background video - z-1 keeps it below backdrop blur layer (z-5) */} {backgroundVideoUrl && (