926 lines
35 KiB
TypeScript
926 lines
35 KiB
TypeScript
/**
|
|
* RuntimePresentation Component
|
|
*
|
|
* Renders a presentation for a specific project and environment.
|
|
* Used by /p/[projectSlug] and /p/[projectSlug]/stage routes.
|
|
*/
|
|
|
|
import Head from 'next/head';
|
|
import React, {
|
|
ReactElement,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
import { flushSync } from 'react-dom';
|
|
import { ToastContainer } from 'react-toastify';
|
|
import 'react-toastify/dist/ReactToastify.css';
|
|
import CardBox from './CardBox';
|
|
import RuntimeControls from './Runtime/RuntimeControls';
|
|
import RuntimeElement from './RuntimeElement';
|
|
import TransitionPreviewOverlay from './Constructor/TransitionPreviewOverlay';
|
|
import GalleryCarouselOverlay from './UiElements/GalleryCarouselOverlay';
|
|
import { BackdropPortalProvider } from './BackdropPortal';
|
|
import { RotatePrompt } from './RotatePrompt';
|
|
import CanvasBackground from './Constructor/CanvasBackground';
|
|
import 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<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,
|
|
});
|
|
|
|
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<string>('');
|
|
|
|
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
|
|
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 (
|
|
<>
|
|
<Head>
|
|
<meta name='theme-color' content='#000000' />
|
|
<meta name='color-scheme' content='dark' />
|
|
<style>{`html, body { background-color: #000000 !important; }`}</style>
|
|
</Head>
|
|
<div className='flex items-center justify-center min-h-screen bg-black'>
|
|
<div className='text-white text-xl'>Loading presentation...</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<>
|
|
<Head>
|
|
<meta name='theme-color' content='#000000' />
|
|
<meta name='color-scheme' content='dark' />
|
|
<style>{`html, body { background-color: #000000 !important; }`}</style>
|
|
</Head>
|
|
<div className='flex items-center justify-center min-h-screen bg-black'>
|
|
<CardBox className='max-w-md'>
|
|
<h2 className='text-xl font-bold text-red-500 mb-4'>Error</h2>
|
|
<p className='text-gray-300'>{error}</p>
|
|
</CardBox>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{/* Rotate prompt for portrait orientation */}
|
|
<RotatePrompt show={showRotatePrompt && isPortrait} />
|
|
|
|
<Head>
|
|
<title>{project?.name || 'Presentation'}</title>
|
|
{/* Dark theme for browser UI and background */}
|
|
<meta name='theme-color' content='#000000' />
|
|
<meta name='color-scheme' content='dark' />
|
|
<meta
|
|
name='apple-mobile-web-app-status-bar-style'
|
|
content='black-translucent'
|
|
/>
|
|
<style>{`
|
|
html, body { background-color: #000000 !important; }
|
|
`}</style>
|
|
{faviconUrl && <link key='favicon' rel='icon' href={faviconUrl} />}
|
|
{ogImageUrl && (
|
|
<>
|
|
<meta key='og:image' property='og:image' content={ogImageUrl} />
|
|
<meta
|
|
key='twitter:image:src'
|
|
property='twitter:image:src'
|
|
content={ogImageUrl}
|
|
/>
|
|
</>
|
|
)}
|
|
{project?.name && (
|
|
<>
|
|
<meta key='og:title' property='og:title' content={project.name} />
|
|
<meta
|
|
key='twitter:title'
|
|
property='twitter:title'
|
|
content={project.name}
|
|
/>
|
|
</>
|
|
)}
|
|
{project?.description && (
|
|
<>
|
|
<meta
|
|
key='og:description'
|
|
property='og:description'
|
|
content={project.description}
|
|
/>
|
|
<meta
|
|
key='twitter:description'
|
|
property='twitter:description'
|
|
content={project.description}
|
|
/>
|
|
</>
|
|
)}
|
|
</Head>
|
|
|
|
{/* Outer container: full viewport with black background for letterbox bars */}
|
|
<div className='relative w-screen h-screen overflow-hidden bg-black'>
|
|
{/* Inner canvas: maintains aspect ratio centered in viewport.
|
|
z-[46] creates stacking context above carousel (z-10 bg, z-45 controls) portaled to body. */}
|
|
<div
|
|
className='relative z-[46] overflow-hidden'
|
|
style={{
|
|
...cssVars,
|
|
...letterboxStyles,
|
|
}}
|
|
>
|
|
<BackdropPortalProvider>
|
|
{/* Safari Black Flash Prevention (video transitions only):
|
|
Persistent snapshot layer shown ONLY during video transitions.
|
|
z-[1] keeps it behind backgrounds (z-5) but above the black container. */}
|
|
{lastKnownBgUrl &&
|
|
isSafari() &&
|
|
(transitionPreview || pendingTransitionComplete) && (
|
|
<div
|
|
className='absolute inset-0 z-[1] pointer-events-none'
|
|
style={{
|
|
backgroundImage: `url("${lastKnownBgUrl}")`,
|
|
backgroundSize: 'contain',
|
|
backgroundPosition: 'center',
|
|
backgroundRepeat: 'no-repeat',
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Page background wrapper - z-5 keeps it BELOW carousel slide (z-10).
|
|
Uses shared CanvasBackground component for single source of truth with constructor.
|
|
Previous background overlay shows during loading.
|
|
Black overlay for fade effect is rendered separately at z-[100]. */}
|
|
<div
|
|
data-testid='page-background-wrapper'
|
|
className='absolute inset-0 z-5'
|
|
>
|
|
<CanvasBackground
|
|
backgroundImageUrl={backgroundImageUrl}
|
|
backgroundVideoUrl={backgroundVideoUrl}
|
|
previousBgImageUrl={pageSwitch.previousBgImageUrl}
|
|
previousBgVideoUrl={pageSwitch.previousBgVideoUrl}
|
|
isSwitching={pageSwitch.isSwitching}
|
|
isNewBgReady={pageSwitch.isNewBgReady}
|
|
onBackgroundReady={() => {
|
|
setIsBackgroundReady(true);
|
|
pageSwitch.markBackgroundReady();
|
|
}}
|
|
videoAutoplay={videoAutoplay}
|
|
videoLoop={videoLoop}
|
|
videoMuted={soundControl.isMuted}
|
|
videoStartTime={videoStartTime}
|
|
videoEndTime={videoEndTime}
|
|
videoStoragePath={selectedPage?.background_video_url}
|
|
/>
|
|
</div>
|
|
{/* 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. */}
|
|
<div
|
|
data-testid='page-elements-wrapper'
|
|
className='absolute inset-0 z-[46]'
|
|
>
|
|
{pageElements.map((element: CanvasElement) => (
|
|
<RuntimeElement
|
|
key={element.id}
|
|
element={element}
|
|
onClick={() => handleElementClick(element)}
|
|
resolveUrl={resolveUrlWithBlob}
|
|
onGalleryCardClick={(cardIndex) =>
|
|
handleGalleryCardClick(element, cardIndex)
|
|
}
|
|
letterboxStyles={letterboxStyles}
|
|
isForwardNavDisabled={isForwardNavDisabled}
|
|
/>
|
|
))}
|
|
</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 */}
|
|
{/* NO fade-out: video itself IS the transition (last frame = new page) */}
|
|
{/* Overlay removed instantly via setTransitionPreview(null) in onComplete */}
|
|
{transitionPreview && (
|
|
<TransitionPreviewOverlay
|
|
videoRef={transitionVideoRef}
|
|
isActive={true}
|
|
isBuffering={
|
|
// Hide overlay until video first frame is painted:
|
|
// - 'idle': React render cycle before hook effect runs
|
|
// - 'preparing': Video loading/buffering
|
|
// - isBuffering: Waiting for first frame paint (from hook)
|
|
transitionPhase === 'idle' ||
|
|
transitionPhase === 'preparing' ||
|
|
isBuffering
|
|
}
|
|
letterboxStyles={letterboxStyles}
|
|
opacity={1}
|
|
/>
|
|
)}
|
|
|
|
{/* 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}
|
|
/>
|
|
)}
|
|
</BackdropPortalProvider>
|
|
</div>
|
|
{/* End inner canvas container */}
|
|
|
|
{/* Controls: Offline toggle, Fullscreen, and Sound buttons */}
|
|
{/* Positioned outside canvas to avoid scaling with canvas transform */}
|
|
<RuntimeControls
|
|
projectId={project?.id || null}
|
|
projectSlug={projectSlug}
|
|
projectName={project?.name}
|
|
pages={pages}
|
|
isFullscreen={isFullscreen}
|
|
toggleFullscreen={toggleFullscreen}
|
|
canvasWidth={canvasWidth}
|
|
canvasHeight={canvasHeight}
|
|
showSoundButton={soundControl.showSoundButton}
|
|
isMuted={soundControl.isMuted}
|
|
onSoundToggle={soundControl.toggleSound}
|
|
/>
|
|
|
|
{/* Toast notifications for offline download status */}
|
|
<ToastContainer
|
|
position='bottom-center'
|
|
autoClose={5000}
|
|
hideProgressBar={false}
|
|
newestOnTop={false}
|
|
closeOnClick
|
|
rtl={false}
|
|
pauseOnFocusLoss
|
|
draggable
|
|
pauseOnHover
|
|
theme='dark'
|
|
/>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// Layout wrapper for standalone usage
|
|
RuntimePresentation.getLayout = function getLayout(page: ReactElement) {
|
|
return <LayoutGuest>{page}</LayoutGuest>;
|
|
};
|