# Runtime Presentation Page - E2E Documentation ## Overview The Runtime Presentation system is a full-screen, presentation-mode viewer for virtual tours. It enables end users to navigate through tour pages with transitions, rich media content, and offline support. Unlike the Constructor (edit mode), Runtime is optimized for public viewing with intelligent preloading and smooth transitions. --- ## Architecture ``` ┌─────────────────────────────────────────────────────────────────┐ │ Access Routes │ │ /p/[projectSlug] │ /p/[projectSlug]/stage │ /runtime │ └─────────────────────────────────────────────────────────────────┘ │ ┌─────────────────────────────────────────────────────────────────┐ │ RuntimePresentation Component │ │ Page Rendering │ Navigation │ Transitions │ Offline Support │ └─────────────────────────────────────────────────────────────────┘ │ ┌─────────────────────────────────────────────────────────────────┐ │ Hooks Layer │ │ usePageDataLoader │ useProjectAssets │ usePreloadOrchestrator │ │ usePageSwitch │ useTransitionPlayback │ useBackgroundTransition│ └─────────────────────────────────────────────────────────────────┘ │ ┌─────────────────────────────────────────────────────────────────┐ │ Component Layer │ │ RuntimeElement │ UiElementRenderer │ GalleryCarouselOverlay │ │ RuntimeControls (Offline + Fullscreen + Sound) │ └─────────────────────────────────────────────────────────────────┘ │ ┌─────────────────────────────────────────────────────────────────┐ │ Helper Libraries │ │ extractPageLinks │ navigationHelpers │ assetUrl │ logger │ └─────────────────────────────────────────────────────────────────┘ │ ┌─────────────────────────────────────────────────────────────────┐ │ Data Layer │ │ Projects API │ Tour Pages API │ S3 Direct Download │ └─────────────────────────────────────────────────────────────────┘ ``` --- ## Access Modes ### Production Mode **Route:** `/p/[projectSlug]` - Public access without authentication - Shows only production environment pages - Full offline support with OfflineToggle ### Stage Mode **Route:** `/p/[projectSlug]/stage` - Testing environment for previewing changes before production - Shows only `stage` environment pages - Environment badge displayed - Content published from Constructor via "Save to Stage" button ### Admin Runtime Mode **Route:** `/runtime` - Internal admin testing - Requires authentication - Can test any project ### Environment Model Content flows through three environments: ``` ┌─────────────────────────────────────────────────────────────────┐ │ Constructor (Edit) Stage (Preview) Production (Live) │ │ dev ──────► stage ──────► production │ │ "Save to Stage" "Publish" │ └─────────────────────────────────────────────────────────────────┘ ``` | Environment | Purpose | Access | |-------------|---------|--------| | `dev` | Active editing in Constructor | Constructor only (not Runtime) | | `stage` | Preview/testing before publish | `/p/[slug]/stage` route | | `production` | Live public presentation | `/p/[slug]` route | **Note:** The `dev` environment is only accessible in the Constructor. Runtime always shows either `stage` or `production` content. --- ## Runtime Context Detection **Primary Method: Route-Based Environment** The platform uses **route-based environment access**, not subdomains. The environment is determined by the frontend route and passed to the backend via headers. | Route | Environment | Component | |-------|-------------|-----------| | `/p/[projectSlug]` | production | `pages/p/[projectSlug]/index.tsx` | | `/p/[projectSlug]/stage` | stage | `pages/p/[projectSlug]/stage.tsx` | **Frontend sends environment via headers:** ```typescript // RuntimePresentation.tsx const apiConfig = { headers: { 'X-Runtime-Project-Slug': projectSlug, 'X-Runtime-Environment': environment, // 'production' | 'stage' }, }; ``` **Backend middleware** (`middleware/runtime-context.ts`): ```javascript // Reads both hostname AND headers for flexibility req.runtimeContext = { mode: detectFromHostname(hostname), // Fallback for subdomain access headerEnvironment: req.headers['x-runtime-environment'], headerProjectSlug: req.headers['x-runtime-project-slug'], }; ``` **Backend DB filtering** (`db/api/runtime-context.ts`): ```javascript // Uses header-based environment when hostname detection returns 'admin' // SECURITY: Only 'production' and 'stage' allowed from headers // 'dev' is blocked to prevent unauthorized access to dev data ``` --- ## Project Loading ### useProjectAssets Hook The component uses the `useProjectAssets` hook to resolve project assets (favicon, og_image) to presigned URLs for meta tags: ```typescript // Resolve project assets (favicon, og_image, logo) to presigned URLs const { faviconUrl, ogImageUrl } = useProjectAssets(project); ``` This hook: - Extracts storage paths from URLs (handles both `storage_key` and legacy `cdn_url` formats) - Queues presigned URL requests for relative paths - Resolves to playback URLs (presigned if available, otherwise proxy) ### Global UI Controls Runtime controls for offline mode, fullscreen, and global sound mute resolve through `global_ui_control_defaults`, `project_ui_control_settings`, and the current page's `global_ui_controls_settings_json`. Dimensions and positions use canvas-relative percentages, so controls keep consistent proportions and spacing across screens for projects with the same canvas ratio. Custom icons use each control's `defaultIconUrl` and `activeIconUrl`; empty values fall back to the built-in MDI icons. Embedded runtime fullscreen uses the same wrapper fallback as Info Panel image detail fullscreen: native document fullscreen first, same-origin iframe fullscreen second, then parent `tour-builder:request-fullscreen` postMessage. Exit from cross-origin wrapper fullscreen posts `tour-builder:exit-fullscreen`. ### usePageDataLoader Hook The component uses the shared `usePageDataLoader` hook for data loading: ```typescript const { project, pages, isLoading, error, initialPageId } = usePageDataLoader({ projectSlug, environment, apiHeaders: { 'X-Runtime-Project-Slug': projectSlug, 'X-Runtime-Environment': environment, }, }); ``` ### Data Fetching Flow ``` 1. usePageDataLoader fetches project └── GET /projects?slug={projectSlug} └── Headers: X-Runtime-Project-Slug, X-Runtime-Environment 2. usePageDataLoader fetches tour pages └── GET /tour_pages?project={projectId} └── Pages include ui_schema_json with navigation elements 3. STRICT environment filtering (both layers) └── Backend: Filters by X-Runtime-Environment header └── Frontend: .filter((p) => p.environment === environment) 4. Pages sorted by sort_order 5. initialPageId returned (first page by sort_order) ``` **Note:** Navigation targets, transitions, and page elements are all stored in `tour_pages.ui_schema_json`. No separate API calls needed. ### Environment Isolation (Critical) **IMPORTANT:** Strict environment filtering prevents data leaks. **Frontend Filter** (`RuntimePresentation.tsx`): ```typescript // STRICT: Only exact environment match - no fallbacks! const envFilteredPages = pageRows .filter((p: any) => p.environment === environment) .sort((a: any, b: any) => (a.sort_order ?? 0) - (b.sort_order ?? 0)); ``` **Backend Filter** (`db/api/runtime-context.ts`): ```javascript // Header-based filtering for route-based access // Only 'production' and 'stage' allowed - 'dev' is BLOCKED if (runtimeContext.headerEnvironment === 'production') return 'production'; if (runtimeContext.headerEnvironment === 'stage') return 'stage'; ``` | Route | Shows | Never Shows | |-------|-------|-------------| | `/p/cardiff` | `environment='production'` only | dev, stage | | `/p/cardiff/stage` | `environment='stage'` only | dev, production | ### Public Runtime Headers ```typescript const headers = { 'X-Runtime-Project-Slug': projectSlug, 'X-Runtime-Environment': environment, // 'production' | 'stage' }; ``` ### Public Field Filtering The `runtime-public.ts` middleware sanitizes responses for unauthenticated requests: **Projects:** id, name, slug, description, logo_url, favicon_url, og_image_url **Tour Pages:** id, projectId, environment, source_key, name, slug, sort_order, background_image_url, background_video_url, background_embed_url, background_audio_url, background_loop, requires_auth, ui_schema_json **Project Audio Tracks:** id, projectId, environment, source_key, name, slug, url, loop, volume, sort_order, is_enabled **Note:** Navigation links and transitions are stored in `tour_pages.ui_schema_json`, not as separate entities. ### Transition Settings (Public Access) The RuntimePresentation fetches transition settings from two endpoints: ```typescript // On mount - fetch global defaults (always public) useEffect(() => { dispatch(fetchGlobalTransitionDefaults()); }, [dispatch]); // When project loads - fetch project-specific settings useEffect(() => { if (project?.id) { dispatch(fetchProjectTransitionSettings({ projectId: project.id, environment, apiHeaders: runtimeApiHeaders, })); } }, [dispatch, project?.id, environment, runtimeApiHeaders]); ``` **Authentication Model (URL-path based):** | Endpoint | Environment | Auth Required | |----------|-------------|---------------| | `GET /global-transition-defaults` | n/a | **No** (always public) | | `GET /project-transition-settings/project/:id/env/production` | production | No for public projects; JWT + staff permission or DB access grant for private projects | | `GET /project-transition-settings/project/:id/env/stage` | stage | Yes | Public presentations (`/p/[slug]`) work in incognito mode without authentication. Private production presentations use the same runtime route, but require JWT and the project visibility/access grant flow described in [private-production-presentations.md](../../documentation/private-production-presentations.md). --- ## Page Rendering ### Z-Index Layering Structure The RuntimePresentation uses a specific z-index hierarchy to ensure proper layering of components: | Layer | Z-Index | Component | Purpose | |-------|---------|-----------|---------| | Background | `z-1` | Background image/video | Page background content | | Backdrop blur | `z-5` | BackdropPortalProvider | Blur effects for elements | | Carousel background | `z-10` | CarouselElement (portal) | Full-width carousel background | | Previous background | `z-10` | Previous bg overlay | Shows during page transitions | | Carousel controls | `z-30` | CarouselElement (portal) | Carousel prev/next buttons | | **Page elements** | **`z-40`** | Elements container | Navigation buttons, UI elements | | UI controls | settings-driven | RuntimeControls | Canvas-relative global controls | | Transition overlay | `z-50` | Transition video | Page transition videos | **Key Design Decisions:** - Page elements at `z-40` ensures they appear above carousel controls (`z-30`) for proper click handling - Carousel uses portals to `document.body` with `position: fixed` for full-screen display - Carousel wrappers have `pointer-events-none` with buttons having `pointer-events-auto` - Background video at `z-1` keeps it below backdrop blur effects ### RuntimeControls Component **Location:** `src/components/Runtime/RuntimeControls.tsx` The RuntimeControls component renders the offline toggle, fullscreen button, and optional global sound button. Each button has independent canvas-relative positioning, dimensions, styling, order, visibility, disabled state, and state-specific icon/color settings. The sound button is presentation-level: if any page in the loaded runtime environment has background audio, unmuted background video, hover/click effects, media player sound, or gallery/info-panel video, the button is visible on every page in that presentation. **Key Features:** | Feature | Implementation | |---------|----------------| | Canvas-relative positioning | `xPercent`/`yPercent` are resolved inside the visible canvas bounds | | Canvas-relative sizing | `buttonSizePercent`, `iconSizePercent`, and `borderRadiusPercent` resolve from canvas width | | Pinch-zoom resistance | Uses `visualViewport` API to counter-scale during pinch-zoom | | Configurable z-index | Runtime uses control settings; constructor clamps controls below editor chrome | | Global sound toggle | Uses `presentationHasAudio`, `useVideoSoundControl`, and `backgroundAudioController` to mute/unmute background audio, hover/click effects, audio/video player elements, and gallery/info-panel videos across the whole presentation | | Optional controls | `showOfflineButton`, `showFullscreenButton`, and `showSoundButton` gate rendering without deleting settings | **iOS Pinch-Zoom Fix:** iOS browsers (all use WebKit engine) have inconsistent scaling behavior between `rem` units and other CSS values during pinch-zoom gestures. The RuntimeControls uses a `useCounterZoom` hook that: 1. Listens to `visualViewport.resize` and `visualViewport.scroll` events 2. Reads `visualViewport.scale` to detect current pinch-zoom level 3. Applies an inverse `transform: scale(1/zoomLevel)` to maintain constant visual size ```typescript // Counter-scale formula const counterScale = 1 / visualViewport.scale; // Applied as: transform: scale(counterScale), transformOrigin: 'top right' ``` **Sub-components:** - `ControlButton` - Styled button with hover states matching BaseButton colors - `ControlIcon` - built-in SVG or custom asset icon renderer - `OfflineControl` - Offline download toggle with status indicators ### UI Schema Structure Pages store elements in `ui_schema_json`: ```typescript interface UISchema { elements: UISchemaElement[]; } interface UISchemaElement { id: string; type: ElementType; // Position (percentage-based) xPercent: number; yPercent: number; rotation?: number; // Dimensions width?: string; height?: string; // Styling opacity?: number; padding?: string; margin?: string; fontSize?: string; fontFamily?: string; color?: string; backgroundColor?: string; // Content iconUrl?: string; imageUrl?: string; mediaUrl?: string; // Navigation (stored in ui_schema_json) targetPageSlug?: string; // Slug-based navigation (consistent across environments) navType?: 'forward' | 'back'; navLabel?: string; navDisabled?: boolean; // Runtime ignores activation while preserving visual effects transitionVideoUrl?: string; // Type-specific descriptionTitle?: string; descriptionText?: string; tooltipTitle?: string; tooltipText?: string; galleryCards?: GalleryCard[]; carouselSlides?: CarouselSlide[]; } ``` ### Element Rendering Elements are rendered using shared components for WYSIWYG consistency with the constructor: ```tsx {/* Page elements - z-40 ensures they appear above carousel controls (z-30) */}
{pageElements.map((element: CanvasElement) => ( handleElementClick(element)} resolveUrl={resolveUrlWithBlob} onGalleryCardClick={(cardIndex) => handleGalleryCardClick(element, cardIndex)} /> ))}
// URL resolver uses preloaded blob URLs when available (instant display) const resolveUrlWithBlob = useCallback( (url: string | undefined): string => { if (!url) return ''; // Try storage key first, then resolved URL const blobUrl = preloadOrchestrator?.getReadyBlobUrl(url) || preloadOrchestrator?.getReadyBlobUrl(resolveAssetPlaybackUrl(url)); if (blobUrl) return blobUrl; return resolveAssetPlaybackUrl(url); }, [preloadOrchestrator], ); ``` **Component Architecture:** - `RuntimeElement` handles positioning, rotation, and interactive effects (hover/focus/active) - `RuntimeElement` delegates content rendering to `UiElementRenderer` - `UiElementRenderer` delegates to per-type components (NavigationElement, GalleryElement, etc.) - Both Constructor (`CanvasElement`) and Runtime (`RuntimeElement`) use `UiElementRenderer` for WYSIWYG consistency ### RuntimeElement Component Wraps each UI element with positioning, interactive effects, and delegates content rendering to `UiElementRenderer`: | Feature | Description | |---------|-------------| | **Percentage positioning** | Uses `xPercent`, `yPercent` for responsive placement | | **Transform** | Centers element and applies rotation | | **Interactive effects** | Hover, focus, and active state styling via `useElementEffects` hook | | **Click handling** | Propagates clicks to parent handler; disabled navigation/info panel elements keep visual hover/focus/active effects but ignore click and keyboard activation | | **Gallery card clicks** | `onGalleryCardClick` prop opens GalleryCarouselOverlay | | **Content delegation** | Renders content via `UiElementRenderer` for WYSIWYG consistency | | **URL resolution** | Passes `resolveUrl` prop for blob URL support | ### UiElementRenderer Component Unified element rendering component - single source of truth used by both `RuntimeElement` (presentation) and `CanvasElement` (constructor) for WYSIWYG consistency. **Architecture:** ``` UiElementRenderer (main entry point) ├── useElementWrapperStyle (shared styling hook) └── Per-type components: ├── NavigationElement (navigation_next, navigation_prev) ├── GalleryElement (gallery) → can trigger GalleryCarouselOverlay ├── TooltipElement (tooltip) ├── DescriptionElement (description) ├── CarouselElement (carousel) ├── LogoElement (logo) ├── SpotElement (spot/hotspot) ├── VideoPlayerElement (video_player) ├── AudioPlayerElement (audio_player) └── PopupElement (popup) GalleryCarouselOverlay (fullscreen overlay) ├── Swipe navigation between images ├── Customizable prev/next/back buttons └── Percentage-based button positioning ``` | Element Type | Component | Rendered Content | |--------------|-----------|------------------| | `navigation_*` | NavigationElement | Icon + label with nav styling | | `spot` | SpotElement | Hotspot/clickable area with icon | | `description` | DescriptionElement | Title + text block | | `tooltip` | TooltipElement | Icon with popover | | `video_player` | VideoPlayerElement | HTML5 video | | `audio_player` | AudioPlayerElement | HTML5 audio | | `gallery` | GalleryElement | Image grid from galleryCards | | `carousel` | CarouselElement | Slideshow from carouselSlides | | `logo` | LogoElement | Logo image element | | `popup` | PopupElement | Modal/popup overlay | ### Element Types | Type | Rendering | |------|-----------| | `navigation_next` | Button with icon, navigates forward | | `navigation_prev` | Button with icon, navigates backward | | `spot` | Hotspot/clickable area with icon | | `description` | Title + text block with styling | | `tooltip` | Icon with hover popover | | `video_player` | HTML5 video with controls | | `audio_player` | HTML5 audio with controls | | `gallery` | Grid of images from galleryCards | | `carousel` | Slideshow from carouselSlides | | `logo` | Logo image element | | `popup` | Modal/popup overlay | --- ## Page Navigation ### Navigation Flow ``` ┌─────────────────────────────────────────────────────────────────┐ │ 1. User clicks navigation element │ │ └── handleElementClick(element) │ └─────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ 2. Extract navigation data & resolve slug │ │ └── targetPageSlug → resolve to targetPageId │ │ └── navType, transitionVideoUrl │ └─────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ 3. Determine if back navigation │ │ └── navType === 'back' OR type === 'navigation_prev' │ └─────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ 4. Wait for target page images │ │ └── Decode images to prevent white flash │ └─────────────────────────────────────────────────────────────────┘ │ ┌───────────────────┴───────────────────┐ │ │ ▼ ▼ ┌──────────────────────┐ ┌──────────────────────┐ │ Has Transition? │ │ No Transition │ │ YES │ │ Direct page switch │ └──────────┬───────────┘ └──────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ 5. Show transition overlay via useTransitionPlayback │ │ └── Full-screen video playback (forward or reverse) │ │ └── Images pre-decode DURING video playback │ └─────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ 6. Video ends → onComplete callback fires │ │ └── waitForPageImages() completes instantly (pre-decoded) │ │ └── Switch to target page │ │ └── Update page history │ │ └── Double requestAnimationFrame ensures page painted │ │ └── THEN remove overlay (setTransitionPreview(null)) │ └─────────────────────────────────────────────────────────────────┘ ``` ### Element Click Handler The click handler uses shared navigation helpers from `lib/navigationHelpers.ts`: ```typescript const handleElementClick = useCallback( (element: any) => { if (isNavigationType(element.type) && element.navDisabled) { return; } // Block navigation while transition is actively playing or buffering if (isTransitionBlocking(transitionPhase as TransitionPhase, isBuffering)) { return; } // Use shared helper to resolve navigation target const navTarget = resolveNavigationTarget(element, pages); if (navTarget) { navigateToPage( navTarget.pageId, navTarget.transitionVideoUrl, navTarget.isBack, ); } }, [navigateToPage, pages, transitionPhase, isBuffering], ); ``` ### Navigation Helpers (`lib/navigationHelpers.ts`) | Helper | Purpose | |--------|---------| | `resolveNavigationTarget(element, pages)` | Resolves element's `targetPageSlug` to page ID, returns `{pageId, transitionVideoUrl, isBack}` or `null` | | `isTransitionBlocking(phase, isBuffering)` | Returns `true` if navigation should be blocked (transition playing) | | `getNavigationDirection(element)` | Returns `'forward'` or `'back'` based on `navType` or element type | **Note:** Navigation elements store `targetPageSlug` (not UUID) because slugs are consistent across environments (dev/stage/production). The slug is resolved to a page ID at navigation time. ### Page History Management Page history is now managed by the shared `usePageNavigation` hook (replacing manual `useState`): ```typescript // usePageNavigation hook provides unified history management const { currentPageId: selectedPageId, pageHistory, applyPageSelection, getNavigationContext, } = usePageNavigation({ pages, defaultPageId: initialPageId, trackHistory: true, }); // applyPageSelection handles history with browser-like behavior: // - Forward: appends to history (trimmed to MAX_HISTORY_LENGTH=50) // - Back (isBack=true): pops from history if target matches previous page applyPageSelection(targetPageId, isBack); // getNavigationContext provides context for history-based navigation const navContext = getNavigationContext(); // Returns: { currentPageSlug, previousPageId } const navTarget = resolveNavigationTarget(element, pages, navContext); ``` --- ## Transition Execution ### Transition State ```typescript // Transition state for useTransitionPlayback hook const [transitionPreview, setTransitionPreview] = useState<{ targetPageId: string; videoUrl: string; // Resolved URL for playback storageKey: string; // Raw storage path for cache lookup (enables presigned URL independence) isReverse: boolean; } | null>(null); ``` ### useTransitionPlayback Hook Integration The `useTransitionPlayback` hook handles both forward and reverse playback with smooth transitions: ```typescript // State for coordinating transition completion with background readiness const [isBackgroundReady, setIsBackgroundReady] = useState(true); const [pendingTransitionComplete, setPendingTransitionComplete] = useState(false); // Hook returns both isBuffering and phase for granular opacity control const { isBuffering, phase: transitionPhase } = useTransitionPlayback({ videoRef: transitionVideoRef, transition: transitionPreview ? { videoUrl: transitionPreview.videoUrl, storageKey: transitionPreview.storageKey, // Raw path for instant cache lookup reverseMode: transitionPreview.isReverse ? 'reverse' : 'none', targetPageId: transitionPreview.targetPageId, displayName: 'Transition', isBack: transitionPreview.isReverse, // Pass through for history management } : null, // onComplete receives isBack flag for proper history management onComplete: async (targetPageId, isBack) => { if (targetPageId) { const targetPage = pages.find((p) => p.id === targetPageId); // Use shared hook to resolve blob URLs and switch page await pageSwitch.switchToPage(targetPage, () => { // usePageNavigation hook: pops history on back, appends on forward applyPageSelection(targetPageId, isBack ?? false); }); setIsBackgroundReady(false); // Signal transition complete, wait for background setPendingTransitionComplete(true); } else { // Cleanup when no target page video?.removeAttribute('src'); video?.load(); setTransitionPreview(null); setPendingTransitionComplete(false); } }, features: { useBlobUrl: true, // Enables seeking for reverse playback preDecodeImages: false, // Overlay shows last frame while new bg loads behind }, preload: { preloadedUrls: preloadOrchestrator?.preloadedUrls || new Set(), getCachedBlobUrl: preloadOrchestrator?.getCachedBlobUrl, getReadyBlobUrl: preloadOrchestrator?.getReadyBlobUrl, // Instant O(1) lookup by storage key }, }); ``` ### useBackgroundTransition Hook Integration The `useBackgroundTransition` hook handles crossfade effects for **non-video navigation only**: ```typescript // Use shared background transition hook for crossfade effects // NOTE: fadeOut config is NOT used for video transitions. // Video transitions end instantly (last frame = new page, then overlay removed). // fadeIn is used for non-video navigation (crossfade 700ms). const { isFadingIn, onFadeInAnimationEnd, resetFadeIn } = useBackgroundTransition({ pageSwitch, // No fadeOut - video transitions don't use fade fadeIn: { hasActiveTransition: Boolean(transitionPreview), }, }); ``` **Video Overlay Removal (separate effect, no hook involvement):** ```typescript // Video transition overlay removal - instant (no fade) when background is ready useEffect(() => { if (pendingTransitionComplete && isBackgroundReady) { const video = transitionVideoRef.current; if (video) { video.removeAttribute('src'); video.load(); } setTransitionPreview(null); setPendingTransitionComplete(false); } }, [pendingTransitionComplete, isBackgroundReady]); ``` **How it works (instant removal, no fade):** 1. When `pendingTransitionComplete && isBackgroundReady`, effect triggers cleanup 2. **Instant** overlay removal (no fade animation): - `video.removeAttribute('src'); video.load()` - cleanup video - `setTransitionPreview(null)` - removes overlay immediately - `setPendingTransitionComplete(false)` - reset state **Why no fade for video transitions:** - Video itself IS the transition effect - First frame of video = old page background - Last frame of video = new page background - Fading would create visual discontinuity ### Transition Overlay Rendering ```tsx {/* Container hidden while buffering to prevent black flash */} {/* Video first frame should match old page background */} {/* Overlay removed instantly when new background ready */} {transitionPreview && ( )} ``` **TransitionPreviewOverlay behavior:** - `containerOpacity = isBuffering ? 0 : 1` - hides entire container while loading - Old page visible through transparent overlay until video ready - Video appears instantly when ready (first frame = old page) - Overlay removed instantly when new background ready ### Key Features | Feature | Description | |---------|-------------| | **Container hidden while buffering** | Prevents black flash, old page visible | | **Instant overlay appearance** | Video first frame matches old page | | **Instant overlay removal** | No fade-out, removed when bg ready | | **useBackgroundTransition** | Shared hook handles crossfade (non-video nav) | | **isBackgroundReady state** | Tracks when new page background is fully rendered | | **pendingTransitionComplete state** | Signals video ended, waiting for background | | **Blob URL support** | Enables seeking for reverse playback | | **Video cleanup** | `removeAttribute('src')` + `load()` prevents memory leaks | | **Navigation blocking** | `isTransitionBlocking()` prevents navigation during playback | ### Reverse Playback Modes The hook internally uses `useReversePlayback` with strategies: 1. **Native Reverse** - `video.playbackRate = -1` (Chrome, Edge) 2. **Frame-Stepping** - Manual frame-by-frame reverse (Safari, Firefox) --- ## Background Media ### Background Image The background uses both CSS `background-image` (for immediate display) and an image element for proper loading detection. **Blob URLs use native `` to prevent re-fetch issues, while regular URLs use Next.js Image:** ```tsx
{/* Image element ensures proper loading for waitForPageImages() */} {backgroundImageUrl && !backgroundVideoUrl && (
{backgroundImageUrl.startsWith('blob:') ? ( // Native img for blob URLs - no re-fetch on re-render setIsBackgroundReady(true)} onError={() => setIsBackgroundReady(true)} /> ) : ( // Next.js Image for regular URLs - optimization benefits setIsBackgroundReady(true)} onError={() => setIsBackgroundReady(true)} /> )}
)}
``` **Why conditional rendering?** - Next.js `` re-fetches `src` on every re-render, even with `unoptimized` - Blob URLs are already in memory - no need for Next.js optimization - Native `` is cached by browser and doesn't re-fetch - This prevents thousands of unnecessary blob URL requests during animations **Why both CSS and Image element?** - CSS `background-image` provides immediate visual (from browser cache) - Image element triggers `onLoad` callback for proper state tracking - `waitForPageImages()` uses `img.decode()` which works with both image types - This prevents black flash when transitioning between pages ### Background Video Background videos support configurable playback settings including custom start/end times. **Video Playback Settings (from `tour_pages`):** | Field | Type | Default | Description | |-------|------|---------|-------------| | `background_video_autoplay` | BOOLEAN | true | Autoplay on load | | `background_video_loop` | BOOLEAN | true | Loop continuously | | `background_video_muted` | BOOLEAN | true | Mute audio (required for autoplay) | | `background_video_start_time` | DECIMAL(10,1) | null | Start time in seconds | | `background_video_end_time` | DECIMAL(10,1) | null | End/loop time in seconds | **DECIMAL Parsing (Critical):** Sequelize DECIMAL fields return strings from the database (e.g., `"2.5"` not `2.5`). These must be parsed before use: ```typescript // Parse DECIMAL strings for video time settings 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; ``` **useBackgroundVideoPlayback Hook:** The `useBackgroundVideoPlayback` hook handles start/end time control via video events: ```typescript const { videoRef: bgVideoRef } = useBackgroundVideoPlayback({ videoUrl: backgroundVideoUrl, autoplay: videoAutoplay, loop: videoLoop, muted: videoMuted, startTime: videoStartTime, // Must be parsed from DECIMAL string endTime: videoEndTime, // Must be parsed from DECIMAL string }); ``` **Note:** When `endTime` is set, native HTML5 `loop` is disabled. The hook handles looping via `timeupdate` event, seeking back to `startTime`. **Rendering:** ```tsx {backgroundVideoUrl && (