39948-vm/frontend/src/components/RuntimePresentation.tsx
2026-05-28 07:13:35 +00:00

994 lines
37 KiB
TypeScript

/**
* RuntimePresentation Component
*
* Renders a presentation for a specific project and environment.
* Used by /p/[projectSlug] and /p/[projectSlug]/stage routes.
*/
import Head from 'next/head';
import React, {
ReactElement,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { flushSync } from 'react-dom';
import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import CardBox from './CardBox';
import RuntimeControls from './Runtime/RuntimeControls';
import RuntimeElement from './RuntimeElement';
import TransitionPreviewOverlay from './Constructor/TransitionPreviewOverlay';
import GalleryCarouselOverlay from './UiElements/GalleryCarouselOverlay';
import { BackdropPortalProvider } from './BackdropPortal';
import { RotatePrompt } from './RotatePrompt';
import CanvasBackground from './Constructor/CanvasBackground';
import CanvasLoadingSpinner from './CanvasLoadingSpinner';
import TransitionBlackOverlay from './TransitionBlackOverlay';
import { useCanvasScale } from '../hooks/useCanvasScale';
import { CANVAS_CONFIG } from '../config/canvas.config';
import LayoutGuest from '../layouts/Guest';
import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
import { useVideoSoundControl } from '../hooks/useVideoSoundControl';
import { usePageDataLoader } from '../hooks/usePageDataLoader';
import { useProjectAssets } from '../hooks/useProjectAssets';
import { usePageNavigation } from '../hooks/usePageNavigation';
import {
extractPageLinksOnly,
extractElementsForPages,
} from '../lib/extractPageLinks';
import { usePageNavigationState } from '../hooks/usePageNavigationState';
import { useTransitionPlayback } from '../hooks/useTransitionPlayback';
import { useNetworkAware } from '../hooks/useNetworkAware';
import { resolveAssetPlaybackUrl } from '../lib/assetUrl';
import { downloadManager } from '../lib/offline/DownloadManager';
import { isSafari } from '../lib/browserUtils';
import { logger } from '../lib/logger';
import {
resolveNavigationTarget,
isTransitionBlocking,
isBackNavigation,
isNavigationType,
} from '../lib/navigationHelpers';
import { useTransitionSettings } from '../hooks/useTransitionSettings';
import { useAppSelector, useAppDispatch } from '../stores/hooks';
import { fetch as fetchGlobalTransitionDefaults } from '../stores/global_transition_defaults/globalTransitionDefaultsSlice';
import {
fetchByProjectAndEnv as fetchProjectTransitionSettings,
selectByProjectAndEnv as selectProjectTransitionSettings,
} from '../stores/project_transition_settings/projectTransitionSettingsSlice';
import type { TransitionPhase } from '../types/presentation';
import type { CanvasElement } from '../types/constructor';
import type { ElementTransitionSettings } from '../types/transition';
import {
entityToProjectSettings,
extractElementTransitionSettings,
} from '../types/transition';
interface RuntimePresentationProps {
projectSlug: string;
environment: 'stage' | 'production';
}
export default function RuntimePresentation({
projectSlug,
environment,
}: RuntimePresentationProps) {
const dispatch = useAppDispatch();
const globalTransitionDefaults = useAppSelector(
(state) => state.global_transition_defaults.data,
);
// Use shared hook for loading project and pages data
// Note: We can't fetch project transition settings until we have the project ID
const { project, pages, isLoading, error, initialPageId } = usePageDataLoader(
{
projectSlug,
environment,
apiHeaders: {
'X-Runtime-Project-Slug': projectSlug,
'X-Runtime-Environment': environment,
},
},
);
// Fetch global transition defaults on mount (public endpoint, no auth needed)
useEffect(() => {
dispatch(fetchGlobalTransitionDefaults());
}, [dispatch]);
// Fetch project transition settings when project is loaded
useEffect(() => {
if (project?.id) {
dispatch(
fetchProjectTransitionSettings({ projectId: project.id, environment }),
);
}
}, [dispatch, project?.id, environment]);
// Select project transition settings from store (environment-aware)
const projectTransitionSettingsEntity = useAppSelector((state) =>
project?.id
? selectProjectTransitionSettings(state, project.id, environment)
: undefined,
);
const projectTransitionSettings = useMemo(
() => entityToProjectSettings(projectTransitionSettingsEntity),
[projectTransitionSettingsEntity],
);
// Track current element's transition settings (set when navigation is triggered)
const [
currentElementTransitionSettings,
setCurrentElementTransitionSettings,
] = useState<ElementTransitionSettings | null>(null);
// Resolve transition settings using the cascade: Element → Project → Global
const transitionSettings = useTransitionSettings({
globalDefaults: globalTransitionDefaults,
projectSettings: projectTransitionSettings,
elementSettings: currentElementTransitionSettings,
});
// Resolve project assets (favicon, og_image, logo) to presigned URLs
const { faviconUrl, ogImageUrl } = useProjectAssets(project);
// Page navigation with history tracking via shared hook
const {
currentPageId: selectedPageId,
pageHistory,
applyPageSelection,
getNavigationContext,
} = usePageNavigation({
pages,
defaultPageId: initialPageId || undefined,
trackHistory: true,
});
// Get current page for design dimensions (presentations use page dimensions, not project)
const currentPage = pages.find((p) => p.id === selectedPageId);
// Canvas scale for responsive UI elements and letterbox mode
// Uses page's design dimensions (saved at constructor save time) for presentation isolation
const {
cssVars,
letterboxStyles,
isPortrait,
showRotatePrompt,
canvasWidth,
canvasHeight,
} = useCanvasScale({
designWidth: currentPage?.design_width ?? undefined,
designHeight: currentPage?.design_height ?? undefined,
});
// Network-aware transitions: skip video on slow networks, use CSS fade instead
const { shouldUseVideoTransitions, networkInfo } = useNetworkAware();
const [transitionPreview, setTransitionPreview] = useState<{
targetPageId: string;
videoUrl: string;
storageKey: string;
isBack: boolean;
reverseVideoUrl?: string;
reverseStorageKey?: string;
} | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const [activeGalleryCarousel, setActiveGalleryCarousel] = useState<{
element: CanvasElement;
initialIndex: number;
} | null>(null);
const transitionVideoRef = useRef<HTMLVideoElement>(null);
const lastInitializedPageIdRef = useRef<string | null>(null);
// Note: Initial page selection is handled by usePageNavigation hook via defaultPageId
// Phase 1: Extract pageLinks from ALL pages (needed for navigation graph)
// This is lightweight - only extracts navigation structure, not asset URLs
const pageLinks = useMemo(() => {
const links = extractPageLinksOnly(pages);
if (links.length > 0) {
logger.info('[PRELOAD] Extracted page links', {
count: links.length,
links: links.map((link) => ({
from: link.from_pageId?.slice(-8),
to: link.to_pageId?.slice(-8),
hasTransition: !!link.transition?.video_url,
})),
});
}
return links;
}, [pages]);
// Phase 2: Extract elements only for current + neighbor pages (progressive)
// This avoids parsing ui_schema_json for all pages upfront
const preloadElements = useMemo(() => {
if (!selectedPageId || pages.length === 0) return [];
// Build simple neighbor set from pageLinks
const neighborIds = new Set<string>();
neighborIds.add(selectedPageId); // Current page
pageLinks.forEach((link) => {
if (link.from_pageId === selectedPageId && link.to_pageId) {
neighborIds.add(link.to_pageId); // Direct neighbors
}
});
// Extract elements only for current + neighbors
const elements = extractElementsForPages(pages, Array.from(neighborIds));
logger.info('[PRELOAD] Extracted elements for pages', {
currentPage: selectedPageId.slice(-8),
pageCount: neighborIds.size,
elementCount: elements.length,
});
return elements;
}, [pages, pageLinks, selectedPageId]);
// Initialize preload orchestrator with transformed data
// STREAM-FIRST: Preloads current page + transition videos only
// Online: Videos stream on-demand, cache after playback (no bandwidth competition)
// Offline: Assets already fully downloaded via useOfflineMode.startDownload()
const preloadOrchestrator = usePreloadOrchestrator({
pages,
pageLinks,
elements: preloadElements,
currentPageId: selectedPageId,
pageHistory,
enabled: !isLoading && !error,
});
// Selected page - moved early for easier access
const selectedPage = useMemo(
() => pages.find((p) => p.id === selectedPageId) || null,
[pages, selectedPageId],
);
// Unified page navigation state machine (replaces 6+ separate hooks)
// Uses useReducer for atomic state transitions, preventing race conditions
const navState = usePageNavigationState({
preloadCache: preloadOrchestrator
? {
getReadyBlobUrl: preloadOrchestrator.getReadyBlobUrl,
getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl,
preloadedUrls: preloadOrchestrator.preloadedUrls,
}
: undefined,
transitionSettings,
});
// Destructure for convenience (matches previous hook interfaces)
// showElements/showSpinner are derived from the unified state machine phase:
// - showElements: true when phase is 'idle' or 'fading_in'
// - showSpinner: true when phase is 'preparing', 'loading_bg', or 'transition_done'
const {
currentImageUrl: navCurrentBgImageUrl,
currentVideoUrl: navCurrentBgVideoUrl,
previousImageUrl: navPreviousBgImageUrl,
previousVideoUrl: navPreviousBgVideoUrl,
isSwitching: navIsSwitching,
isNewBgReady: navIsNewBgReady,
pendingTransitionComplete,
isFadingIn,
showElements: navShowElements,
showSpinner: navShowSpinner,
showTransitionVideo,
transitionStyle,
lastKnownBgUrl,
onBackgroundReady: navOnBackgroundReady,
onVideoBufferStateChange,
onTransitionEnded,
navigateToPage: navNavigateToPage,
resetToIdle: navResetToIdle,
startTransition,
} = navState;
// Integrate useTransitionPlayback hook for smooth transitions (matches Constructor pattern)
const {
isBuffering,
isVideoReady,
phase: transitionPhase,
} = useTransitionPlayback({
videoRef: transitionVideoRef,
transition: transitionPreview
? {
videoUrl: transitionPreview.videoUrl,
storageKey: transitionPreview.storageKey,
reverseMode: transitionPreview.reverseVideoUrl ? 'separate' : 'none',
reverseVideoUrl: transitionPreview.reverseVideoUrl,
reverseStorageKey: transitionPreview.reverseStorageKey, // Raw path for cache lookup
targetPageId: transitionPreview.targetPageId,
displayName: 'Transition',
isBack: transitionPreview.isBack,
}
: null,
onComplete: async (targetPageId, isBack) => {
// Resume background downloads now that transition is complete
downloadManager.resumeAll();
if (targetPageId) {
const targetPage = pages.find((p) => p.id === targetPageId);
// Mark this page as initialized to prevent redundant effect calls
lastInitializedPageIdRef.current = targetPageId;
// Signal that transition video has ended
// State machine transitions to 'transition_done', waiting for background
onTransitionEnded();
// DON'T close transitionPreview here - it stays visible until background is ready
// The useEffect below will close it when pendingTransitionComplete becomes false
// Navigate to target page - state machine handles ready state
await navNavigateToPage(targetPage, {
hasTransition: false, // Already played
isBack: isBack ?? false,
onSwitched: () => {
applyPageSelection(targetPageId, isBack ?? false);
},
});
} else {
// No target page - clean up and remove overlay
const video = transitionVideoRef.current;
video?.removeAttribute('src');
video?.load();
setTransitionPreview(null);
navResetToIdle();
}
},
features: {
useBlobUrl: true,
// Don't pre-decode images in the hook - we handle it via overlay:
// Overlay shows last transition frame while new page background loads behind it
preDecodeImages: false,
},
preload: {
preloadedUrls: preloadOrchestrator?.preloadedUrls || new Set(),
getCachedBlobUrl: preloadOrchestrator?.getCachedBlobUrl,
getReadyBlobUrl: preloadOrchestrator?.getReadyBlobUrl,
getReadyBlob: preloadOrchestrator?.getReadyBlob,
},
});
// Sync transition video buffering state with navigation state machine
// This enables unified showSpinner logic in the state machine
useEffect(() => {
const isTransitionBuffering = Boolean(transitionPreview) && isBuffering;
onVideoBufferStateChange(isTransitionBuffering);
}, [transitionPreview, isBuffering, onVideoBufferStateChange]);
// Clean up transition preview when state machine says video overlay should be hidden
// showTransitionVideo is true during 'transitioning', 'transition_done', and 'fading_in' phases
// During 'fading_in', the overlay fades out (isFadingOut=true), then removed when phase goes to 'idle'
useEffect(() => {
if (transitionPreview && !showTransitionVideo) {
setTransitionPreview(null);
}
}, [transitionPreview, showTransitionVideo]);
// Reset navigation state when starting a new transition
const resetFadeIn = useCallback(() => {
navResetToIdle();
}, [navResetToIdle]);
const toggleFullscreen = useCallback(async () => {
try {
if (!document.fullscreenElement) {
await document.documentElement.requestFullscreen();
setIsFullscreen(true);
} else {
await document.exitFullscreen();
setIsFullscreen(false);
}
} catch (err) {
logger.error(
'Fullscreen toggle failed:',
err instanceof Error ? err : { error: err },
);
}
}, []);
useEffect(() => {
const handleFullscreenChange = () => {
setIsFullscreen(Boolean(document.fullscreenElement));
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
return () =>
document.removeEventListener('fullscreenchange', handleFullscreenChange);
}, []);
const pageElements = useMemo(() => {
if (!selectedPage) return [];
try {
const uiSchema =
typeof selectedPage.ui_schema_json === 'string'
? JSON.parse(selectedPage.ui_schema_json)
: selectedPage.ui_schema_json;
return Array.isArray(uiSchema?.elements) ? uiSchema.elements : [];
} catch {
return [];
}
}, [selectedPage]);
// Set initial backgrounds when page first loads (before preload cache is populated)
// The condition ensures this only runs once on initial load when backgrounds are empty.
// After that, navigateToPage handles all subsequent navigation explicitly.
useEffect(() => {
if (selectedPage && lastInitializedPageIdRef.current !== selectedPage.id) {
// Only initialize when backgrounds are empty (initial load)
if (!navCurrentBgImageUrl && !navCurrentBgVideoUrl) {
lastInitializedPageIdRef.current = selectedPage.id;
navNavigateToPage(selectedPage);
}
}
}, [
selectedPage,
navCurrentBgImageUrl,
navCurrentBgVideoUrl,
navNavigateToPage,
]);
// Video transition overlay removal - clears when elements should show
// When phase becomes 'idle' or 'fading_in' (navShowElements=true),
// the transition preview is no longer needed and can be cleared
useEffect(() => {
if (navShowElements && transitionPreview) {
// Clear transition preview - overlay will be removed
setTransitionPreview(null);
}
}, [navShowElements, transitionPreview]);
const navigateToPage = useCallback(
async (
targetPageId: string,
transitionVideoUrl?: string,
isBack = false,
reverseVideoUrl?: string,
) => {
const targetPage = pages.find((p) => p.id === targetPageId);
if (!targetPage) return;
// Check if video is already cached (use video even on slow network if cached)
const isTransitionCached =
transitionVideoUrl &&
preloadOrchestrator?.getReadyBlobUrl(transitionVideoUrl);
// For back navigation, verify reversed video is available
// Without reversed video, back navigation should use CSS fade instead
const isBackWithoutReverse = isBack && !reverseVideoUrl;
// Use video if: has transition AND (cached OR good network) AND not back-without-reverse
const useVideoTransition =
transitionVideoUrl &&
(isTransitionCached || shouldUseVideoTransitions) &&
!isBackWithoutReverse;
if (useVideoTransition) {
// Reset states from previous transition/navigation
resetFadeIn();
// Pause background downloads to give transition video exclusive bandwidth
downloadManager.pauseAll();
// Signal navigation state machine that video transition is starting
// This sets phase to 'transitioning' so spinner shows during buffering
startTransition(targetPageId, isBack);
// Play transition using useTransitionPlayback hook
setTransitionPreview({
targetPageId,
videoUrl: resolveAssetPlaybackUrl(transitionVideoUrl),
storageKey: transitionVideoUrl, // Raw storage path for cache lookup
isBack,
reverseVideoUrl: reverseVideoUrl
? resolveAssetPlaybackUrl(reverseVideoUrl)
: undefined,
reverseStorageKey: reverseVideoUrl, // Raw storage path for reverse video cache lookup
});
} else {
// Direct navigation with fade-from-black effect:
// Page switches instantly, black overlay fades out to reveal new page
// Mark this page as initialized to prevent redundant effect calls
lastInitializedPageIdRef.current = targetPageId;
// Log when skipping video due to missing reversed video (back navigation)
if (isBackWithoutReverse && transitionVideoUrl) {
logger.info(
'[NAVIGATION] Skipping video transition for back navigation - reversed video not ready, using CSS fade',
{
transitionVideoUrl: transitionVideoUrl?.slice(-60),
isBack,
},
);
}
// Log when skipping video due to slow network
if (
transitionVideoUrl &&
!shouldUseVideoTransitions &&
!isBackWithoutReverse
) {
logger.info(
'[NAVIGATION] Skipping video transition due to slow network, downloading in background',
{
effectiveType: networkInfo.effectiveType,
downlink: networkInfo.downlink,
rtt: networkInfo.rtt,
},
);
// Start background download of transition video for future use (low priority)
downloadManager.addJob({
assetId: `transition-bg-${transitionVideoUrl}`,
projectId: 'transition-preload',
url: resolveAssetPlaybackUrl(transitionVideoUrl),
filename: transitionVideoUrl.split('/').pop() || 'transition.mp4',
variantType: 'original',
assetType: 'video',
priority: 10, // Low priority - background preload
storageKey: transitionVideoUrl,
});
}
await navNavigateToPage(targetPage, {
hasTransition: false,
isBack,
onSwitched: () => {
applyPageSelection(targetPageId, isBack);
},
});
}
},
[
pages,
navNavigateToPage,
resetFadeIn,
applyPageSelection,
startTransition,
shouldUseVideoTransitions,
networkInfo,
preloadOrchestrator,
],
);
// Page loading state from unified navigation state machine
// navShowSpinner: true when phase is 'preparing', 'loading_bg', or 'transition_done'
// navShowElements: true when phase is 'idle' or 'fading_in'
const areTransitionsReady = preloadOrchestrator?.areTransitionsReady ?? true;
const handleElementClick = useCallback(
(element: CanvasElement) => {
// Block navigation while transition is actively playing or buffering
if (
isTransitionBlocking(transitionPhase as TransitionPhase, isBuffering)
) {
return;
}
// Get navigation context from hook for history-based back navigation
const navContext = getNavigationContext();
// Use shared helper to resolve navigation target with history context
const navTarget = resolveNavigationTarget(element, pages, navContext);
// Debug: log element navigation data
logger.info('Element clicked', {
elementType: element.type,
targetPageSlug: element.targetPageSlug,
legacyTargetPageId: element.targetPageId,
resolvedTargetPageId: navTarget?.pageId,
transitionVideoUrl: element.transitionVideoUrl,
hasTransition: Boolean(element.transitionVideoUrl),
isBack: isBackNavigation(element),
previousPageId: navContext.previousPageId,
});
if (navTarget) {
// Extract element transition settings for CSS-based transitions
// For back navigation, use navTarget's settings (the forward element that brought us here)
// For forward navigation, use the clicked element's settings
const elementTransitionSource = isBackNavigation(element)
? navTarget
: element;
const elementSettings = extractElementTransitionSettings(
elementTransitionSource,
);
// Use flushSync to ensure state is updated synchronously before transition starts
// Without this, React's async state batching causes the transition to use OLD settings
flushSync(() => {
setCurrentElementTransitionSettings(elementSettings);
});
navigateToPage(
navTarget.pageId,
navTarget.transitionVideoUrl,
navTarget.isBack,
navTarget.reverseVideoUrl,
);
}
},
[
navigateToPage,
pages,
transitionPhase,
isBuffering,
getNavigationContext,
setCurrentElementTransitionSettings,
],
);
// Handler for gallery card clicks
const handleGalleryCardClick = useCallback(
(element: CanvasElement, cardIndex: number) => {
if (element.galleryCards?.length > 0) {
setActiveGalleryCarousel({ element, initialIndex: cardIndex });
}
},
[],
);
// URL resolver that uses preloaded blob URLs when available (instant display)
const resolveUrlWithBlob = useCallback(
(url: string | undefined): string => {
if (!url) return '';
// Try to get blob URL from preload orchestrator (instant display)
// Check storage key first (most reliable), then resolved URL
const blobUrl =
preloadOrchestrator?.getReadyBlobUrl(url) ||
preloadOrchestrator?.getReadyBlobUrl(resolveAssetPlaybackUrl(url));
if (blobUrl) return blobUrl;
// Fall back to standard resolution
return resolveAssetPlaybackUrl(url);
},
[preloadOrchestrator],
);
// Background URLs come directly from navigation state (already resolved)
const backgroundImageUrl = navCurrentBgImageUrl;
const backgroundVideoUrl = navCurrentBgVideoUrl;
// Background video playback settings from selected page
const videoAutoplay = selectedPage?.background_video_autoplay ?? true;
const videoLoop = selectedPage?.background_video_loop ?? true;
// Note: pageVideoMuted is the page setting, but we use soundControl.isMuted for actual muted state
// This allows iOS to autoplay (starts muted) while giving user control via sound button
const pageVideoMuted = selectedPage?.background_video_muted ?? true;
const videoStartTime =
selectedPage?.background_video_start_time != null
? parseFloat(String(selectedPage.background_video_start_time))
: null;
const videoEndTime =
selectedPage?.background_video_end_time != null
? parseFloat(String(selectedPage.background_video_end_time))
: null;
// Sound control hook for iOS autoplay compatibility
// Videos start muted (for iOS autoplay), user can unmute via sound button
const soundControl = useVideoSoundControl({
pageHasSound: pageVideoMuted === false, // Show button when page allows sound
hasBackgroundVideo: Boolean(backgroundVideoUrl),
videoUrl: backgroundVideoUrl, // Track video changes for page navigation reset
});
// Note: useBackgroundVideoPlayback is handled internally by CanvasBackground component
if (isLoading) {
return (
<>
<Head>
<meta name='theme-color' content='#000000' />
<meta name='color-scheme' content='dark' />
<style>{`html, body { background-color: #000000 !important; }`}</style>
</Head>
<div className='flex items-center justify-center min-h-screen bg-black'>
<div className='text-white text-xl'>Loading presentation...</div>
</div>
</>
);
}
if (error) {
return (
<>
<Head>
<meta name='theme-color' content='#000000' />
<meta name='color-scheme' content='dark' />
<style>{`html, body { background-color: #000000 !important; }`}</style>
</Head>
<div className='flex items-center justify-center min-h-screen bg-black'>
<CardBox className='max-w-md'>
<h2 className='text-xl font-bold text-red-500 mb-4'>Error</h2>
<p className='text-gray-300'>{error}</p>
</CardBox>
</div>
</>
);
}
return (
<>
{/* Rotate prompt for portrait orientation */}
<RotatePrompt show={showRotatePrompt && isPortrait} />
<Head>
<title>{project?.name || 'Presentation'}</title>
{/* Dark theme for browser UI and background */}
<meta name='theme-color' content='#000000' />
<meta name='color-scheme' content='dark' />
<meta
name='apple-mobile-web-app-status-bar-style'
content='black-translucent'
/>
<style>{`
html, body { background-color: #000000 !important; }
`}</style>
{faviconUrl && <link key='favicon' rel='icon' href={faviconUrl} />}
{ogImageUrl && (
<>
<meta key='og:image' property='og:image' content={ogImageUrl} />
<meta
key='twitter:image:src'
property='twitter:image:src'
content={ogImageUrl}
/>
</>
)}
{project?.name && (
<>
<meta key='og:title' property='og:title' content={project.name} />
<meta
key='twitter:title'
property='twitter:title'
content={project.name}
/>
</>
)}
{project?.description && (
<>
<meta
key='og:description'
property='og:description'
content={project.description}
/>
<meta
key='twitter:description'
property='twitter:description'
content={project.description}
/>
</>
)}
</Head>
{/* Outer container: full viewport with black background for letterbox bars */}
<div className='relative w-screen h-screen overflow-hidden bg-black'>
{/* Inner canvas: maintains aspect ratio centered in viewport.
z-[46] creates stacking context above carousel (z-10 bg, z-45 controls) portaled to body. */}
<div
className='relative z-[46] overflow-hidden'
style={{
...cssVars,
...letterboxStyles,
}}
>
<BackdropPortalProvider>
{/* Safari Black Flash Prevention (video transitions only):
Persistent snapshot layer shown ONLY during video transitions.
z-[1] keeps it behind backgrounds (z-5) but above the black container. */}
{lastKnownBgUrl &&
isSafari() &&
(transitionPreview || pendingTransitionComplete) && (
<div
className='absolute inset-0 z-[1] pointer-events-none'
style={{
backgroundImage: `url("${lastKnownBgUrl}")`,
backgroundSize: 'contain',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
}}
/>
)}
{/* Page background wrapper - z-5 keeps it BELOW carousel slide (z-10).
Uses shared CanvasBackground component for single source of truth with constructor.
Previous background overlay shows during loading.
Black overlay for fade effect is rendered separately at z-[100]. */}
<div
data-testid='page-background-wrapper'
className='absolute inset-0 z-5'
>
<CanvasBackground
backgroundImageUrl={backgroundImageUrl}
backgroundVideoUrl={backgroundVideoUrl}
previousBgImageUrl={navPreviousBgImageUrl}
previousBgVideoUrl={navPreviousBgVideoUrl}
isSwitching={navIsSwitching}
isNewBgReady={navIsNewBgReady}
onBackgroundReady={navOnBackgroundReady}
onVideoBufferStateChange={onVideoBufferStateChange}
videoAutoplay={videoAutoplay}
videoLoop={videoLoop}
videoMuted={soundControl.isMuted}
videoStartTime={videoStartTime}
videoEndTime={videoEndTime}
videoStoragePath={selectedPage?.background_video_url}
pauseVideo={
Boolean(transitionPreview) ||
pendingTransitionComplete ||
navIsSwitching
}
/>
</div>
{/* End page background wrapper */}
{/* Page loading spinner - from unified navigation state machine.
navShowSpinner is true when:
- Phase is 'preparing', 'loading_bg', 'transition_done', OR
- Video transition is active but buffering
Skip when video transition overlay is active - it has its own spinner. */}
{navShowSpinner && !transitionPreview && (
<CanvasLoadingSpinner isVisible={true} zIndex={100} />
)}
{/* Page elements wrapper - z-[46] keeps it ABOVE carousel slide (z-10) AND carousel controls (z-45).
UI controls (z-50) remain on top.
No fade animation - elements switch instantly behind the black overlay.
Shows when phase is 'idle' or 'fading_in' (navShowElements). */}
{navShowElements && (
<div
data-testid='page-elements-wrapper'
className='absolute inset-0 z-[46]'
>
{pageElements.map((element: CanvasElement) => (
<RuntimeElement
key={element.id}
element={element}
onClick={() => handleElementClick(element)}
resolveUrl={resolveUrlWithBlob}
onGalleryCardClick={(cardIndex) =>
handleGalleryCardClick(element, cardIndex)
}
letterboxStyles={letterboxStyles}
pageTransitionSettings={transitionSettings}
preloadCache={
preloadOrchestrator
? {
getReadyBlob: preloadOrchestrator.getReadyBlob,
getCachedBlobUrl:
preloadOrchestrator.getCachedBlobUrl,
}
: undefined
}
/>
))}
</div>
)}
{/* End page elements wrapper */}
{/* Overlay for fade-through-color transition - z-[100] is ABOVE elements (z-[46]).
This covers the elements during page transition to hide the instant switch.
Only rendered for 'fade' type. */}
<TransitionBlackOverlay
isFadingIn={isFadingIn}
transitionType={transitionSettings.type}
transitionStyle={transitionStyle}
overlayColor={transitionSettings.overlayColor}
/>
{/* Transition overlay - uses useTransitionPlayback hook for smooth transitions */}
{/* Opacity is 0 during 'preparing' phase to show old page while video loads */}
{/* Fades out during 'fading_in' phase when background is ready */}
{/* Overlay stays visible until fade completes (phase goes to 'idle') */}
{transitionPreview && showTransitionVideo && (
<TransitionPreviewOverlay
videoKey={transitionPreview.videoUrl}
videoRef={transitionVideoRef}
isActive={true}
isBuffering={
// Show spinner during buffering:
// - 'idle': React render cycle before hook effect runs
// - 'preparing': Video loading/buffering
// - isBuffering: Waiting for first frame or mid-playback buffering
transitionPhase === 'idle' ||
transitionPhase === 'preparing' ||
isBuffering
}
isVideoReady={isVideoReady}
showSpinner={true}
letterboxStyles={letterboxStyles}
isFadingOut={isFadingIn}
fadeOutDuration={transitionSettings.durationMs}
/>
)}
{/* Gallery Carousel Overlay */}
{activeGalleryCarousel && (
<GalleryCarouselOverlay
cards={activeGalleryCarousel.element.galleryCards || []}
initialIndex={activeGalleryCarousel.initialIndex}
onClose={() => setActiveGalleryCarousel(null)}
resolveUrl={resolveUrlWithBlob}
prevIconUrl={
activeGalleryCarousel.element.galleryCarouselPrevIconUrl
}
nextIconUrl={
activeGalleryCarousel.element.galleryCarouselNextIconUrl
}
backIconUrl={
activeGalleryCarousel.element.galleryCarouselBackIconUrl
}
backLabel={
activeGalleryCarousel.element.galleryCarouselBackLabel ||
'BACK'
}
prevX={activeGalleryCarousel.element.galleryCarouselPrevX}
prevY={activeGalleryCarousel.element.galleryCarouselPrevY}
nextX={activeGalleryCarousel.element.galleryCarouselNextX}
nextY={activeGalleryCarousel.element.galleryCarouselNextY}
backX={activeGalleryCarousel.element.galleryCarouselBackX}
backY={activeGalleryCarousel.element.galleryCarouselBackY}
prevWidth={
activeGalleryCarousel.element.galleryCarouselPrevWidth
}
prevHeight={
activeGalleryCarousel.element.galleryCarouselPrevHeight
}
nextWidth={
activeGalleryCarousel.element.galleryCarouselNextWidth
}
nextHeight={
activeGalleryCarousel.element.galleryCarouselNextHeight
}
backWidth={
activeGalleryCarousel.element.galleryCarouselBackWidth
}
backHeight={
activeGalleryCarousel.element.galleryCarouselBackHeight
}
letterboxStyles={letterboxStyles}
isEditMode={false}
pageTransitionSettings={transitionSettings}
galleryElement={activeGalleryCarousel.element}
/>
)}
</BackdropPortalProvider>
</div>
{/* End inner canvas container */}
{/* Controls: Offline toggle, Fullscreen, and Sound buttons */}
{/* Positioned outside canvas to avoid scaling with canvas transform */}
<RuntimeControls
projectId={project?.id || null}
projectSlug={projectSlug}
projectName={project?.name}
pages={pages}
isFullscreen={isFullscreen}
toggleFullscreen={toggleFullscreen}
canvasWidth={canvasWidth}
canvasHeight={canvasHeight}
showSoundButton={soundControl.showSoundButton}
isMuted={soundControl.isMuted}
onSoundToggle={soundControl.toggleSound}
/>
{/* Toast notifications for offline download status */}
<ToastContainer
position='bottom-center'
autoClose={5000}
hideProgressBar={false}
newestOnTop={false}
closeOnClick
rtl={false}
pauseOnFocusLoss
draggable
pauseOnHover
theme='dark'
/>
</div>
</>
);
}
// Layout wrapper for standalone usage
RuntimePresentation.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};