/** * useBackgroundTransition Hook * * Manages background transition effects when switching between pages. * Controls the fade-from-black overlay for smooth page transitions. * * When a page switch occurs: * 1. Page content switches instantly (hidden by black overlay) * 2. Black overlay fades out (1 → 0) revealing new page * * NOTE: Video transitions do NOT use fades - the video itself IS the transition. */ import { useEffect, useLayoutEffect, useState, useCallback, useRef, } from 'react'; import { isSafari, getCrossfadeDuration } from '../lib/browserUtils'; import type { ResolvedTransitionSettings } from '../types/transition'; /** * Fade-in configuration for page content */ 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-in configuration for page content */ fadeIn?: FadeInConfig; /** Optional resolved transition settings for dynamic duration/easing */ transitionSettings?: ResolvedTransitionSettings | null; } export interface UseBackgroundTransitionResult { /** Whether page content is currently fading (fade-from-black in progress) */ isFadingIn: boolean; /** Reset fade-in state (for cleanup or cancellation) */ resetFadeIn: () => void; /** Inline style for transition duration and easing */ transitionStyle: React.CSSProperties; } /** * Hook for managing background transition effects. * * @example * const { isFadingIn, transitionStyle } = useBackgroundTransition({ * pageSwitch, * fadeIn: { * hasActiveTransition: Boolean(transitionPreview), * }, * transitionSettings, * }); * * // Render black overlay that fades out * */ export function useBackgroundTransition({ pageSwitch, fadeIn, transitionSettings, }: UseBackgroundTransitionOptions): UseBackgroundTransitionResult { const [isFadingIn, setIsFadingIn] = useState(false); // Track previous isSwitching state to detect transition start const wasSwitchingRef = useRef(false); // Store transitionSettings in ref to avoid stale closures const transitionSettingsRef = useRef(transitionSettings); transitionSettingsRef.current = transitionSettings; // Timer ref for fade completion const fadeInTimerRef = useRef | null>(null); // Track if animation was already completed const fadeInCompletedRef = useRef(false); // Track fadeIn config in ref to avoid stale closure issues const fadeInRef = useRef(fadeIn); fadeInRef.current = fadeIn; /** * Reset fade-in state (for cleanup or cancellation). */ const resetFadeIn = useCallback(() => { setIsFadingIn(false); fadeInCompletedRef.current = false; if (fadeInTimerRef.current) { clearTimeout(fadeInTimerRef.current); fadeInTimerRef.current = null; } }, []); /** * Complete fade-in animation. */ const completeFadeIn = useCallback(() => { if (fadeInCompletedRef.current) return; fadeInCompletedRef.current = true; if (fadeInTimerRef.current) { clearTimeout(fadeInTimerRef.current); fadeInTimerRef.current = null; } setIsFadingIn(false); }, []); /** * Effect: Clear previous background overlay after fade completes (direct navigation). */ useEffect(() => { const hasActiveTransition = fadeInRef.current?.hasActiveTransition ?? false; // Skip clearing during video transitions if (hasActiveTransition) return; if (pageSwitch.isSwitching && pageSwitch.isNewBgReady && !isFadingIn) { pageSwitch.clearPreviousBackground(); } }, [ pageSwitch.isSwitching, pageSwitch.isNewBgReady, pageSwitch.clearPreviousBackground, isFadingIn, ]); /** * Layout effect: Start fade-from-black when switching starts. * useLayoutEffect runs before paint, ensuring overlay appears immediately. */ useLayoutEffect(() => { const currentFadeIn = fadeInRef.current; // Skip if fadeIn config was not provided if (!currentFadeIn) { wasSwitchingRef.current = pageSwitch.isSwitching; return; } const justStartedSwitching = pageSwitch.isSwitching && !wasSwitchingRef.current; wasSwitchingRef.current = pageSwitch.isSwitching; // Only start fade for NON-transition navigation if (justStartedSwitching && !currentFadeIn.hasActiveTransition) { fadeInCompletedRef.current = false; if (fadeInTimerRef.current) { clearTimeout(fadeInTimerRef.current); fadeInTimerRef.current = null; } setIsFadingIn(true); // Timer to end fade after animation duration const duration = getCrossfadeDuration( transitionSettingsRef.current?.durationMs, ); const bufferMs = isSafari() ? 100 : 50; fadeInTimerRef.current = setTimeout(() => { fadeInTimerRef.current = null; completeFadeIn(); }, duration + bufferMs); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [pageSwitch.isSwitching, completeFadeIn]); // Cleanup timer on unmount useEffect(() => { return () => { if (fadeInTimerRef.current) { clearTimeout(fadeInTimerRef.current); fadeInTimerRef.current = null; } }; }, []); const transitionStyle: React.CSSProperties = { '--transition-duration': `${transitionSettings?.durationMs ?? 700}ms`, '--transition-easing': transitionSettings?.easing ?? 'ease-in-out', '--overlay-color': transitionSettings?.overlayColor ?? '#000000', } as React.CSSProperties; return { isFadingIn, resetFadeIn, transitionStyle, }; }