39948-vm/frontend/src/components/Constructor/CanvasBackground.tsx

208 lines
6.6 KiB
TypeScript

/**
* 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<CanvasBackgroundProps> = ({
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 && (
<div className='pointer-events-none absolute inset-0 z-1 h-full w-full select-none'>
{backgroundImageUrl.startsWith('blob:') ? (
// eslint-disable-next-line @next/next/no-img-element
<img
key={`bg_image_${backgroundImageUrl}`}
src={backgroundImageUrl}
alt='Background'
className='absolute inset-0 h-full w-full object-contain'
draggable={false}
onLoad={handleLoad}
onError={handleError}
/>
) : (
<NextImage
key={`bg_image_${backgroundImageUrl}`}
src={backgroundImageUrl}
alt='Background'
fill
sizes='100vw'
className='object-contain'
draggable={false}
unoptimized
onLoad={handleLoad}
onError={handleError}
/>
)}
</div>
)}
{/* 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). */}
<PreviousBackgroundOverlay
imageUrl={previousBgImageUrl}
videoUrl={previousBgVideoUrl}
isSwitching={isSwitching}
isNewBgReady={isNewBgReady}
isFadingIn={isFadingIn}
/>
{/* Background video - z-1 keeps it below backdrop blur layer (z-5) */}
{backgroundVideoUrl && (
<video
ref={videoRef}
key={`bg_video_${backgroundVideoUrl}`}
className='absolute inset-0 z-1 h-full w-full object-contain'
src={backgroundVideoUrl}
autoPlay={effectiveAutoplay}
loop={useNativeLoop}
muted={videoMuted}
playsInline
/>
)}
{/* Background audio */}
{backgroundAudioUrl && (
<audio
key={`bg_audio_${backgroundAudioUrl}`}
src={backgroundAudioUrl}
autoPlay
loop
hidden
/>
)}
</>
);
};
export default CanvasBackground;