improved preloading logic

This commit is contained in:
Dmitri 2026-03-28 17:11:39 +04:00
parent b925094555
commit eac21c84b3
12 changed files with 1028 additions and 356 deletions

View File

@ -211,6 +211,18 @@ class PWAManifestService {
[page.id],
);
}
if (page.background_audio_url) {
addAsset(
`page-audio-${page.id}`,
page.background_audio_url,
`page-${page.slug}-audio.mp3`,
'original',
'audio',
'audio/mpeg',
0,
[page.id],
);
}
// Extract URLs from ui_schema_json elements
try {

File diff suppressed because one or more lines are too long

View File

@ -6,7 +6,6 @@
*/
import { mdiFullscreen, mdiFullscreenExit } from '@mdi/js';
import axios from 'axios';
import Head from 'next/head';
import Image from 'next/image';
import React, {
@ -25,28 +24,44 @@ import { ElementContentRenderer } from './UiElements/ElementContentRenderer';
import LayoutGuest from '../layouts/Guest';
import { getPageTitle } from '../config';
import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
import { usePageDataLoader } from '../hooks/usePageDataLoader';
import { extractPageLinksAndElements } from '../lib/extractPageLinks';
import { usePageSwitch } from '../hooks/usePageSwitch';
import { useTransitionPlayback } from '../hooks/useTransitionPlayback';
import { useBackgroundTransition } from '../hooks/useBackgroundTransition';
import { resolveAssetPlaybackUrl } from '../lib/assetUrl';
import { logger } from '../lib/logger';
// buildElementStyle is now used in RuntimeElement component
import type { RuntimeProject, RuntimePage } from '../types/runtime';
import {
resolveNavigationTarget,
isTransitionBlocking,
} from '../lib/navigationHelpers';
import type { TransitionPhase } from '../types/presentation';
interface RuntimePresentationProps {
projectSlug: string;
environment: 'stage' | 'production';
}
const getRows = (response: any) =>
Array.isArray(response?.data?.rows) ? response.data.rows : [];
export default function RuntimePresentation({
projectSlug,
environment,
}: RuntimePresentationProps) {
const [project, setProject] = useState<RuntimeProject | null>(null);
const [pages, setPages] = useState<RuntimePage[]>([]);
// Use shared hook for loading project and pages data
const {
project,
pages,
isLoading,
error,
initialPageId,
} = usePageDataLoader({
projectSlug,
environment,
apiHeaders: {
'X-Runtime-Project-Slug': projectSlug,
'X-Runtime-Environment': environment,
},
});
const [selectedPageId, setSelectedPageId] = useState<string | null>(null);
const [pageHistory, setPageHistory] = useState<string[]>([]);
const [transitionPreview, setTransitionPreview] = useState<{
@ -55,27 +70,21 @@ export default function RuntimePresentation({
storageKey: string;
isReverse: boolean;
} | null>(null);
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [isFullscreen, setIsFullscreen] = useState(false);
const [isBackgroundReady, setIsBackgroundReady] = useState(true);
const [pendingTransitionComplete, setPendingTransitionComplete] =
useState(false);
const [isOverlayFadingOut, setIsOverlayFadingOut] = useState(false);
const transitionVideoRef = useRef<HTMLVideoElement>(null);
const lastInitializedPageIdRef = useRef<string | null>(null);
// API request config with custom headers for project/environment
const apiConfig = useMemo(
() => ({
headers: {
'X-Runtime-Project-Slug': projectSlug,
'X-Runtime-Environment': environment,
},
}),
[projectSlug, environment],
);
// Set initial page when data loads
useEffect(() => {
if (initialPageId && !selectedPageId) {
setSelectedPageId(initialPageId);
setPageHistory([initialPageId]);
}
}, [initialPageId, selectedPageId]);
// Extract page links and preload elements from ui_schema_json
// This enables the neighbor graph to find connected pages for preloading
@ -163,6 +172,20 @@ export default function RuntimePresentation({
},
});
// Use shared background transition hook for fade-out effects
const { isOverlayFadingOut, resetFadeOut } = useBackgroundTransition({
pageSwitch,
fadeOut: {
pendingTransitionComplete,
isBackgroundReady,
transitionVideoRef,
onTransitionCleanup: useCallback(() => {
setTransitionPreview(null);
setPendingTransitionComplete(false);
}, []),
},
});
const toggleFullscreen = useCallback(async () => {
try {
if (!document.fullscreenElement) {
@ -190,126 +213,6 @@ export default function RuntimePresentation({
document.removeEventListener('fullscreenchange', handleFullscreenChange);
}, []);
// Fade out and remove transition overlay when background is ready
useEffect(() => {
if (pendingTransitionComplete && isBackgroundReady && !isOverlayFadingOut) {
// Start fade-out animation
setIsOverlayFadingOut(true);
// After fade completes (300ms), remove the overlay
const fadeTimer = setTimeout(() => {
const video = transitionVideoRef.current;
video?.removeAttribute('src');
video?.load();
setTransitionPreview(null);
setPendingTransitionComplete(false);
setIsOverlayFadingOut(false);
// Clear previous background from shared hook
pageSwitch.clearPreviousBackground();
}, 300);
return () => clearTimeout(fadeTimer);
}
}, [
pendingTransitionComplete,
isBackgroundReady,
isOverlayFadingOut,
pageSwitch.clearPreviousBackground,
]);
// Clear previous background overlay when new background is ready (direct navigation)
useEffect(() => {
if (
pageSwitch.isSwitching &&
pageSwitch.isNewBgReady &&
pageSwitch.previousBgImageUrl
) {
// New background is ready - clear the previous background overlay
pageSwitch.clearPreviousBackground();
}
}, [
pageSwitch.isSwitching,
pageSwitch.isNewBgReady,
pageSwitch.previousBgImageUrl,
pageSwitch.clearPreviousBackground,
]);
// Load presentation data
useEffect(() => {
let isCancelled = false;
const loadPresentation = async () => {
try {
setIsLoading(true);
setError('');
// Fetch project by slug
const projectsResponse = await axios.get('/projects', {
...apiConfig,
params: { slug: projectSlug },
});
if (isCancelled) return;
const projectRows = getRows(projectsResponse);
const foundProject = projectRows.find(
(p: RuntimeProject) => p.slug === projectSlug,
);
if (!foundProject) {
setError(`Project "${projectSlug}" not found.`);
return;
}
setProject(foundProject);
// Fetch pages for this project
// (Elements and navigation are extracted from ui_schema_json)
const pagesResponse = await axios.get('/tour_pages', {
...apiConfig,
params: { project: foundProject.id },
});
if (isCancelled) return;
const pageRows = getRows(pagesResponse);
// Filter by environment and sort by sort_order
// STRICT: Only show pages matching the exact environment
// Production = production only, Stage = stage only, Dev = dev only
const envFilteredPages = pageRows
.filter((p: any) => p.environment === environment)
.sort((a: any, b: any) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
setPages(envFilteredPages);
// Set initial page (first page by sort_order)
if (envFilteredPages.length > 0) {
const firstPage = envFilteredPages[0];
setSelectedPageId(firstPage.id);
setPageHistory([firstPage.id]);
}
} catch (err: any) {
if (isCancelled) return;
const message =
err?.response?.data?.message ||
err?.message ||
'Failed to load presentation.';
setError(message);
} finally {
if (!isCancelled) {
setIsLoading(false);
}
}
};
loadPresentation();
return () => {
isCancelled = true;
};
}, [projectSlug, environment, apiConfig]);
const selectedPage = useMemo(
() => pages.find((p) => p.id === selectedPageId) || null,
[pages, selectedPageId],
@ -371,8 +274,8 @@ export default function RuntimePresentation({
if (transitionVideoUrl) {
// Reset states from previous transition before starting new one
// This prevents the fade-out effect from re-triggering when isOverlayFadingOut resets
setIsOverlayFadingOut(false);
// This prevents the fade-out effect from re-triggering
resetFadeOut();
setPendingTransitionComplete(false);
// Play transition using useTransitionPlayback hook
setTransitionPreview({
@ -394,50 +297,35 @@ export default function RuntimePresentation({
});
}
},
[pages, pageSwitch],
[pages, pageSwitch, resetFadeOut],
);
const handleElementClick = useCallback(
(element: any) => {
// Disable navigation while transition is actively playing or buffering
// Only block during active phases, not during fade-out (completed phase)
const isActivelyPlaying =
transitionPhase === 'preparing' ||
transitionPhase === 'playing' ||
transitionPhase === 'reversing';
if (isActivelyPlaying || isBuffering) {
// Block navigation while transition is actively playing or buffering
if (isTransitionBlocking(transitionPhase as TransitionPhase, isBuffering)) {
return;
}
// Support both targetPageSlug (new) and targetPageId (legacy)
const targetPageSlug = element.targetPageSlug;
const legacyTargetPageId = element.targetPageId;
// Resolve slug to page ID, or use legacy targetPageId
let targetPageId: string | undefined;
if (targetPageSlug) {
const targetPage = pages.find((p) => p.slug === targetPageSlug);
targetPageId = targetPage?.id;
} else if (legacyTargetPageId) {
targetPageId = legacyTargetPageId;
}
// Use shared helper to resolve navigation target
const navTarget = resolveNavigationTarget(element, pages);
// Debug: log element navigation data
logger.info('Element clicked', {
elementType: element.type,
targetPageSlug,
legacyTargetPageId,
resolvedTargetPageId: targetPageId,
targetPageSlug: element.targetPageSlug,
legacyTargetPageId: element.targetPageId,
resolvedTargetPageId: navTarget?.pageId,
transitionVideoUrl: element.transitionVideoUrl,
hasTransition: Boolean(element.transitionVideoUrl),
});
if (targetPageId) {
const isBack =
element.navType === 'back' || element.type === 'navigation_prev';
// Get transition video URL from element itself
const transitionVideoUrl = element.transitionVideoUrl;
navigateToPage(targetPageId, transitionVideoUrl, isBack);
if (navTarget) {
navigateToPage(
navTarget.pageId,
navTarget.transitionVideoUrl,
navTarget.isBack,
);
}
},
[navigateToPage, pages, transitionPhase, isBuffering],

View File

@ -20,3 +20,14 @@ export type {
UsePageNavigationOptions,
UsePageNavigationResult,
} from './usePageNavigation';
export { useBackgroundTransition } from './useBackgroundTransition';
export type {
FadeOutConfig,
UseBackgroundTransitionOptions,
UseBackgroundTransitionResult,
} from './useBackgroundTransition';
export { usePageDataLoader } from './usePageDataLoader';
export type {
UsePageDataLoaderOptions,
UsePageDataLoaderResult,
} from './usePageDataLoader';

View File

@ -0,0 +1,164 @@
/**
* useBackgroundTransition Hook
*
* Manages background transition effects when switching between pages.
* Handles the fade-out animation of the transition video overlay and
* coordinates with the page switch hook to clear previous backgrounds.
*
* This hook consolidates the background transition logic used by both
* RuntimePresentation and constructor.tsx.
*
* Two modes:
* 1. Full mode (RuntimePresentation): Fade-out animation + direct navigation clearing
* 2. Simple mode (constructor): Direct navigation clearing only
*/
import { useEffect, useState, useCallback } from 'react';
/**
* Fade-out configuration (optional - for RuntimePresentation)
*/
export interface FadeOutConfig {
/** Whether a transition video has finished playing and is waiting for bg ready */
pendingTransitionComplete: boolean;
/** Whether the new background image is ready to display */
isBackgroundReady: boolean;
/** Ref to the transition video element for cleanup */
transitionVideoRef: React.RefObject<HTMLVideoElement | null>;
/** Callback to clear transition state after overlay is removed */
onTransitionCleanup: () => void;
}
export interface UseBackgroundTransitionOptions {
/** Page switch hook instance for clearing previous background */
pageSwitch: {
clearPreviousBackground: () => void;
isSwitching: boolean;
isNewBgReady: boolean;
previousBgImageUrl: string;
};
/** Optional fade-out configuration (for RuntimePresentation) */
fadeOut?: FadeOutConfig;
}
export interface UseBackgroundTransitionResult {
/** Whether the overlay is currently fading out */
isOverlayFadingOut: boolean;
/** Reset the fade-out state (call before starting a new transition) */
resetFadeOut: () => void;
}
/**
* Duration of the fade-out animation in milliseconds
*/
const FADE_DURATION_MS = 300;
/**
* Hook for managing background transition effects.
*
* @example
* // Full mode with fade-out (RuntimePresentation)
* const { isOverlayFadingOut, resetFadeOut } = useBackgroundTransition({
* pageSwitch,
* fadeOut: {
* pendingTransitionComplete,
* isBackgroundReady,
* transitionVideoRef,
* onTransitionCleanup: () => {
* setTransitionPreview(null);
* setPendingTransitionComplete(false);
* },
* },
* });
*
* @example
* // Simple mode - direct navigation only (constructor)
* useBackgroundTransition({ pageSwitch });
*/
export function useBackgroundTransition({
pageSwitch,
fadeOut,
}: UseBackgroundTransitionOptions): UseBackgroundTransitionResult {
const [isOverlayFadingOut, setIsOverlayFadingOut] = useState(false);
/**
* Reset fade-out state before starting a new transition.
* This prevents the fade-out effect from re-triggering when state resets.
*/
const resetFadeOut = useCallback(() => {
setIsOverlayFadingOut(false);
}, []);
/**
* Effect: Fade out and remove transition overlay when background is ready.
* Only runs when fadeOut config is provided.
*
* Sequence:
* 1. Transition video finishes playing (pendingTransitionComplete = true)
* 2. New background image loads (isBackgroundReady = true)
* 3. Start fade-out animation (isOverlayFadingOut = true)
* 4. After fade completes, clean up video and clear transition state
*/
useEffect(() => {
if (!fadeOut) return;
const {
pendingTransitionComplete,
isBackgroundReady,
transitionVideoRef,
onTransitionCleanup,
} = fadeOut;
if (pendingTransitionComplete && isBackgroundReady && !isOverlayFadingOut) {
// Start fade-out animation
setIsOverlayFadingOut(true);
// After fade completes, remove the overlay
const fadeTimer = setTimeout(() => {
const video = transitionVideoRef.current;
if (video) {
video.removeAttribute('src');
video.load();
}
// Clear previous background from shared hook
pageSwitch.clearPreviousBackground();
// Notify caller to clear transition state
onTransitionCleanup();
// Reset fade-out state
setIsOverlayFadingOut(false);
}, FADE_DURATION_MS);
return () => clearTimeout(fadeTimer);
}
}, [fadeOut, isOverlayFadingOut, pageSwitch]);
/**
* Effect: Clear previous background overlay when new background is ready (direct navigation).
*
* This handles the case when navigating without a transition video.
* The previous background stays visible until the new one is ready to paint.
*/
useEffect(() => {
if (
pageSwitch.isSwitching &&
pageSwitch.isNewBgReady &&
pageSwitch.previousBgImageUrl
) {
// New background is ready - clear the previous background overlay
pageSwitch.clearPreviousBackground();
}
}, [
pageSwitch.isSwitching,
pageSwitch.isNewBgReady,
pageSwitch.previousBgImageUrl,
pageSwitch.clearPreviousBackground,
]);
return {
isOverlayFadingOut,
resetFadeOut,
};
}

View File

@ -0,0 +1,253 @@
/**
* usePageDataLoader Hook
*
* Unified hook for loading project and page data in presentation components.
* Used by both RuntimePresentation (public) and constructor.tsx (authenticated).
*
* Features:
* - Loads project by slug or ID
* - Loads pages filtered by environment
* - Sorts pages by sort_order
* - Handles loading and error states
* - Supports reloading with page preservation
*/
import { useState, useCallback, useEffect, useMemo } from 'react';
import axios from 'axios';
import { logger } from '../lib/logger';
import type { RuntimeProject, RuntimePage } from '../types/runtime';
/**
* Configuration for the page data loader
*/
export interface UsePageDataLoaderOptions {
/** Project ID for authenticated mode (constructor) */
projectId?: string;
/** Project slug for public mode (runtime) */
projectSlug?: string;
/** Environment to filter pages by */
environment: 'dev' | 'stage' | 'production';
/** Whether the data loading should be enabled */
enabled?: boolean;
/** Custom API headers (e.g., for runtime environment context) */
apiHeaders?: Record<string, string>;
/** Initial page ID from route (for constructor) */
initialPageId?: string;
}
/**
* Result of the page data loader
*/
export interface UsePageDataLoaderResult {
/** Loaded project data */
project: RuntimeProject | null;
/** Loaded and filtered pages */
pages: RuntimePage[];
/** Whether data is currently loading */
isLoading: boolean;
/** Error message if loading failed */
error: string;
/** Reload the data (optionally preserving current page selection) */
reload: (preservePageId?: string) => Promise<void>;
/** Initially selected page ID */
initialPageId: string;
}
/**
* Extract rows from API response
*/
const getRows = (response: unknown): unknown[] => {
const data = response as { data?: { rows?: unknown[] } };
return Array.isArray(data?.data?.rows) ? data.data.rows : [];
};
/**
* Hook for loading project and page data.
*
* @example
* // Runtime mode (public presentation)
* const { project, pages, isLoading, error } = usePageDataLoader({
* projectSlug: 'my-project',
* environment: 'production',
* });
*
* @example
* // Constructor mode (authenticated)
* const { project, pages, isLoading, error, reload } = usePageDataLoader({
* projectId: 'uuid-here',
* environment: 'dev',
* enabled: isAuthReady,
* });
*/
export function usePageDataLoader({
projectId,
projectSlug,
environment,
enabled = true,
apiHeaders = {},
initialPageId: initialPageIdFromProps = '',
}: UsePageDataLoaderOptions): UsePageDataLoaderResult {
const [project, setProject] = useState<RuntimeProject | null>(null);
const [pages, setPages] = useState<RuntimePage[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState('');
const [selectedInitialPageId, setSelectedInitialPageId] = useState('');
// Memoize API config to prevent unnecessary reloads
const apiConfig = useMemo(
() => ({
headers: apiHeaders,
}),
// Serialize headers for comparison
// eslint-disable-next-line react-hooks/exhaustive-deps
[JSON.stringify(apiHeaders)],
);
/**
* Load project and pages data
*/
const loadData = useCallback(
async (preservePageId?: string) => {
// Need either projectId or projectSlug
if (!projectId && !projectSlug) {
setError('No project identifier provided.');
setIsLoading(false);
return;
}
if (!enabled) {
return;
}
try {
setIsLoading(true);
setError('');
let foundProject: RuntimeProject | null = null;
// Load by ID (constructor mode)
if (projectId) {
const projectResponse = await axios.get(
`/projects/${projectId}`,
apiConfig,
);
foundProject = projectResponse.data;
}
// Load by slug (runtime mode)
else if (projectSlug) {
const projectsResponse = await axios.get('/projects', {
...apiConfig,
params: { slug: projectSlug },
});
const projectRows = getRows(projectsResponse) as RuntimeProject[];
foundProject =
projectRows.find((p) => p.slug === projectSlug) || null;
if (!foundProject) {
setError(`Project "${projectSlug}" not found.`);
setIsLoading(false);
return;
}
}
if (!foundProject) {
setError('Project not found.');
setIsLoading(false);
return;
}
setProject(foundProject);
// Load pages for this project
const pagesParams: Record<string, string> = {
project: foundProject.id,
};
// For constructor mode, also filter by environment in params
if (projectId) {
pagesParams.environment = environment;
pagesParams.limit = '500';
pagesParams.sort = 'asc';
pagesParams.field = 'sort_order';
}
const pagesResponse = await axios.get('/tour_pages', {
...apiConfig,
params: pagesParams,
});
let pageRows = getRows(pagesResponse) as RuntimePage[];
// For runtime mode, filter by environment client-side
// (backend may not have environment header support)
if (projectSlug) {
pageRows = pageRows.filter((p) => p.environment === environment);
}
// Sort by sort_order
pageRows.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
setPages(pageRows);
// Determine initial page
const preservedPageExists =
preservePageId && pageRows.some((p) => p.id === preservePageId);
const defaultPageId = preservedPageExists
? preservePageId
: initialPageIdFromProps ||
(pageRows.length > 0 ? pageRows[0].id : '');
setSelectedInitialPageId(defaultPageId);
} catch (err: unknown) {
const axiosError = err as {
response?: { status?: number; data?: { message?: string } };
message?: string;
};
// Handle authentication errors
if (axiosError?.response?.status === 401) {
setError('Your session has expired. Please sign in again.');
logger.error('Unauthorized request during data load');
return;
}
const message =
axiosError?.response?.data?.message ||
axiosError?.message ||
'Failed to load presentation data.';
logger.error(
'Failed to load page data:',
err instanceof Error ? err : { error: err },
);
setError(message);
setPages([]);
} finally {
setIsLoading(false);
}
},
[
projectId,
projectSlug,
environment,
enabled,
apiConfig,
initialPageIdFromProps,
],
);
// Initial load
useEffect(() => {
loadData();
}, [loadData]);
return {
project,
pages,
isLoading,
error,
reload: loadData,
initialPageId: selectedInitialPageId,
};
}

View File

@ -611,6 +611,12 @@ export function usePreloadOrchestrator(
) {
storagePaths.push(currentPage.background_video_url);
}
if (
currentPage?.background_audio_url &&
isRelativeStoragePath(currentPage.background_audio_url)
) {
storagePaths.push(currentPage.background_audio_url);
}
assets.forEach((asset) => {
if (isRelativeStoragePath(asset.url)) {
@ -618,18 +624,32 @@ export function usePreloadOrchestrator(
}
});
if (shouldPreloadAggressively) {
const neighbors = neighborGraph.getNeighbors(currentPageId, 1);
neighbors.forEach(({ pageId }) => {
const page = pages.find((p) => p.id === pageId);
if (
page?.background_image_url &&
isRelativeStoragePath(page.background_image_url)
) {
storagePaths.push(page.background_image_url);
}
});
}
// Always collect neighbor background URLs for presigning
// This ensures instant navigation to neighbor pages
const neighbors = neighborGraph.getNeighbors(currentPageId, 1);
neighbors.forEach(({ pageId }) => {
const page = pages.find((p) => p.id === pageId);
if (
page?.background_image_url &&
isRelativeStoragePath(page.background_image_url)
) {
storagePaths.push(page.background_image_url);
}
// Always collect neighbor video URLs for smooth transitions
if (
page?.background_video_url &&
isRelativeStoragePath(page.background_video_url)
) {
storagePaths.push(page.background_video_url);
}
// Also collect neighbor audio URLs
if (
page?.background_audio_url &&
isRelativeStoragePath(page.background_audio_url)
) {
storagePaths.push(page.background_audio_url);
}
});
// Batch fetch presigned URLs, then add to queue
// Helper to resolve URL - prefer presigned if available, else fallback to proxy
@ -679,6 +699,22 @@ export function usePreloadOrchestrator(
});
}
}
if (currentPage?.background_audio_url) {
const storageKey = currentPage.background_audio_url;
const resolvedUrl = resolveUrl(storageKey, presignedUrls);
if (resolvedUrl) {
addToQueue({
id: `bg-aud-${currentPageId}`,
url: resolvedUrl,
storageKey: isRelativeStoragePath(storageKey)
? storageKey
: undefined,
priority: PRELOAD_CONFIG.priority.currentPage + 100,
assetType: 'audio',
pageId: currentPageId,
});
}
}
// Add element assets
assets.forEach((asset) => {
@ -698,29 +734,63 @@ export function usePreloadOrchestrator(
}
});
// If aggressive preloading, also preload neighbor backgrounds
if (shouldPreloadAggressively) {
const neighbors = neighborGraph.getNeighbors(currentPageId, 1);
neighbors.forEach(({ pageId }) => {
const page = pages.find((p) => p.id === pageId);
if (page?.background_image_url) {
const storageKey = page.background_image_url;
const resolvedUrl = resolveUrl(storageKey, presignedUrls);
if (resolvedUrl) {
addToQueue({
id: `bg-img-${pageId}`,
url: resolvedUrl,
storageKey: isRelativeStoragePath(storageKey)
? storageKey
: undefined,
priority: PRELOAD_CONFIG.priority.neighborBase,
assetType: 'image',
pageId,
});
}
// Always preload immediate neighbor backgrounds for smooth navigation
// This is critical for instant page switches without white flash
const neighbors = neighborGraph.getNeighbors(currentPageId, 1);
neighbors.forEach(({ pageId }) => {
const page = pages.find((p) => p.id === pageId);
if (page?.background_image_url) {
const storageKey = page.background_image_url;
const resolvedUrl = resolveUrl(storageKey, presignedUrls);
if (resolvedUrl) {
addToQueue({
id: `bg-img-${pageId}`,
url: resolvedUrl,
storageKey: isRelativeStoragePath(storageKey)
? storageKey
: undefined,
// Neighbor backgrounds get high priority (just below current page)
priority: PRELOAD_CONFIG.priority.neighborBase + 100,
assetType: 'image',
pageId,
});
}
});
}
}
// Always preload neighbor videos for smooth page transitions
if (page?.background_video_url) {
const storageKey = page.background_video_url;
const resolvedUrl = resolveUrl(storageKey, presignedUrls);
if (resolvedUrl) {
addToQueue({
id: `bg-vid-${pageId}`,
url: resolvedUrl,
storageKey: isRelativeStoragePath(storageKey)
? storageKey
: undefined,
priority: PRELOAD_CONFIG.priority.neighborBase + 50,
assetType: 'video',
pageId,
});
}
}
// Always preload neighbor audio for seamless playback
if (page?.background_audio_url) {
const storageKey = page.background_audio_url;
const resolvedUrl = resolveUrl(storageKey, presignedUrls);
if (resolvedUrl) {
addToQueue({
id: `bg-aud-${pageId}`,
url: resolvedUrl,
storageKey: isRelativeStoragePath(storageKey)
? storageKey
: undefined,
priority: PRELOAD_CONFIG.priority.neighborBase + 30,
assetType: 'audio',
pageId,
});
}
}
});
};
// If there are storage paths to presign, fetch them first
@ -755,7 +825,6 @@ export function usePreloadOrchestrator(
pages,
pageLinks,
addToQueue,
shouldPreloadAggressively,
maxNeighborDepth,
]);

View File

@ -0,0 +1,158 @@
/**
* Media Helpers
*
* Utilities for media duration probing and formatting.
* Used by constructor.tsx for displaying video/audio duration info.
*/
import axios from 'axios';
import { resolveAssetPlaybackUrl } from './assetUrl';
import { logger } from './logger';
/**
* Format duration in seconds to human-readable string.
*
* @param durationSec - Duration in seconds (or null/undefined)
* @returns Formatted string like "Duration: 1:30" or "Duration: unknown"
*
* @example
* formatDurationNote(90); // "Duration: 1:30"
* formatDurationNote(45); // "Duration: 45s"
* formatDurationNote(null); // "Duration: unknown"
*/
export const formatDurationNote = (
durationSec?: number | string | null,
): string => {
const parsed = Number(durationSec);
if (!Number.isFinite(parsed) || parsed <= 0) return 'Duration: unknown';
const totalSeconds = Math.round(parsed);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes <= 0) return `Duration: ${seconds}s`;
return `Duration: ${minutes}:${String(seconds).padStart(2, '0')}`;
};
/**
* Read media duration from a URL by loading metadata.
*
* Creates a temporary video/audio element to probe the duration.
* Times out after 12 seconds if metadata doesn't load.
*
* @param playbackUrl - URL to the media file
* @param mediaType - Type of media ('video' or 'audio')
* @returns Promise resolving to duration in seconds, or null if failed
*
* @example
* const duration = await readMediaDuration('https://example.com/video.mp4', 'video');
*/
export const readMediaDuration = (
playbackUrl: string,
mediaType: 'video' | 'audio',
): Promise<number | null> =>
new Promise((resolve) => {
const mediaElement =
mediaType === 'video'
? document.createElement('video')
: document.createElement('audio');
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const cleanup = () => {
mediaElement.removeEventListener('loadedmetadata', onLoadedMetadata);
mediaElement.removeEventListener('error', onError);
mediaElement.removeEventListener('abort', onError);
if (timeoutId) clearTimeout(timeoutId);
mediaElement.pause();
mediaElement.removeAttribute('src');
mediaElement.load();
};
const onLoadedMetadata = () => {
const duration = Number(mediaElement.duration);
cleanup();
if (Number.isFinite(duration) && duration > 0) {
resolve(duration);
return;
}
resolve(null);
};
const onError = () => {
cleanup();
resolve(null);
};
timeoutId = setTimeout(() => {
cleanup();
resolve(null);
}, 12000);
mediaElement.preload = 'metadata';
mediaElement.crossOrigin = 'anonymous';
mediaElement.addEventListener('loadedmetadata', onLoadedMetadata);
mediaElement.addEventListener('error', onError);
mediaElement.addEventListener('abort', onError);
mediaElement.src = playbackUrl;
mediaElement.load();
});
/**
* Resolve media duration with blob fallback.
*
* First tries to load duration directly from the URL.
* If that fails (e.g., CORS issues), downloads as blob and retries.
*
* @param source - Storage path or URL to the media
* @param mediaType - Type of media ('video' or 'audio')
* @returns Promise resolving to duration in seconds, or null if failed
*
* @example
* const duration = await resolveDurationWithFallback('assets/video.mp4', 'video');
*/
export const resolveDurationWithFallback = async (
source: string,
mediaType: 'video' | 'audio',
): Promise<number | null> => {
const playbackUrl = resolveAssetPlaybackUrl(source);
if (!playbackUrl) return null;
// Try direct metadata loading first
const directDuration = await readMediaDuration(playbackUrl, mediaType);
if (Number.isFinite(directDuration) && Number(directDuration) > 0) {
return Number(directDuration);
}
// Fallback: download as blob and probe
try {
const requestUrl =
playbackUrl.startsWith('http://') || playbackUrl.startsWith('https://')
? playbackUrl
: playbackUrl.replace(/^\/api(?=\/)/, '');
const token =
typeof window !== 'undefined' ? localStorage.getItem('token') || '' : '';
const response = await axios.get(requestUrl, {
responseType: 'blob',
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
});
const blobUrl = URL.createObjectURL(response.data);
try {
const blobDuration = await readMediaDuration(blobUrl, mediaType);
if (Number.isFinite(blobDuration) && Number(blobDuration) > 0) {
return Number(blobDuration);
}
return null;
} finally {
URL.revokeObjectURL(blobUrl);
}
} catch (error) {
logger.error(
'Failed to fetch media for duration probing:',
error instanceof Error ? error : { error },
);
return null;
}
};

View File

@ -0,0 +1,136 @@
/**
* Navigation Helpers
*
* Shared utilities for page navigation in RuntimePresentation and constructor.tsx.
* Handles target page resolution, back navigation detection, and transition blocking.
*/
import type { RuntimePage } from '../types/runtime';
import type {
NavigableElement,
NavigationTarget,
TransitionPhase,
} from '../types/presentation';
/**
* Resolve target page from element navigation properties.
* Supports both targetPageSlug (new) and targetPageId (legacy).
*
* @param element - Element with navigation properties
* @param pages - Available pages to search
* @returns The target page or undefined if not found
*/
export const resolveNavigationTarget = (
element: NavigableElement,
pages: RuntimePage[],
): NavigationTarget | null => {
const targetPageSlug = element.targetPageSlug;
const legacyTargetPageId = element.targetPageId;
let targetPage: RuntimePage | undefined;
if (targetPageSlug) {
targetPage = pages.find((p) => p.slug === targetPageSlug);
} else if (legacyTargetPageId) {
targetPage = pages.find((p) => p.id === legacyTargetPageId);
}
if (!targetPage) {
return null;
}
const isBack = isBackNavigation(element);
return {
page: targetPage,
pageId: targetPage.id,
transitionVideoUrl: element.transitionVideoUrl,
isBack,
};
};
/**
* Determine if navigation direction is "back".
* Elements with navType='back' or type='navigation_prev' navigate backwards.
*
* @param element - Element to check
* @returns true if this is a back navigation
*/
export const isBackNavigation = (element: NavigableElement): boolean => {
return element.navType === 'back' || element.type === 'navigation_prev';
};
/**
* Get navigation direction based on element properties.
*
* @param element - Element with navigation properties
* @returns 'back' or 'forward'
*/
export const getNavigationDirection = (
element: NavigableElement,
): 'back' | 'forward' => {
return isBackNavigation(element) ? 'back' : 'forward';
};
/**
* Check if transition is actively blocking navigation.
* Navigation should be blocked during preparing, playing, or reversing phases.
*
* @param transitionPhase - Current transition phase
* @param isBuffering - Whether video is buffering
* @returns true if navigation should be blocked
*/
export const isTransitionBlocking = (
transitionPhase: TransitionPhase,
isBuffering: boolean,
): boolean => {
const activePhases: TransitionPhase[] = ['preparing', 'playing', 'reversing'];
return activePhases.includes(transitionPhase) || isBuffering;
};
/**
* Check if element has a playable transition.
* A transition is playable if it has a video URL, and for back navigation,
* either supports reverse or has a separate reverse video.
*
* @param element - Element with transition properties
* @param direction - Navigation direction
* @returns true if element has a playable transition
*/
export const hasPlayableTransition = (
element: {
transitionVideoUrl?: string;
transitionReverseMode?: string;
reverseVideoUrl?: string;
},
direction: 'back' | 'forward' = 'forward',
): boolean => {
if (!element.transitionVideoUrl) {
return false;
}
// For back navigation with separate_video mode, need reverse video
if (
direction === 'back' &&
element.transitionReverseMode === 'separate_video' &&
!element.reverseVideoUrl
) {
return false;
}
return true;
};
/**
* Check if element is a navigation element type.
*
* @param elementType - Element type to check
* @returns true if element is a navigation type
*/
export const isNavigationType = (elementType: string): boolean => {
return (
elementType === 'navigation_next' ||
elementType === 'navigation_prev' ||
elementType === 'navigation'
);
};

View File

@ -37,10 +37,20 @@ import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
import { extractPageLinksAndElements } from '../lib/extractPageLinks';
import { usePageSwitch } from '../hooks/usePageSwitch';
import { useTransitionPlayback } from '../hooks/useTransitionPlayback';
import { useBackgroundTransition } from '../hooks/useBackgroundTransition';
import { logger } from '../lib/logger';
import { resolveAssetPlaybackUrl } from '../lib/assetUrl';
import { parseJsonObject } from '../lib/parseJson';
import { buildElementStyle } from '../lib/elementStyles';
import {
resolveNavigationTarget,
hasPlayableTransition,
getNavigationDirection,
} from '../lib/navigationHelpers';
import {
formatDurationNote,
resolveDurationWithFallback,
} from '../lib/mediaHelpers';
import {
createDefaultElement,
mergeElementWithDefaults,
@ -142,113 +152,6 @@ const getAssetLabel = (asset: ProjectAsset) => {
const getAssetSourceValue = (asset: ProjectAsset) =>
String(asset.storage_key || asset.cdn_url || '').trim();
const formatDurationNote = (durationSec?: number | string | null) => {
const parsed = Number(durationSec);
if (!Number.isFinite(parsed) || parsed <= 0) return 'Duration: unknown';
const totalSeconds = Math.round(parsed);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes <= 0) return `Duration: ${seconds}s`;
return `Duration: ${minutes}:${String(seconds).padStart(2, '0')}`;
};
const readMediaDuration = (
playbackUrl: string,
mediaType: 'video' | 'audio',
): Promise<number | null> =>
new Promise((resolve) => {
const mediaElement =
mediaType === 'video'
? document.createElement('video')
: document.createElement('audio');
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const cleanup = () => {
mediaElement.removeEventListener('loadedmetadata', onLoadedMetadata);
mediaElement.removeEventListener('error', onError);
mediaElement.removeEventListener('abort', onError);
if (timeoutId) clearTimeout(timeoutId);
mediaElement.pause();
mediaElement.removeAttribute('src');
mediaElement.load();
};
const onLoadedMetadata = () => {
const duration = Number(mediaElement.duration);
cleanup();
if (Number.isFinite(duration) && duration > 0) {
resolve(duration);
return;
}
resolve(null);
};
const onError = () => {
cleanup();
resolve(null);
};
timeoutId = setTimeout(() => {
cleanup();
resolve(null);
}, 12000);
mediaElement.preload = 'metadata';
mediaElement.crossOrigin = 'anonymous';
mediaElement.addEventListener('loadedmetadata', onLoadedMetadata);
mediaElement.addEventListener('error', onError);
mediaElement.addEventListener('abort', onError);
mediaElement.src = playbackUrl;
mediaElement.load();
});
const resolveDurationWithFallback = async (
source: string,
mediaType: 'video' | 'audio',
) => {
const playbackUrl = resolveAssetPlaybackUrl(source);
if (!playbackUrl) return null;
const directDuration = await readMediaDuration(playbackUrl, mediaType);
if (Number.isFinite(directDuration) && Number(directDuration) > 0) {
return Number(directDuration);
}
try {
const requestUrl =
playbackUrl.startsWith('http://') || playbackUrl.startsWith('https://')
? playbackUrl
: playbackUrl.replace(/^\/api(?=\/)/, '');
const token =
typeof window !== 'undefined' ? localStorage.getItem('token') || '' : '';
const response = await axios.get(requestUrl, {
responseType: 'blob',
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
});
const blobUrl = URL.createObjectURL(response.data);
try {
const blobDuration = await readMediaDuration(blobUrl, mediaType);
if (Number.isFinite(blobDuration) && Number(blobDuration) > 0) {
return Number(blobDuration);
}
return null;
} finally {
URL.revokeObjectURL(blobUrl);
}
} catch (error) {
logger.error(
'Failed to fetch media for duration probing:',
error instanceof Error ? error : { error },
);
return null;
}
};
const isBackgroundImageAsset = (asset: ProjectAsset) => {
if (asset.type) return asset.type === 'background_image';
const normalizedName = String(asset.name || '').toLowerCase();
@ -531,21 +434,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
},
});
// Clear previous background overlay when new background is ready (direct navigation)
useEffect(() => {
if (
pageSwitch.isSwitching &&
pageSwitch.isNewBgReady &&
pageSwitch.previousBgImageUrl
) {
pageSwitch.clearPreviousBackground();
}
}, [
pageSwitch.isSwitching,
pageSwitch.isNewBgReady,
pageSwitch.previousBgImageUrl,
pageSwitch.clearPreviousBackground,
]);
// Use shared background transition hook for direct navigation clearing
// (No fade-out needed in constructor - transitions complete immediately)
useBackgroundTransition({ pageSwitch });
const isConstructorEditMode = constructorInteractionMode === 'edit';
const allowedNavigationTypes = useMemo<NavigationElementType[]>(() => {
@ -1856,42 +1747,24 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
if (element.navDisabled) {
return;
}
const direction =
element.navType === 'back' || element.type === 'navigation_prev'
? 'back'
: 'forward';
// Support both targetPageSlug (new) and targetPageId (legacy)
const targetPageSlug = String(element.targetPageSlug || '').trim();
const legacyTargetPageId = String(element.targetPageId || '').trim();
// Resolve slug to page ID, or use legacy targetPageId
const targetPage = targetPageSlug
? pages.find((p) => p.slug === targetPageSlug)
: legacyTargetPageId
? pages.find((p) => p.id === legacyTargetPageId)
: null;
const targetPageId = targetPage?.id || '';
// Use shared navigation helpers
const direction = getNavigationDirection(element);
const navTarget = resolveNavigationTarget(element, pages);
if (!targetPageId) {
if (!navTarget) {
setErrorMessage(
'No target page configured for this navigation button.',
);
return;
}
const hasPlayableTransition =
Boolean(element.transitionVideoUrl) &&
!(
direction === 'back' &&
element.transitionReverseMode === 'separate_video' &&
!element.reverseVideoUrl
);
if (!hasPlayableTransition) {
// Check if transition can be played using shared helper
if (!hasPlayableTransition(element, direction)) {
setPendingNavigationPageId('');
setTransitionPreview(null);
// Use switchToPage which resolves blob URLs via usePageSwitch (reduces flash)
switchToPage(targetPage).then(() => {
switchToPage(navTarget.page).then(() => {
setSelectedElementId('');
setSelectedMenuItem('none');
setErrorMessage('');
@ -1899,7 +1772,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
return;
}
setPendingNavigationPageId(targetPageId);
setPendingNavigationPageId(navTarget.pageId);
openTransitionPreviewForElement(element, direction);
}
return;

View File

@ -11,6 +11,7 @@ export interface PreloadPage {
id: string;
background_image_url?: string;
background_video_url?: string;
background_audio_url?: string;
}
/**

View File

@ -0,0 +1,107 @@
/**
* Presentation Types
*
* Shared types for RuntimePresentation and constructor.tsx components.
* These types facilitate code sharing between the two main presentation components.
*/
import type { RuntimePage } from './runtime';
/**
* Transition preview state for video transitions
*/
export interface TransitionPreviewState {
targetPageId: string;
videoUrl: string;
storageKey: string;
isReverse: boolean;
}
/**
* Background state for page display
*/
export interface BackgroundState {
imageUrl: string;
videoUrl: string;
audioUrl: string;
}
/**
* Navigation target resolved from element click
*/
export interface NavigationTarget {
page: RuntimePage;
pageId: string;
transitionVideoUrl?: string;
isBack: boolean;
}
/**
* API configuration for runtime requests
*/
export interface RuntimeApiConfig {
headers: {
'X-Runtime-Project-Slug'?: string;
'X-Runtime-Environment'?: string;
};
}
/**
* Page data loader result
*/
export interface PageDataLoaderResult {
project: RuntimeProject | null;
pages: RuntimePage[];
isLoading: boolean;
error: string;
reload: (preservePageId?: string) => Promise<void>;
}
/**
* Runtime project for page data loader
*/
export interface RuntimeProject {
id: string;
name?: string;
slug?: string;
description?: string;
}
/**
* Canvas element with navigation properties (for click handling)
*/
export interface NavigableElement {
id: string;
type: string;
targetPageSlug?: string;
targetPageId?: string;
transitionVideoUrl?: string;
navType?: 'forward' | 'back';
navDisabled?: boolean;
}
/**
* Transition phase from useTransitionPlayback
*/
export type TransitionPhase =
| 'idle'
| 'preparing'
| 'playing'
| 'reversing'
| 'completed';
/**
* Background transition options
*/
export interface BackgroundTransitionOptions {
pendingTransitionComplete: boolean;
isBackgroundReady: boolean;
transitionVideoRef: React.RefObject<HTMLVideoElement | null>;
pageSwitch: {
clearPreviousBackground: () => void;
isSwitching: boolean;
isNewBgReady: boolean;
previousBgImageUrl: string;
};
onCleanup: () => void;
}