39948-vm/frontend/src/hooks/useBackgroundTransition.ts

205 lines
5.9 KiB
TypeScript

/**
* 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
* <TransitionBlackOverlay isFadingIn={isFadingIn} transitionStyle={transitionStyle} />
*/
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<ReturnType<typeof setTimeout> | 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,
};
}