/** * 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 InfoPanelOverlay from './UiElements/InfoPanelOverlay'; import ImageDetailPanel from './UiElements/ImageDetailPanel'; 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 { backgroundAudioController } from '../lib/backgroundAudioController'; 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, GalleryCarouselMediaItem, InfoPanelImage, } from '../types/constructor'; import { isInfoPanelElementType } from '../lib/elementDefaults'; 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(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 [activeInfoPanel, setActiveInfoPanel] = useState( null, ); const [activeDetailImage, setActiveDetailImage] = useState(null); const [activeInfoPanelGallery, setActiveInfoPanelGallery] = useState<{ items: GalleryCarouselMediaItem[]; initialIndex: number; } | null>(null); // Track selected image in media section (runtime-only local state) const [runtimeSelectedImageId, setRuntimeSelectedImageId] = useState< string | null >(null); const transitionVideoRef = useRef(null); const lastInitializedPageIdRef = useRef(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(); 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, currentEmbedUrl: navCurrentBgEmbedUrl, currentAudioUrl: navCurrentBgAudioUrl, previousImageUrl: navPreviousBgImageUrl, previousVideoUrl: navPreviousBgVideoUrl, isSwitching: navIsSwitching, isNewBgReady: navIsNewBgReady, pendingTransitionComplete, isFadingIn, showElements: navShowElements, showSpinner: navShowSpinner, showTransitionVideo, transitionStyle, lastKnownBgUrl, onBackgroundReady: navOnBackgroundReady, onVideoBufferStateChange, onTransitionEnded, navigateToPage: navNavigateToPage, setBackgroundDirectly: navSetBackgroundDirectly, 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]); // Handle first user interaction for background audio unlock // CRITICAL: Use touchEnd, NOT touchStart - iOS Safari only unlocks audio // when finger is LIFTED from screen (touchend), not when touched (touchstart) const handleCanvasInteraction = useCallback(() => { backgroundAudioController.notifyUserInteraction(); }, []); 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 && !navCurrentBgEmbedUrl ) { lastInitializedPageIdRef.current = selectedPage.id; navNavigateToPage(selectedPage); } } }, [ selectedPage, navCurrentBgImageUrl, navCurrentBgVideoUrl, navCurrentBgEmbedUrl, 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, ], ); const handleInfoPanelNavigateToPage = useCallback( (targetPageSlug: string) => { if ( isTransitionBlocking(transitionPhase as TransitionPhase, isBuffering) ) { return; } const targetPage = pages.find((page) => page.slug === targetPageSlug); if (!targetPage) return; setActiveInfoPanel(null); setActiveDetailImage(null); setActiveInfoPanelGallery(null); setRuntimeSelectedImageId(null); navigateToPage(targetPage.id); }, [navigateToPage, pages, transitionPhase, isBuffering], ); const handleInfoPanelOpenExternalUrl = useCallback((url: string) => { const trimmed = url.trim(); if (!trimmed) return; const href = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; window.open(href, '_blank', 'noopener,noreferrer'); }, []); const handleInfoPanelUseAsBackground = useCallback( (item: InfoPanelImage) => { const mediaType = item.itemType === 'video' ? 'video' : item.itemType === '360' ? '360' : 'image'; if ( (mediaType === 'image' && !item.imageUrl) || (mediaType === 'video' && !item.videoUrl) || (mediaType === '360' && !item.embedUrl) ) { return; } navSetBackgroundDirectly( mediaType === 'image' && item.imageUrl ? resolveAssetPlaybackUrl(item.imageUrl) : '', mediaType === 'video' && item.videoUrl ? resolveAssetPlaybackUrl(item.videoUrl) : '', mediaType === '360' && item.embedUrl ? resolveAssetPlaybackUrl(item.embedUrl) : '', navCurrentBgAudioUrl, ); setActiveDetailImage(null); setActiveInfoPanelGallery(null); }, [navCurrentBgAudioUrl, navSetBackgroundDirectly], ); const handleInfoPanelOpenGallery = useCallback( (items: InfoPanelImage[], initialIndex: number) => { const activeItemId = items[initialIndex]?.id; const galleryItems = items .map((item) => { const mediaType = item.itemType === 'video' ? 'video' : item.itemType === '360' ? '360' : 'image'; if (mediaType === 'image' && !item.imageUrl) return null; if (mediaType === 'video' && !item.videoUrl) return null; if (mediaType === '360' && !item.embedUrl) return null; return { id: item.id, imageUrl: item.imageUrl, videoUrl: item.videoUrl, embedUrl: item.embedUrl, caption: item.caption, title: item.caption, mediaType, }; }) .filter((item): item is GalleryCarouselMediaItem => Boolean(item)); if (galleryItems.length === 0) return; setActiveDetailImage(null); setActiveInfoPanelGallery({ items: galleryItems, initialIndex: Math.max( 0, galleryItems.findIndex((item) => item.id === activeItemId), ), }); }, [], ); // 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) => { // Handle info panel click if (isInfoPanelElementType(element.type)) { setActiveInfoPanel(element); setActiveDetailImage(null); return; } // 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; const backgroundEmbedUrl = navCurrentBgEmbedUrl; const backgroundAudioUrl = navCurrentBgAudioUrl; // 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; // Background audio playback settings from selected page const audioLoop = selectedPage?.background_audio_loop ?? true; const audioStartTime = selectedPage?.background_audio_start_time != null ? parseFloat(String(selectedPage.background_audio_start_time)) : null; const audioEndTime = selectedPage?.background_audio_end_time != null ? parseFloat(String(selectedPage.background_audio_end_time)) : null; const hasElementAudio = useMemo( () => pageElements.some((element) => { if (element.hoverAudioUrl || element.clickAudioUrl) return true; if ( (element.type === 'audio_player' || element.type === 'video_player') && element.mediaUrl && !element.mediaMuted ) { return true; } if ( element.galleryCards?.some( (card: GalleryCarouselMediaItem) => card.mediaType === 'video' || Boolean(card.videoUrl), ) ) { return true; } if ( element.infoPanelSections?.some((section) => section.images?.some( (item: InfoPanelImage) => item.itemType === 'video' || Boolean(item.videoUrl), ), ) ) { return true; } return false; }), [pageElements], ); // Global sound control starts muted for browser autoplay compatibility. const soundControl = useVideoSoundControl({ pageHasSound: pageVideoMuted === false, hasBackgroundVideo: Boolean(backgroundVideoUrl), hasBackgroundAudio: Boolean(backgroundAudioUrl), hasElementAudio, }); // Note: useBackgroundVideoPlayback is handled internally by CanvasBackground component if (isLoading) { return ( <>
Loading presentation...
); } if (error) { return ( <>

Error

{error}

); } return ( <> {/* Rotate prompt for portrait orientation */} {project?.name || 'Presentation'} {/* Dark theme for browser UI and background */} {faviconUrl && } {ogImageUrl && ( <> )} {project?.name && ( <> )} {project?.description && ( <> )} {/* Outer container: full viewport with black background for letterbox bars */} {/* onClick/onTouchEnd: Notify audio controller of user interaction for autoplay unlock */}
{/* Inner canvas: maintains aspect ratio centered in viewport. z-[46] creates stacking context above carousel (z-10 bg, z-45 controls) portaled to body. */}
{/* 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) && (
)} {/* 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]. */}
{/* 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 && ( )} {/* 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 && (
{pageElements.map((element: CanvasElement) => ( handleElementClick(element)} resolveUrl={resolveUrlWithBlob} onGalleryCardClick={(cardIndex) => handleGalleryCardClick(element, cardIndex) } letterboxStyles={letterboxStyles} pageTransitionSettings={transitionSettings} preloadCache={ preloadOrchestrator ? { getReadyBlob: preloadOrchestrator.getReadyBlob, getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl, } : undefined } isInfoPanelOpen={activeInfoPanel?.id === element.id} /> ))}
)} {/* 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. */} {/* 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 && ( )} {/* Gallery Carousel Overlay */} {activeGalleryCarousel && ( 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} /> )} {activeInfoPanelGallery && ( setActiveInfoPanelGallery(null)} resolveUrl={resolveUrlWithBlob} prevIconUrl={activeInfoPanel?.galleryCarouselPrevIconUrl} nextIconUrl={activeInfoPanel?.galleryCarouselNextIconUrl} backIconUrl={activeInfoPanel?.galleryCarouselBackIconUrl} backLabel={activeInfoPanel?.galleryCarouselBackLabel || 'BACK'} prevX={activeInfoPanel?.galleryCarouselPrevX} prevY={activeInfoPanel?.galleryCarouselPrevY} nextX={activeInfoPanel?.galleryCarouselNextX} nextY={activeInfoPanel?.galleryCarouselNextY} backX={activeInfoPanel?.galleryCarouselBackX} backY={activeInfoPanel?.galleryCarouselBackY} prevWidth={activeInfoPanel?.galleryCarouselPrevWidth} prevHeight={activeInfoPanel?.galleryCarouselPrevHeight} nextWidth={activeInfoPanel?.galleryCarouselNextWidth} nextHeight={activeInfoPanel?.galleryCarouselNextHeight} backWidth={activeInfoPanel?.galleryCarouselBackWidth} backHeight={activeInfoPanel?.galleryCarouselBackHeight} letterboxStyles={letterboxStyles} isEditMode={false} pageTransitionSettings={transitionSettings} galleryElement={activeInfoPanel || undefined} /> )} {/* Info Panel Overlay */} {activeInfoPanel && ( <> { setActiveInfoPanel(null); setActiveDetailImage(null); setActiveInfoPanelGallery(null); setRuntimeSelectedImageId(null); }} resolveUrl={resolveUrlWithBlob} letterboxStyles={letterboxStyles} cssVars={cssVars} onImageClick={(image) => setActiveDetailImage(image)} onOpenGallery={handleInfoPanelOpenGallery} onUseAsBackground={handleInfoPanelUseAsBackground} onSelectImage={(imageId) => setRuntimeSelectedImageId(imageId) } onNavigateToPage={handleInfoPanelNavigateToPage} onOpenExternalUrl={handleInfoPanelOpenExternalUrl} active360ItemId={ activeDetailImage?.itemType === '360' ? activeDetailImage.id : null } /> {activeDetailImage && ( setActiveDetailImage(null)} resolveUrl={resolveUrlWithBlob} letterboxStyles={letterboxStyles} cssVars={cssVars} /> )} )}
{/* End inner canvas container */} {/* Controls: Offline toggle, Fullscreen, and Sound buttons */} {/* Positioned outside canvas to avoid scaling with canvas transform */} {!activeGalleryCarousel && !activeInfoPanelGallery && ( )} {/* Toast notifications for offline download status */}
); } // Layout wrapper for standalone usage RuntimePresentation.getLayout = function getLayout(page: ReactElement) { return {page}; };