/** * 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 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 { usePageSwitch } from '../hooks/usePageSwitch'; import { useTransitionPlayback } from '../hooks/useTransitionPlayback'; import { useBackgroundTransition } from '../hooks/useBackgroundTransition'; import { useBackgroundUrls } from '../hooks/useBackgroundUrls'; import { resolveAssetPlaybackUrl } from '../lib/assetUrl'; import { isSafari, scheduleAfterPaint } 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 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, }); const [transitionPreview, setTransitionPreview] = useState<{ targetPageId: string; videoUrl: string; storageKey: string; isBack: boolean; reverseVideoUrl?: string; } | null>(null); const [isFullscreen, setIsFullscreen] = useState(false); const [isBackgroundReady, setIsBackgroundReady] = useState(true); // Track when transition video has completed but we're waiting for background to load const [pendingTransitionComplete, setPendingTransitionComplete] = useState(false); const [activeGalleryCarousel, setActiveGalleryCarousel] = useState<{ element: CanvasElement; initialIndex: number; } | null>(null); // Safari Black Flash Prevention (video transitions only): // Track the last successfully displayed background to use as a "snapshot" layer. // Only shown during video transitions to prevent black flashes. const [lastKnownBgUrl, setLastKnownBgUrl] = useState(''); 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 const preloadOrchestrator = usePreloadOrchestrator({ pages, pageLinks, elements: preloadElements, currentPageId: selectedPageId, pageHistory, enabled: !isLoading && !error, }); // Initialize page switch hook for smooth background transitions const pageSwitch = usePageSwitch({ preloadCache: preloadOrchestrator ? { getReadyBlobUrl: preloadOrchestrator.getReadyBlobUrl, getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl, preloadedUrls: preloadOrchestrator.preloadedUrls, } : undefined, }); // Integrate useTransitionPlayback hook for smooth transitions (matches Constructor pattern) const { isBuffering, phase: transitionPhase } = useTransitionPlayback({ videoRef: transitionVideoRef, transition: transitionPreview ? { videoUrl: transitionPreview.videoUrl, storageKey: transitionPreview.storageKey, reverseMode: transitionPreview.reverseVideoUrl ? 'separate' : 'none', reverseVideoUrl: transitionPreview.reverseVideoUrl, targetPageId: transitionPreview.targetPageId, displayName: 'Transition', isBack: transitionPreview.isBack, } : null, onComplete: async (targetPageId, isBack) => { if (targetPageId) { const targetPage = pages.find((p) => p.id === targetPageId); // Mark this page as initialized to prevent redundant effect calls lastInitializedPageIdRef.current = targetPageId; // Use shared hook to resolve blob URLs and switch page await pageSwitch.switchToPage(targetPage, () => { // Use applyPageSelection for proper history management (pops on back) applyPageSelection(targetPageId, isBack ?? false); }); setIsBackgroundReady(false); // Video transition completed - last frame shows new page background // Signal that we're waiting for background to load before removing overlay // Overlay will be removed instantly (no fade) when isBackgroundReady becomes true setPendingTransitionComplete(true); } else { // No target page - clean up and remove overlay const video = transitionVideoRef.current; video?.removeAttribute('src'); video?.load(); setTransitionPreview(null); setPendingTransitionComplete(false); } }, 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, }, }); // Use shared background transition hook for fade-from-black effects // Video transitions end instantly (last frame = new page, then overlay removed). // fadeIn controls the black overlay for non-video navigation. // hasActiveTransition prevents fade during video-to-background handoff. const { isFadingIn, resetFadeIn, transitionStyle } = useBackgroundTransition({ pageSwitch, fadeIn: { hasActiveTransition: Boolean(transitionPreview) || pendingTransitionComplete, }, transitionSettings, }); 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 selectedPage = useMemo( () => pages.find((p) => p.id === selectedPageId) || null, [pages, selectedPageId], ); 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) // navigateToPage handles subsequent navigation by calling switchToPage directly if (!pageSwitch.currentBgImageUrl && !pageSwitch.currentBgVideoUrl) { lastInitializedPageIdRef.current = selectedPage.id; pageSwitch.switchToPage(selectedPage); } } }, [ selectedPage, pageSwitch.currentBgImageUrl, pageSwitch.currentBgVideoUrl, pageSwitch.switchToPage, ]); // Handle background ready state for pages without any background useEffect(() => { // Only mark ready immediately if there's no background media at all. // For pages with image or video, CanvasBackground will call onBackgroundReady // after the media's first frame is painted (using scheduleAfterPaint or requestVideoFrameCallback). if ( !selectedPage?.background_image_url && !selectedPage?.background_video_url ) { setIsBackgroundReady(true); } }, [selectedPage?.background_image_url, selectedPage?.background_video_url]); // Video transition overlay removal - instant (no fade) when background is ready // Uses scheduleAfterPaint to ensure browser has painted the new background before removing overlay // Note: Double RAF doesn't work in Safari (nested RAFs can execute in same frame) // CRITICAL: Must wait for BOTH isBackgroundReady AND pageSwitch.isNewBgReady // - isBackgroundReady: RuntimePresentation state (image onLoad or immediate for video pages) // - pageSwitch.isNewBgReady: Controls PreviousBackgroundOverlay visibility // If we only check isBackgroundReady, PreviousBackgroundOverlay may still be showing useEffect(() => { if ( pendingTransitionComplete && isBackgroundReady && pageSwitch.isNewBgReady ) { // Wait for paint cycle to complete before removing overlay // scheduleAfterPaint handles Safari's RAF quirks automatically scheduleAfterPaint(() => { // CRITICAL: Remove overlay from DOM FIRST, then clear video src // If we clear src before removing overlay, Safari shows black frame // because video.removeAttribute('src') immediately clears the frame setTransitionPreview(null); setPendingTransitionComplete(false); // Clear previous background now that transition is complete // This resets isSwitching state for next navigation pageSwitch.clearPreviousBackground(); // Clear video src AFTER overlay is removed from DOM // Use another scheduleAfterPaint to ensure React has unmounted the overlay scheduleAfterPaint(() => { const video = transitionVideoRef.current; if (video) { video.removeAttribute('src'); video.load(); } }); }); } }, [ pendingTransitionComplete, isBackgroundReady, pageSwitch.isNewBgReady, pageSwitch.clearPreviousBackground, ]); // Safari Black Flash Prevention (video transitions only): // Update lastKnownBgUrl whenever we have a valid background image. // This ensures snapshot is always ready before transitions start. useEffect(() => { if (pageSwitch.currentBgImageUrl) { setLastKnownBgUrl(pageSwitch.currentBgImageUrl); } }, [pageSwitch.currentBgImageUrl]); const navigateToPage = useCallback( async ( targetPageId: string, transitionVideoUrl?: string, isBack = false, reverseVideoUrl?: string, ) => { const targetPage = pages.find((p) => p.id === targetPageId); if (!targetPage) return; if (transitionVideoUrl) { // Reset states from previous transition/navigation resetFadeIn(); setPendingTransitionComplete(false); // Play transition using useTransitionPlayback hook setTransitionPreview({ targetPageId, videoUrl: resolveAssetPlaybackUrl(transitionVideoUrl), storageKey: transitionVideoUrl, // Raw storage path for cache lookup isBack, reverseVideoUrl: reverseVideoUrl ? resolveAssetPlaybackUrl(reverseVideoUrl) : undefined, }); } else { // Direct navigation with fade-from-black effect: // Page switches instantly, black overlay fades out to reveal new page setIsBackgroundReady(false); // Mark this page as initialized to prevent redundant effect calls lastInitializedPageIdRef.current = targetPageId; await pageSwitch.switchToPage(targetPage, () => { // Use applyPageSelection for proper history management (pops on back) applyPageSelection(targetPageId, isBack); }); } }, [pages, pageSwitch, resetFadeIn, applyPageSelection], ); // Compute whether all neighbor backgrounds are ready for instant navigation const areNeighborBackgroundsReady = preloadOrchestrator?.areNeighborBackgroundsReady ?? true; // Compute disabled state for forward navigation elements // DISABLED: Allow navigation even if neighbors not preloaded const isForwardNavDisabled = false && !areNeighborBackgroundsReady; const handleElementClick = useCallback( (element: CanvasElement) => { // Block navigation while transition is actively playing or buffering if ( isTransitionBlocking(transitionPhase as TransitionPhase, isBuffering) ) { return; } // DISABLED: Block forward navigation if neighbor backgrounds not yet preloaded // Back navigation is always allowed (previous pages are already visited) if ( false && isNavigationType(element.type) && !isBackNavigation(element) && !areNeighborBackgroundsReady ) { logger.info('Navigation blocked - neighbors not preloaded'); 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, areNeighborBackgroundsReady, 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], ); // Unified background URL resolution via shared hook (same as constructor) // No localPaths needed since RuntimePresentation has no editing mode const { backgroundImageSrc: backgroundImageUrl, backgroundVideoSrc: backgroundVideoUrl, } = useBackgroundUrls({ pageSwitch, resolveUrl: resolveUrlWithBlob, }); // 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 ( <>
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 */}
{/* 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]. */}
{ setIsBackgroundReady(true); pageSwitch.markBackgroundReady(); }} videoAutoplay={videoAutoplay} videoLoop={videoLoop} videoMuted={soundControl.isMuted} videoStartTime={videoStartTime} videoEndTime={videoEndTime} videoStoragePath={selectedPage?.background_video_url} />
{/* End page background wrapper */} {/* 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. */}
{pageElements.map((element: CanvasElement) => ( handleElementClick(element)} resolveUrl={resolveUrlWithBlob} onGalleryCardClick={(cardIndex) => handleGalleryCardClick(element, cardIndex) } letterboxStyles={letterboxStyles} isForwardNavDisabled={isForwardNavDisabled} /> ))}
{/* 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 */} {/* NO fade-out: video itself IS the transition (last frame = new page) */} {/* Overlay removed instantly via setTransitionPreview(null) in onComplete */} {transitionPreview && ( )} {/* 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} /> )}
{/* End inner canvas container */} {/* Controls: Offline toggle, Fullscreen, and Sound buttons */} {/* Positioned outside canvas to avoid scaling with canvas transform */} {/* Toast notifications for offline download status */}
); } // Layout wrapper for standalone usage RuntimePresentation.getLayout = function getLayout(page: ReactElement) { return {page}; };