- 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 -
1276 lines
47 KiB
TypeScript
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>;
|
|
};
|