fixed adaptivity issue for transitions, added fades for navigation between pages without transitions

This commit is contained in:
Dmitri 2026-04-13 09:57:02 +04:00
parent ad9c788b21
commit d55453a42d
9 changed files with 279 additions and 238 deletions

View File

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
/// <reference path="./build/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.

View File

@ -15,8 +15,10 @@ interface CanvasBackgroundProps {
backgroundVideoUrl?: string;
backgroundAudioUrl?: string;
previousBgImageUrl?: string;
previousBgVideoUrl?: string;
isSwitching?: boolean;
isNewBgReady?: boolean;
isFadingIn?: boolean;
onBackgroundReady?: () => void;
// Video playback settings
videoAutoplay?: boolean;
@ -31,8 +33,10 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
backgroundVideoUrl,
backgroundAudioUrl,
previousBgImageUrl,
previousBgVideoUrl,
isSwitching = false,
isNewBgReady = false,
isFadingIn = false,
onBackgroundReady,
videoAutoplay = true,
videoLoop = true,
@ -94,10 +98,12 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
</div>
)}
{/* Previous background overlay - shows during page switch until new bg is ready */}
{previousBgImageUrl && isSwitching && !isNewBgReady && (
{/* 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). */}
{previousBgImageUrl && (isFadingIn || (isSwitching && !isNewBgReady)) && (
<div
className='pointer-events-none absolute inset-0 z-10'
className={`pointer-events-none absolute inset-0 z-0 ${isFadingIn ? 'animate-crossfade-out' : ''}`}
style={{
backgroundImage: `url("${previousBgImageUrl}")`,
backgroundSize: 'contain',
@ -106,6 +112,16 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
}}
/>
)}
{previousBgVideoUrl && (isFadingIn || (isSwitching && !isNewBgReady)) && (
<video
className={`absolute inset-0 z-0 h-full w-full object-contain pointer-events-none ${isFadingIn ? 'animate-crossfade-out' : ''}`}
src={previousBgVideoUrl}
autoPlay
loop
muted
playsInline
/>
)}
{/* Background video - z-1 keeps it below backdrop blur layer (z-5) */}
{backgroundVideoUrl && (

View File

@ -4,6 +4,9 @@
* Full-screen overlay for transition video preview.
* Designed to work with useTransitionPlayback hook which manages
* video src and playback externally via the videoRef.
*
* Supports letterbox mode to constrain transitions within canvas bounds,
* matching the behavior of background images and UI elements.
*/
import React from 'react';
@ -15,26 +18,53 @@ interface TransitionPreviewOverlayProps {
isActive: boolean;
/** Whether the video is currently buffering (used to hide video during load) */
isBuffering?: boolean;
/** Letterbox styles from useCanvasScale - positions overlay within canvas bounds */
letterboxStyles?: React.CSSProperties;
/** Video object-fit mode (default: 'contain' to match backgrounds) */
videoFit?: 'contain' | 'cover';
/** Additional opacity value for fade-out effects (0-1) */
opacity?: number;
}
const TransitionPreviewOverlay: React.FC<TransitionPreviewOverlayProps> = ({
videoRef,
isActive,
isBuffering = false,
letterboxStyles,
videoFit = 'contain',
opacity,
}) => {
if (!isActive) return null;
// Video opacity: 0 while buffering, 1 otherwise
const videoOpacity = isBuffering ? 0 : 1;
// Container opacity: controlled by parent for fade-out effects
const containerOpacity = opacity ?? 1;
return (
<div className='fixed inset-0 z-50 overflow-hidden pointer-events-none'>
<video
ref={videoRef}
className='absolute inset-0 h-full w-full object-cover transition-opacity duration-300 ease-linear'
style={{ opacity: isBuffering ? 0 : 1 }}
muted
playsInline
preload='auto'
disablePictureInPicture
/>
// Outer: full viewport with black background (letterbox bars)
// No fade transition - transition video itself is the effect
<div
className='fixed inset-0 z-50 overflow-hidden pointer-events-none bg-black'
style={{ opacity: containerOpacity }}
>
{/* Inner: respects letterbox dimensions when provided */}
<div
className='overflow-hidden'
style={letterboxStyles || { position: 'absolute', inset: 0 }}
>
<video
ref={videoRef}
className={`absolute inset-0 h-full w-full transition-opacity duration-300 ease-linear ${
videoFit === 'cover' ? 'object-cover' : 'object-contain'
}`}
style={{ opacity: videoOpacity }}
muted
playsInline
preload='auto'
disablePictureInPicture
/>
</div>
</div>
);
};

View File

@ -22,6 +22,7 @@ import BaseButton from './BaseButton';
import CardBox from './CardBox';
import { OfflineToggle } from './Offline/OfflineToggle';
import RuntimeElement from './RuntimeElement';
import TransitionPreviewOverlay from './Constructor/TransitionPreviewOverlay';
import GalleryCarouselOverlay from './UiElements/GalleryCarouselOverlay';
import { BackdropPortalProvider } from './BackdropPortal';
import { RotatePrompt } from './RotatePrompt';
@ -226,12 +227,12 @@ export default function RuntimePresentation({
},
});
// Use shared background transition hook for fade-out and fade-in effects
// Use shared background transition hook for crossfade effects
const {
isOverlayFadingOut,
resetFadeOut,
isFadingIn,
elementsOpacity,
onFadeInAnimationEnd,
resetFadeIn,
} = useBackgroundTransition({
pageSwitch,
@ -349,10 +350,10 @@ export default function RuntimePresentation({
isReverse: isBack,
});
} else {
// Direct navigation - use shared hook for smooth transition
// Reset fade-in state to start fresh
resetFadeIn();
// Previous background stays visible until new one is ready
// Direct navigation with crossfade effect:
// useBackgroundTransition detects switching and applies animation classes
// - New page gets animate-crossfade-in (0 → 1)
// - Previous background gets animate-crossfade-out (1 → 0)
setIsBackgroundReady(false);
// Mark this page as initialized to prevent redundant effect calls
lastInitializedPageIdRef.current = targetPageId;
@ -534,65 +535,17 @@ export default function RuntimePresentation({
style={{
...cssVars,
...letterboxStyles,
backgroundImage: backgroundImageUrl
? `url("${backgroundImageUrl}")`
: undefined,
backgroundSize: 'contain',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
}}
>
<BackdropPortalProvider>
{/* Background image element - z-1 keeps it below backdrop blur (z-5).
CSS backgroundImage provides instant display.
Use native img for blob URLs to prevent repeated fetch requests from Next.js Image. */}
{backgroundImageUrl && !backgroundVideoUrl && (
<div className='absolute inset-0 z-1 pointer-events-none'>
{backgroundImageUrl.startsWith('blob:') ? (
// eslint-disable-next-line @next/next/no-img-element
<img
key={backgroundImageUrl}
src={backgroundImageUrl}
alt=''
className='absolute inset-0 w-full h-full object-contain'
onLoad={() => {
setIsBackgroundReady(true);
pageSwitch.markBackgroundReady();
}}
onError={() => {
setIsBackgroundReady(true);
pageSwitch.markBackgroundReady();
}}
/>
) : (
<Image
key={backgroundImageUrl}
src={backgroundImageUrl}
alt=''
fill
sizes='100vw'
className='object-contain'
priority
unoptimized
onLoad={() => {
setIsBackgroundReady(true);
pageSwitch.markBackgroundReady();
}}
onError={() => {
setIsBackgroundReady(true);
pageSwitch.markBackgroundReady();
}}
/>
)}
</div>
)}
{/* Previous background overlay - shows during direct navigation until new bg is ready */}
{/* Previous background overlays - show during loading AND crossfade.
Uses CSS animation for fade-out effect.
Cleared by useBackgroundTransition after fade completes. */}
{pageSwitch.previousBgImageUrl &&
pageSwitch.isSwitching &&
!pageSwitch.isNewBgReady && (
(isFadingIn ||
(pageSwitch.isSwitching && !pageSwitch.isNewBgReady)) && (
<div
className='absolute inset-0 pointer-events-none z-10'
className={`absolute inset-0 pointer-events-none z-0 ${isFadingIn ? 'animate-crossfade-out' : ''}`}
style={{
backgroundImage: `url("${pageSwitch.previousBgImageUrl}")`,
backgroundSize: 'contain',
@ -601,43 +554,101 @@ export default function RuntimePresentation({
}}
/>
)}
{/* Background video - z-1 keeps it below backdrop blur (z-5) */}
{backgroundVideoUrl && (
<video
ref={bgVideoRef}
key={backgroundVideoUrl}
className='absolute inset-0 z-1 h-full w-full object-contain'
src={backgroundVideoUrl}
autoPlay={videoAutoplay}
loop={useNativeLoop}
muted={videoMuted}
playsInline
/>
)}
{/* Page elements - z-40 ensures they appear above carousel background (z-10) and carousel controls (z-30) */}
<div
className='absolute inset-0 z-40'
style={{
opacity: elementsOpacity,
transition: isFadingIn
? `opacity ${CANVAS_CONFIG.pageTransition.fadeInDurationMs}ms ${CANVAS_CONFIG.pageTransition.easing}`
: 'none',
}}
>
{pageElements.map((element: CanvasElement) => (
<RuntimeElement
key={element.id}
element={element}
onClick={() => handleElementClick(element)}
resolveUrl={resolveUrlWithBlob}
onGalleryCardClick={(cardIndex) =>
handleGalleryCardClick(element, cardIndex)
}
{pageSwitch.previousBgVideoUrl &&
(isFadingIn ||
(pageSwitch.isSwitching && !pageSwitch.isNewBgReady)) && (
<video
className={`absolute inset-0 h-full w-full object-contain pointer-events-none z-0 ${isFadingIn ? 'animate-crossfade-out' : ''}`}
src={pageSwitch.previousBgVideoUrl}
autoPlay
loop
muted
playsInline
/>
))}
)}
{/* New page content wrapper - fades in for non-transition navigation.
z-1 ensures it's above previous backgrounds (z-0) during fade.
onAnimationEnd resets isFadingIn when CSS animation completes. */}
<div
data-testid='page-content-wrapper'
className={`absolute inset-0 z-1 ${isFadingIn ? 'animate-crossfade-in' : ''}`}
onAnimationEnd={onFadeInAnimationEnd}
>
{/* Background image element - z-1 keeps it below backdrop blur (z-5).
CSS backgroundImage provides instant display.
Use native img for blob URLs to prevent repeated fetch requests from Next.js Image. */}
{backgroundImageUrl && !backgroundVideoUrl && (
<div className='absolute inset-0 z-1 pointer-events-none'>
{backgroundImageUrl.startsWith('blob:') ? (
// eslint-disable-next-line @next/next/no-img-element
<img
key={backgroundImageUrl}
src={backgroundImageUrl}
alt=''
className='absolute inset-0 w-full h-full object-contain'
onLoad={() => {
setIsBackgroundReady(true);
pageSwitch.markBackgroundReady();
}}
onError={() => {
setIsBackgroundReady(true);
pageSwitch.markBackgroundReady();
}}
/>
) : (
<Image
key={backgroundImageUrl}
src={backgroundImageUrl}
alt=''
fill
sizes='100vw'
className='object-contain'
priority
unoptimized
onLoad={() => {
setIsBackgroundReady(true);
pageSwitch.markBackgroundReady();
}}
onError={() => {
setIsBackgroundReady(true);
pageSwitch.markBackgroundReady();
}}
/>
)}
</div>
)}
{/* Background video - z-1 keeps it below backdrop blur (z-5) */}
{backgroundVideoUrl && (
<video
ref={bgVideoRef}
key={backgroundVideoUrl}
className='absolute inset-0 z-1 h-full w-full object-contain'
src={backgroundVideoUrl}
autoPlay={videoAutoplay}
loop={useNativeLoop}
muted={videoMuted}
playsInline
/>
)}
{/* Page elements - z-40 ensures they appear above carousel background (z-10) and carousel controls (z-30) */}
<div className='absolute inset-0 z-40'>
{pageElements.map((element: CanvasElement) => (
<RuntimeElement
key={element.id}
element={element}
onClick={() => handleElementClick(element)}
resolveUrl={resolveUrlWithBlob}
onGalleryCardClick={(cardIndex) =>
handleGalleryCardClick(element, cardIndex)
}
/>
))}
</div>
</div>
{/* End new page content wrapper */}
{/* Controls: Offline toggle and Fullscreen button */}
<div className='absolute top-4 right-4 z-50 flex items-center gap-2'>
@ -661,24 +672,13 @@ export default function RuntimePresentation({
{/* Opacity is 0 during 'preparing' phase to show old page while video loads */}
{/* Also fades to 0 when isOverlayFadingOut to reveal the new page underneath */}
{transitionPreview && (
<div className='fixed inset-0 z-50 overflow-hidden pointer-events-none'>
<video
ref={transitionVideoRef}
className='absolute inset-0 h-full w-full object-cover transition-opacity duration-300 ease-linear'
style={{
opacity:
transitionPhase === 'preparing' ||
isBuffering ||
isOverlayFadingOut
? 0
: 1,
}}
muted
playsInline
preload='auto'
disablePictureInPicture
/>
</div>
<TransitionPreviewOverlay
videoRef={transitionVideoRef}
isActive={true}
isBuffering={transitionPhase === 'preparing' || isBuffering}
letterboxStyles={letterboxStyles}
opacity={isOverlayFadingOut ? 0 : 1}
/>
)}
{/* Gallery Carousel Overlay */}

View File

@ -43,14 +43,10 @@ export const CANVAS_CONFIG = {
// Page transition effects
pageTransition: {
/**
* Fade-in duration for non-transition navigation (ms).
* Applied when switching pages without a transition video.
*/
fadeInDurationMs: 500,
/**
* Fade-out duration for transition video overlay (ms).
* Applied after transition video finishes playing.
* Note: Crossfade duration is controlled by CSS in main.css (.animate-crossfade-in/out)
*/
fadeOutDurationMs: 300,
/**

View File

@ -34,6 +34,34 @@
@apply bg-transparent border border-blue-600 text-blue-600 !important;
}
/* Page crossfade animation keyframes */
@keyframes page-crossfade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes page-crossfade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
/* Crossfade animation classes */
.animate-crossfade-in {
animation: page-crossfade-in 500ms ease-out forwards;
}
.animate-crossfade-out {
animation: page-crossfade-out 500ms ease-out forwards;
}
/* Element appear animation keyframes */
@keyframes element-fade-in {
from {

View File

@ -14,19 +14,20 @@
* 2. Simple mode (constructor): Direct navigation clearing + optional fade-in
*/
import { useEffect, useState, useCallback, useRef } from 'react';
import {
useEffect,
useLayoutEffect,
useState,
useCallback,
useRef,
} from 'react';
import { CANVAS_CONFIG } from '../config/canvas.config';
/**
* Fade-out duration from config
* Fade-out duration from config (for transition video overlay)
*/
const FADE_OUT_DURATION_MS = CANVAS_CONFIG.pageTransition.fadeOutDurationMs;
/**
* Fade-in duration from config
*/
const FADE_IN_DURATION_MS = CANVAS_CONFIG.pageTransition.fadeInDurationMs;
/**
* Fade-out configuration (optional - for RuntimePresentation)
*/
@ -47,8 +48,6 @@ export interface FadeOutConfig {
export interface FadeInConfig {
/** Whether a transition video is currently active (disables fade-in) */
hasActiveTransition: boolean;
/** Optional duration override (uses config default if not provided) */
durationMs?: number;
}
export interface UseBackgroundTransitionOptions {
@ -58,6 +57,7 @@ export interface UseBackgroundTransitionOptions {
isSwitching: boolean;
isNewBgReady: boolean;
previousBgImageUrl: string;
previousBgVideoUrl: string;
};
/** Optional fade-out configuration (for RuntimePresentation) */
fadeOut?: FadeOutConfig;
@ -70,11 +70,11 @@ export interface UseBackgroundTransitionResult {
isOverlayFadingOut: boolean;
/** Reset the fade-out state (call before starting a new transition) */
resetFadeOut: () => void;
/** Whether page content is currently fading in */
/** Whether page content is currently fading (crossfade in progress) */
isFadingIn: boolean;
/** Opacity value for elements container (0 during fade, 1 when complete) */
elementsOpacity: number;
/** Reset fade-in state before starting new navigation */
/** Handler to call when fade-in animation ends (pass to onAnimationEnd) */
onFadeInAnimationEnd: () => void;
/** Reset fade-in state (for cleanup or cancellation) */
resetFadeIn: () => void;
}
@ -83,7 +83,7 @@ export interface UseBackgroundTransitionResult {
*
* @example
* // Full mode with fade-out and fade-in (RuntimePresentation)
* const { isOverlayFadingOut, resetFadeOut, isFadingIn, elementsOpacity, resetFadeIn } = useBackgroundTransition({
* const { isOverlayFadingOut, resetFadeOut, isFadingIn, onFadeInAnimationEnd } = useBackgroundTransition({
* pageSwitch,
* fadeOut: {
* pendingTransitionComplete,
@ -99,6 +99,12 @@ export interface UseBackgroundTransitionResult {
* },
* });
*
* // In JSX:
* <div
* className={isFadingIn ? 'animate-crossfade-in' : ''}
* onAnimationEnd={onFadeInAnimationEnd}
* >
*
* @example
* // Simple mode - direct navigation only (constructor)
* useBackgroundTransition({ pageSwitch });
@ -109,15 +115,10 @@ export function useBackgroundTransition({
fadeIn,
}: UseBackgroundTransitionOptions): UseBackgroundTransitionResult {
const [isOverlayFadingOut, setIsOverlayFadingOut] = useState(false);
// Fade-in state
const [isFadingIn, setIsFadingIn] = useState(false);
const [elementsOpacity, setElementsOpacity] = useState(1);
// Track previous isSwitching state to detect transition start
const wasSwitchingRef = useRef(false);
// Track timer for cleanup
const fadeInTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
/**
* Reset fade-out state before starting a new transition.
@ -128,16 +129,18 @@ export function useBackgroundTransition({
}, []);
/**
* Reset fade-in state before starting new navigation.
* Clears any in-progress fade animation.
* Reset fade-in state (for cleanup or cancellation).
*/
const resetFadeIn = useCallback(() => {
if (fadeInTimerRef.current) {
clearTimeout(fadeInTimerRef.current);
fadeInTimerRef.current = null;
}
setIsFadingIn(false);
setElementsOpacity(1);
}, []);
/**
* Handler for onAnimationEnd event.
* Called when CSS animation completes - CSS is the source of truth for duration.
*/
const onFadeInAnimationEnd = useCallback(() => {
setIsFadingIn(false);
}, []);
/**
@ -187,92 +190,55 @@ export function useBackgroundTransition({
}, [fadeOut, isOverlayFadingOut, pageSwitch]);
/**
* Effect: Clear previous background overlay when new background is ready (direct navigation).
* Effect: Clear previous background overlay after fade completes (direct navigation).
*
* This handles the case when navigating without a transition video.
* The previous background stays visible until the new one is ready to paint.
* 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 &&
pageSwitch.previousBgImageUrl
) {
// New background is ready - clear the previous background overlay
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.previousBgImageUrl,
pageSwitch.clearPreviousBackground,
isFadingIn,
]);
/**
* Effect: Fade-in page content on non-transition navigation.
* 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.
*
* Trigger: pageSwitch.isSwitching becomes true AND no active transition
*
* Sequence:
* 1. Navigation starts (isSwitching: false true)
* 2. No transition video active
* 3. Set elementsOpacity = 0
* 4. Use double RAF to ensure paint before animation
* 5. Set elementsOpacity = 1 (CSS animates)
* 6. After duration, set isFadingIn = false
* IMPORTANT: Skip this for transitions - transition video IS the effect.
*/
useEffect(() => {
useLayoutEffect(() => {
if (!fadeIn) {
wasSwitchingRef.current = pageSwitch.isSwitching;
return;
}
const { hasActiveTransition, durationMs = FADE_IN_DURATION_MS } = fadeIn;
const { hasActiveTransition } = fadeIn;
const justStartedSwitching =
pageSwitch.isSwitching && !wasSwitchingRef.current;
wasSwitchingRef.current = pageSwitch.isSwitching;
// Start fade-in when:
// - Just started switching (transition from false to true)
// - No active transition video
// Only start crossfade for NON-transition navigation
// Transitions use video overlay - no fade needed
if (justStartedSwitching && !hasActiveTransition) {
setIsFadingIn(true);
setElementsOpacity(0);
// Double RAF ensures opacity:0 is painted before transition starts
// (Same pattern as usePageSwitch.ts:396-397)
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setElementsOpacity(1);
});
});
// Clear any existing timer
if (fadeInTimerRef.current) {
clearTimeout(fadeInTimerRef.current);
}
// Mark fade as complete after duration
fadeInTimerRef.current = setTimeout(() => {
setIsFadingIn(false);
fadeInTimerRef.current = null;
}, durationMs);
}
// Cleanup on unmount
return () => {
if (fadeInTimerRef.current) {
clearTimeout(fadeInTimerRef.current);
fadeInTimerRef.current = null;
}
};
}, [pageSwitch.isSwitching, fadeIn]);
return {
isOverlayFadingOut,
resetFadeOut,
isFadingIn,
elementsOpacity,
onFadeInAnimationEnd,
resetFadeIn,
};
}

View File

@ -63,6 +63,8 @@ export interface UsePageSwitchResult {
currentBgAudioUrl: string;
/** Previous background image URL (for overlay) */
previousBgImageUrl: string;
/** Previous background video URL (for overlay during fade) */
previousBgVideoUrl: string;
/** Whether we're in the middle of a page switch */
isSwitching: boolean;
/** Whether the new background is ready to display */
@ -198,6 +200,10 @@ export function usePageSwitch(
const previousBgImageUrlRef = useRef('');
previousBgImageUrlRef.current = previousBgImageUrl;
const [previousBgVideoUrl, setPreviousBgVideoUrl] = useState('');
const previousBgVideoUrlRef = useRef('');
previousBgVideoUrlRef.current = previousBgVideoUrl;
// Transition state
const [isSwitching, setIsSwitching] = useState(false);
const [isNewBgReady, setIsNewBgReady] = useState(true);
@ -362,16 +368,20 @@ export function usePageSwitch(
setCurrentBgVideoUrl('');
setCurrentBgAudioUrl('');
setPreviousBgImageUrl('');
setPreviousBgVideoUrl('');
setIsSwitching(false);
setIsNewBgReady(true);
onSwitched?.();
return;
}
// Save current image as previous for overlay (use ref to avoid dependency)
// Save current backgrounds as previous for overlay (use refs to avoid dependencies)
if (currentBgImageUrlRef.current) {
setPreviousBgImageUrl(currentBgImageUrlRef.current);
}
if (currentBgVideoUrlRef.current) {
setPreviousBgVideoUrl(currentBgVideoUrlRef.current);
}
setIsSwitching(true);
setIsNewBgReady(false);
@ -433,16 +443,21 @@ export function usePageSwitch(
}, []);
/**
* Clear the previous background overlay
* Clear the previous background overlay (both image and video)
*/
const clearPreviousBackground = useCallback(() => {
const prevUrl = previousBgImageUrlRef.current;
const prevImageUrl = previousBgImageUrlRef.current;
const prevVideoUrl = previousBgVideoUrlRef.current;
setPreviousBgImageUrl('');
setPreviousBgVideoUrl('');
setIsSwitching(false);
// Revoke the previous blob URL after clearing
if (prevUrl) {
revokeBlobUrl(prevUrl);
// Revoke the previous blob URLs after clearing
if (prevImageUrl) {
revokeBlobUrl(prevImageUrl);
}
if (prevVideoUrl) {
revokeBlobUrl(prevVideoUrl);
}
}, [revokeBlobUrl]);
@ -451,6 +466,7 @@ export function usePageSwitch(
currentBgVideoUrl,
currentBgAudioUrl,
previousBgImageUrl,
previousBgVideoUrl,
isSwitching,
isNewBgReady,
switchToPage,

View File

@ -358,10 +358,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
// Destructure stable callback reference to avoid infinite loops in useEffect deps
const pageSwitchToPage = pageSwitch.switchToPage;
// Use shared background transition hook for direct navigation clearing and fade-in
// (No fade-out needed in constructor - transitions complete immediately)
// NOTE: Must be defined before switchToPage callback which uses resetFadeIn
const { isFadingIn, elementsOpacity, resetFadeIn } = useBackgroundTransition({
// Use shared background transition hook for direct navigation clearing and crossfade
// Crossfade starts automatically when new background is ready
const { isFadingIn } = useBackgroundTransition({
pageSwitch,
fadeIn: {
hasActiveTransition: Boolean(transitionPreview),
@ -374,9 +373,6 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
// isBack parameter indicates this is a back navigation (pops history instead of pushing)
const switchToPage = useCallback(
async (page: TourPage | null, isBack = false) => {
// Reset fade-in state to start fresh
resetFadeIn();
// Mark this page as initialized to prevent redundant effect calls
if (page) {
lastInitializedPageIdRef.current = page.id;
@ -386,6 +382,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
updateBackgroundFromPage(page);
// Use hook to resolve and set blob URLs for display
// Fade starts automatically when new background is ready (crossfade effect)
await pageSwitchToPage(
page
? {
@ -403,12 +400,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
},
);
},
[
pageSwitchToPage,
updateBackgroundFromPage,
applyPageSelection,
resetFadeIn,
],
[pageSwitchToPage, updateBackgroundFromPage, applyPageSelection],
);
const { isBuffering: isReverseBuffering } = useTransitionPlayback({
@ -1466,8 +1458,10 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
backgroundVideoUrl={backgroundVideoSrc}
backgroundAudioUrl={backgroundAudioSrc}
previousBgImageUrl={pageSwitch.previousBgImageUrl}
previousBgVideoUrl={pageSwitch.previousBgVideoUrl}
isSwitching={pageSwitch.isSwitching}
isNewBgReady={pageSwitch.isNewBgReady}
isFadingIn={isFadingIn}
onBackgroundReady={() => pageSwitch.markBackgroundReady()}
videoAutoplay={backgroundVideoAutoplay}
videoLoop={backgroundVideoLoop}
@ -1478,13 +1472,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
{/* Elements container - z-10 ensures they appear above backdrop layer */}
<div
className='absolute inset-0 z-10'
style={{
opacity: elementsOpacity,
transition: isFadingIn
? `opacity ${CANVAS_CONFIG.pageTransition.fadeInDurationMs}ms ${CANVAS_CONFIG.pageTransition.easing}`
: 'none',
}}
className={`absolute inset-0 z-10 ${isFadingIn ? 'animate-crossfade-in' : ''}`}
>
{isLoading ? (
<div className='absolute inset-0 flex items-center justify-center'>
@ -1590,6 +1578,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
videoRef={transitionVideoRef}
isActive={Boolean(transitionPreview)}
isBuffering={isReverseBuffering}
letterboxStyles={letterboxStyles}
/>
{/* Gallery Carousel Overlay */}