/** * useBackgroundTransition Hook * * Manages background transition effects when switching between pages. * Handles crossfade animation for non-video navigation and * coordinates with the page switch hook to clear previous backgrounds. * * This hook consolidates the background transition logic used by both * RuntimePresentation and constructor.tsx. * * NOTE: Video transitions do NOT use any fades - the video itself IS the transition. * Video last frame = new page background, then overlay is removed instantly. * * Two modes: * 1. fadeIn mode: Crossfade animation for direct (non-video) page navigation * 2. fadeOut mode: Legacy support - kept for backwards compatibility but not recommended */ import { useEffect, useLayoutEffect, useState, useCallback, useRef, } from 'react'; import { isSafari, scheduleAfterPaintSafari, getCrossfadeDuration, } from '../lib/browserUtils'; /** * Fade-out configuration (optional - for RuntimePresentation) */ export interface FadeOutConfig { /** Whether a transition video has finished playing and is waiting for bg ready */ pendingTransitionComplete: boolean; /** Whether the new background image is ready to display */ isBackgroundReady: boolean; /** Ref to the transition video element for cleanup */ transitionVideoRef: React.RefObject; /** Callback to clear transition state after overlay is removed */ onTransitionCleanup: () => void; } /** * Fade-in configuration (optional - for page content fade-in) */ export interface FadeInConfig { /** Whether a transition video is currently active (disables fade-in) */ hasActiveTransition: boolean; } export interface UseBackgroundTransitionOptions { /** Page switch hook instance for clearing previous background */ pageSwitch: { clearPreviousBackground: () => void; isSwitching: boolean; isNewBgReady: boolean; previousBgImageUrl: string; previousBgVideoUrl: string; }; /** Optional fade-out configuration (for RuntimePresentation) */ fadeOut?: FadeOutConfig; /** Optional fade-in configuration for page content */ fadeIn?: FadeInConfig; } export interface UseBackgroundTransitionResult { /** Whether the overlay is currently fading out */ isOverlayFadingOut: boolean; /** Reset the fade-out state (call before starting a new transition) */ resetFadeOut: () => void; /** Whether page content is currently fading (crossfade in progress) */ isFadingIn: boolean; /** Handler to call when fade-in animation ends (pass to onAnimationEnd) */ onFadeInAnimationEnd: () => void; /** Reset fade-in state (for cleanup or cancellation) */ resetFadeIn: () => void; } /** * Hook for managing background transition effects. * * @example * // Full mode with fade-out and fade-in (RuntimePresentation) * const { isOverlayFadingOut, resetFadeOut, isFadingIn, onFadeInAnimationEnd } = useBackgroundTransition({ * pageSwitch, * fadeOut: { * pendingTransitionComplete, * isBackgroundReady, * transitionVideoRef, * onTransitionCleanup: () => { * setTransitionPreview(null); * setPendingTransitionComplete(false); * }, * }, * fadeIn: { * hasActiveTransition: Boolean(transitionPreview), * }, * }); * * // In JSX: *
* * @example * // Simple mode - direct navigation only (constructor) * useBackgroundTransition({ pageSwitch }); */ export function useBackgroundTransition({ pageSwitch, fadeOut, fadeIn, }: UseBackgroundTransitionOptions): UseBackgroundTransitionResult { const [isOverlayFadingOut, setIsOverlayFadingOut] = useState(false); const [isFadingIn, setIsFadingIn] = useState(false); // Track previous isSwitching state to detect transition start const wasSwitchingRef = useRef(false); /** * Reset fade-out state before starting a new transition. * This prevents the fade-out effect from re-triggering when state resets. */ const resetFadeOut = useCallback(() => { setIsOverlayFadingOut(false); }, []); /** * Reset fade-in state (for cleanup or cancellation). */ const resetFadeIn = useCallback(() => { setIsFadingIn(false); }, []); /** * Handler for onAnimationEnd event. * Called when CSS animation completes - CSS is the source of truth for duration. */ const onFadeInAnimationEnd = useCallback(() => { setIsFadingIn(false); }, []); /** * Effect: Fade out and remove transition overlay when background is ready. * Only runs when fadeOut config is provided. * * Sequence: * 1. Transition video finishes playing (pendingTransitionComplete = true) * 2. New background image loads (isBackgroundReady = true) * 3. Safari: Wait extra frame to ensure background is painted * 4. Start fade-out animation (isOverlayFadingOut = true) * 5. After fade completes, clean up video and clear transition state */ useEffect(() => { if (!fadeOut) return; const { pendingTransitionComplete, isBackgroundReady, transitionVideoRef, onTransitionCleanup, } = fadeOut; if (pendingTransitionComplete && isBackgroundReady && !isOverlayFadingOut) { // Safari Black Flash Prevention: // Wait an extra frame in Safari to ensure the new background is truly painted // before starting the fade-out animation. This prevents showing black between // the transition video and the new page content. const startFadeOut = () => { // Start fade-out animation setIsOverlayFadingOut(true); }; // Safari: verify paint completion with extra frame wait if (isSafari()) { scheduleAfterPaintSafari(startFadeOut); } else { startFadeOut(); } } }, [fadeOut, isOverlayFadingOut]); /** * Effect: Complete fade-out and cleanup after animation duration. * Separated from the start effect to ensure proper cleanup timing. */ useEffect(() => { if (!fadeOut || !isOverlayFadingOut) return; const { transitionVideoRef, onTransitionCleanup } = fadeOut; // After fade completes, remove the overlay // Duration is read from CSS variable for consistency const fadeTimer = setTimeout(() => { const video = transitionVideoRef.current; if (video) { video.removeAttribute('src'); video.load(); } // Clear previous background from shared hook pageSwitch.clearPreviousBackground(); // Notify caller to clear transition state onTransitionCleanup(); // Reset fade-out state setIsOverlayFadingOut(false); }, getCrossfadeDuration()); return () => clearTimeout(fadeTimer); }, [fadeOut, isOverlayFadingOut, pageSwitch]); /** * Effect: Clear previous background overlay after fade completes (direct navigation). * * The previous background stays visible during the entire fade animation, * providing a smooth crossfade effect. Only cleared after fade ends. */ useEffect(() => { if (pageSwitch.isSwitching && pageSwitch.isNewBgReady && !isFadingIn) { // Fade is complete - clear the previous background overlay // This also resets isSwitching state so next navigation triggers fade-in pageSwitch.clearPreviousBackground(); } }, [ pageSwitch.isSwitching, pageSwitch.isNewBgReady, pageSwitch.clearPreviousBackground, isFadingIn, ]); /** * Layout effect: Set up crossfade BEFORE browser paints when switching starts. * useLayoutEffect runs synchronously after DOM mutations but before paint, * preventing any flash of new content at full opacity. * * IMPORTANT: Skip this for transitions - transition video IS the effect. */ useLayoutEffect(() => { if (!fadeIn) { wasSwitchingRef.current = pageSwitch.isSwitching; return; } const { hasActiveTransition } = fadeIn; const justStartedSwitching = pageSwitch.isSwitching && !wasSwitchingRef.current; wasSwitchingRef.current = pageSwitch.isSwitching; // Only start crossfade for NON-transition navigation // Transitions use video overlay - no fade needed if (justStartedSwitching && !hasActiveTransition) { setIsFadingIn(true); } }, [pageSwitch.isSwitching, fadeIn]); return { isOverlayFadingOut, resetFadeOut, isFadingIn, onFadeInAnimationEnd, resetFadeIn, }; }