39948-vm/frontend/src/components/RuntimePresentation.tsx
Dmitri 6413c7bdf0 implemented:
- three destinations for info panel thumbnails : the panel image preview, new page, external page (URL). Two destinations for other elements (other page, external URL)
- toggle for info panel media opening (fullscreen or in the panel)
- ability to replace background with info panel media (image, video, 360 panorama)
- ability to make 360 panorama as page background
- global mute button
-
2026-06-15 07:50:45 +02:00

1276 lines
47 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 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<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 [activeInfoPanel, setActiveInfoPanel] = useState<CanvasElement | null>(
null,
);
const [activeDetailImage, setActiveDetailImage] =
useState<InfoPanelImage | null>(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<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,
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<GalleryCarouselMediaItem | null>((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 (
<>
<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 */}
{/* onClick/onTouchEnd: Notify audio controller of user interaction for autoplay unlock */}
<div
className='relative w-screen h-screen overflow-hidden bg-black'
onClick={handleCanvasInteraction}
onTouchEnd={handleCanvasInteraction}
>
{/* 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}
backgroundEmbedUrl={backgroundEmbedUrl}
backgroundAudioUrl={backgroundAudioUrl}
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
}
audioLoop={audioLoop}
audioStartTime={audioStartTime}
audioEndTime={audioEndTime}
audioStoragePath={selectedPage?.background_audio_url}
/>
</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='pointer-events-none 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
}
isInfoPanelOpen={activeInfoPanel?.id === element.id}
/>
))}
</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}
/>
)}
{activeInfoPanelGallery && (
<GalleryCarouselOverlay
cards={activeInfoPanelGallery.items}
initialIndex={activeInfoPanelGallery.initialIndex}
onClose={() => 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 && (
<>
<InfoPanelOverlay
element={
runtimeSelectedImageId
? {
...activeInfoPanel,
infoPanelSelectedImageId: runtimeSelectedImageId,
}
: activeInfoPanel
}
onClose={() => {
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 && (
<ImageDetailPanel
element={activeInfoPanel}
image={activeDetailImage}
onClose={() => setActiveDetailImage(null)}
resolveUrl={resolveUrlWithBlob}
letterboxStyles={letterboxStyles}
cssVars={cssVars}
/>
)}
</>
)}
</BackdropPortalProvider>
</div>
{/* End inner canvas container */}
{/* Controls: Offline toggle, Fullscreen, and Sound buttons */}
{/* Positioned outside canvas to avoid scaling with canvas transform */}
{!activeGalleryCarousel && !activeInfoPanelGallery && (
<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>;
};