improved preloading logic
This commit is contained in:
parent
b925094555
commit
eac21c84b3
@ -211,6 +211,18 @@ class PWAManifestService {
|
|||||||
[page.id],
|
[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
|
// Extract URLs from ui_schema_json elements
|
||||||
try {
|
try {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@ -6,7 +6,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { mdiFullscreen, mdiFullscreenExit } from '@mdi/js';
|
import { mdiFullscreen, mdiFullscreenExit } from '@mdi/js';
|
||||||
import axios from 'axios';
|
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import React, {
|
import React, {
|
||||||
@ -25,28 +24,44 @@ import { ElementContentRenderer } from './UiElements/ElementContentRenderer';
|
|||||||
import LayoutGuest from '../layouts/Guest';
|
import LayoutGuest from '../layouts/Guest';
|
||||||
import { getPageTitle } from '../config';
|
import { getPageTitle } from '../config';
|
||||||
import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
|
import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
|
||||||
|
import { usePageDataLoader } from '../hooks/usePageDataLoader';
|
||||||
import { extractPageLinksAndElements } from '../lib/extractPageLinks';
|
import { extractPageLinksAndElements } from '../lib/extractPageLinks';
|
||||||
import { usePageSwitch } from '../hooks/usePageSwitch';
|
import { usePageSwitch } from '../hooks/usePageSwitch';
|
||||||
import { useTransitionPlayback } from '../hooks/useTransitionPlayback';
|
import { useTransitionPlayback } from '../hooks/useTransitionPlayback';
|
||||||
|
import { useBackgroundTransition } from '../hooks/useBackgroundTransition';
|
||||||
import { resolveAssetPlaybackUrl } from '../lib/assetUrl';
|
import { resolveAssetPlaybackUrl } from '../lib/assetUrl';
|
||||||
import { logger } from '../lib/logger';
|
import { logger } from '../lib/logger';
|
||||||
// buildElementStyle is now used in RuntimeElement component
|
import {
|
||||||
import type { RuntimeProject, RuntimePage } from '../types/runtime';
|
resolveNavigationTarget,
|
||||||
|
isTransitionBlocking,
|
||||||
|
} from '../lib/navigationHelpers';
|
||||||
|
import type { TransitionPhase } from '../types/presentation';
|
||||||
|
|
||||||
interface RuntimePresentationProps {
|
interface RuntimePresentationProps {
|
||||||
projectSlug: string;
|
projectSlug: string;
|
||||||
environment: 'stage' | 'production';
|
environment: 'stage' | 'production';
|
||||||
}
|
}
|
||||||
|
|
||||||
const getRows = (response: any) =>
|
|
||||||
Array.isArray(response?.data?.rows) ? response.data.rows : [];
|
|
||||||
|
|
||||||
export default function RuntimePresentation({
|
export default function RuntimePresentation({
|
||||||
projectSlug,
|
projectSlug,
|
||||||
environment,
|
environment,
|
||||||
}: RuntimePresentationProps) {
|
}: RuntimePresentationProps) {
|
||||||
const [project, setProject] = useState<RuntimeProject | null>(null);
|
// Use shared hook for loading project and pages data
|
||||||
const [pages, setPages] = useState<RuntimePage[]>([]);
|
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 [selectedPageId, setSelectedPageId] = useState<string | null>(null);
|
||||||
const [pageHistory, setPageHistory] = useState<string[]>([]);
|
const [pageHistory, setPageHistory] = useState<string[]>([]);
|
||||||
const [transitionPreview, setTransitionPreview] = useState<{
|
const [transitionPreview, setTransitionPreview] = useState<{
|
||||||
@ -55,27 +70,21 @@ export default function RuntimePresentation({
|
|||||||
storageKey: string;
|
storageKey: string;
|
||||||
isReverse: boolean;
|
isReverse: boolean;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
const [isBackgroundReady, setIsBackgroundReady] = useState(true);
|
const [isBackgroundReady, setIsBackgroundReady] = useState(true);
|
||||||
const [pendingTransitionComplete, setPendingTransitionComplete] =
|
const [pendingTransitionComplete, setPendingTransitionComplete] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const [isOverlayFadingOut, setIsOverlayFadingOut] = useState(false);
|
|
||||||
|
|
||||||
const transitionVideoRef = useRef<HTMLVideoElement>(null);
|
const transitionVideoRef = useRef<HTMLVideoElement>(null);
|
||||||
const lastInitializedPageIdRef = useRef<string | null>(null);
|
const lastInitializedPageIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
// API request config with custom headers for project/environment
|
// Set initial page when data loads
|
||||||
const apiConfig = useMemo(
|
useEffect(() => {
|
||||||
() => ({
|
if (initialPageId && !selectedPageId) {
|
||||||
headers: {
|
setSelectedPageId(initialPageId);
|
||||||
'X-Runtime-Project-Slug': projectSlug,
|
setPageHistory([initialPageId]);
|
||||||
'X-Runtime-Environment': environment,
|
}
|
||||||
},
|
}, [initialPageId, selectedPageId]);
|
||||||
}),
|
|
||||||
[projectSlug, environment],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Extract page links and preload elements from ui_schema_json
|
// Extract page links and preload elements from ui_schema_json
|
||||||
// This enables the neighbor graph to find connected pages for preloading
|
// 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 () => {
|
const toggleFullscreen = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
if (!document.fullscreenElement) {
|
if (!document.fullscreenElement) {
|
||||||
@ -190,126 +213,6 @@ export default function RuntimePresentation({
|
|||||||
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
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(
|
const selectedPage = useMemo(
|
||||||
() => pages.find((p) => p.id === selectedPageId) || null,
|
() => pages.find((p) => p.id === selectedPageId) || null,
|
||||||
[pages, selectedPageId],
|
[pages, selectedPageId],
|
||||||
@ -371,8 +274,8 @@ export default function RuntimePresentation({
|
|||||||
|
|
||||||
if (transitionVideoUrl) {
|
if (transitionVideoUrl) {
|
||||||
// Reset states from previous transition before starting new one
|
// Reset states from previous transition before starting new one
|
||||||
// This prevents the fade-out effect from re-triggering when isOverlayFadingOut resets
|
// This prevents the fade-out effect from re-triggering
|
||||||
setIsOverlayFadingOut(false);
|
resetFadeOut();
|
||||||
setPendingTransitionComplete(false);
|
setPendingTransitionComplete(false);
|
||||||
// Play transition using useTransitionPlayback hook
|
// Play transition using useTransitionPlayback hook
|
||||||
setTransitionPreview({
|
setTransitionPreview({
|
||||||
@ -394,50 +297,35 @@ export default function RuntimePresentation({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[pages, pageSwitch],
|
[pages, pageSwitch, resetFadeOut],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleElementClick = useCallback(
|
const handleElementClick = useCallback(
|
||||||
(element: any) => {
|
(element: any) => {
|
||||||
// Disable navigation while transition is actively playing or buffering
|
// Block navigation while transition is actively playing or buffering
|
||||||
// Only block during active phases, not during fade-out (completed phase)
|
if (isTransitionBlocking(transitionPhase as TransitionPhase, isBuffering)) {
|
||||||
const isActivelyPlaying =
|
|
||||||
transitionPhase === 'preparing' ||
|
|
||||||
transitionPhase === 'playing' ||
|
|
||||||
transitionPhase === 'reversing';
|
|
||||||
if (isActivelyPlaying || isBuffering) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Support both targetPageSlug (new) and targetPageId (legacy)
|
// Use shared helper to resolve navigation target
|
||||||
const targetPageSlug = element.targetPageSlug;
|
const navTarget = resolveNavigationTarget(element, pages);
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debug: log element navigation data
|
// Debug: log element navigation data
|
||||||
logger.info('Element clicked', {
|
logger.info('Element clicked', {
|
||||||
elementType: element.type,
|
elementType: element.type,
|
||||||
targetPageSlug,
|
targetPageSlug: element.targetPageSlug,
|
||||||
legacyTargetPageId,
|
legacyTargetPageId: element.targetPageId,
|
||||||
resolvedTargetPageId: targetPageId,
|
resolvedTargetPageId: navTarget?.pageId,
|
||||||
transitionVideoUrl: element.transitionVideoUrl,
|
transitionVideoUrl: element.transitionVideoUrl,
|
||||||
hasTransition: Boolean(element.transitionVideoUrl),
|
hasTransition: Boolean(element.transitionVideoUrl),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (targetPageId) {
|
if (navTarget) {
|
||||||
const isBack =
|
navigateToPage(
|
||||||
element.navType === 'back' || element.type === 'navigation_prev';
|
navTarget.pageId,
|
||||||
// Get transition video URL from element itself
|
navTarget.transitionVideoUrl,
|
||||||
const transitionVideoUrl = element.transitionVideoUrl;
|
navTarget.isBack,
|
||||||
navigateToPage(targetPageId, transitionVideoUrl, isBack);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[navigateToPage, pages, transitionPhase, isBuffering],
|
[navigateToPage, pages, transitionPhase, isBuffering],
|
||||||
|
|||||||
@ -20,3 +20,14 @@ export type {
|
|||||||
UsePageNavigationOptions,
|
UsePageNavigationOptions,
|
||||||
UsePageNavigationResult,
|
UsePageNavigationResult,
|
||||||
} from './usePageNavigation';
|
} from './usePageNavigation';
|
||||||
|
export { useBackgroundTransition } from './useBackgroundTransition';
|
||||||
|
export type {
|
||||||
|
FadeOutConfig,
|
||||||
|
UseBackgroundTransitionOptions,
|
||||||
|
UseBackgroundTransitionResult,
|
||||||
|
} from './useBackgroundTransition';
|
||||||
|
export { usePageDataLoader } from './usePageDataLoader';
|
||||||
|
export type {
|
||||||
|
UsePageDataLoaderOptions,
|
||||||
|
UsePageDataLoaderResult,
|
||||||
|
} from './usePageDataLoader';
|
||||||
|
|||||||
164
frontend/src/hooks/useBackgroundTransition.ts
Normal file
164
frontend/src/hooks/useBackgroundTransition.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
253
frontend/src/hooks/usePageDataLoader.ts
Normal file
253
frontend/src/hooks/usePageDataLoader.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -611,6 +611,12 @@ export function usePreloadOrchestrator(
|
|||||||
) {
|
) {
|
||||||
storagePaths.push(currentPage.background_video_url);
|
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) => {
|
assets.forEach((asset) => {
|
||||||
if (isRelativeStoragePath(asset.url)) {
|
if (isRelativeStoragePath(asset.url)) {
|
||||||
@ -618,18 +624,32 @@ export function usePreloadOrchestrator(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (shouldPreloadAggressively) {
|
// Always collect neighbor background URLs for presigning
|
||||||
const neighbors = neighborGraph.getNeighbors(currentPageId, 1);
|
// This ensures instant navigation to neighbor pages
|
||||||
neighbors.forEach(({ pageId }) => {
|
const neighbors = neighborGraph.getNeighbors(currentPageId, 1);
|
||||||
const page = pages.find((p) => p.id === pageId);
|
neighbors.forEach(({ pageId }) => {
|
||||||
if (
|
const page = pages.find((p) => p.id === pageId);
|
||||||
page?.background_image_url &&
|
if (
|
||||||
isRelativeStoragePath(page.background_image_url)
|
page?.background_image_url &&
|
||||||
) {
|
isRelativeStoragePath(page.background_image_url)
|
||||||
storagePaths.push(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
|
// Batch fetch presigned URLs, then add to queue
|
||||||
// Helper to resolve URL - prefer presigned if available, else fallback to proxy
|
// 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
|
// Add element assets
|
||||||
assets.forEach((asset) => {
|
assets.forEach((asset) => {
|
||||||
@ -698,29 +734,63 @@ export function usePreloadOrchestrator(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// If aggressive preloading, also preload neighbor backgrounds
|
// Always preload immediate neighbor backgrounds for smooth navigation
|
||||||
if (shouldPreloadAggressively) {
|
// This is critical for instant page switches without white flash
|
||||||
const neighbors = neighborGraph.getNeighbors(currentPageId, 1);
|
const neighbors = neighborGraph.getNeighbors(currentPageId, 1);
|
||||||
neighbors.forEach(({ pageId }) => {
|
neighbors.forEach(({ pageId }) => {
|
||||||
const page = pages.find((p) => p.id === pageId);
|
const page = pages.find((p) => p.id === pageId);
|
||||||
if (page?.background_image_url) {
|
if (page?.background_image_url) {
|
||||||
const storageKey = page.background_image_url;
|
const storageKey = page.background_image_url;
|
||||||
const resolvedUrl = resolveUrl(storageKey, presignedUrls);
|
const resolvedUrl = resolveUrl(storageKey, presignedUrls);
|
||||||
if (resolvedUrl) {
|
if (resolvedUrl) {
|
||||||
addToQueue({
|
addToQueue({
|
||||||
id: `bg-img-${pageId}`,
|
id: `bg-img-${pageId}`,
|
||||||
url: resolvedUrl,
|
url: resolvedUrl,
|
||||||
storageKey: isRelativeStoragePath(storageKey)
|
storageKey: isRelativeStoragePath(storageKey)
|
||||||
? storageKey
|
? storageKey
|
||||||
: undefined,
|
: undefined,
|
||||||
priority: PRELOAD_CONFIG.priority.neighborBase,
|
// Neighbor backgrounds get high priority (just below current page)
|
||||||
assetType: 'image',
|
priority: PRELOAD_CONFIG.priority.neighborBase + 100,
|
||||||
pageId,
|
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
|
// If there are storage paths to presign, fetch them first
|
||||||
@ -755,7 +825,6 @@ export function usePreloadOrchestrator(
|
|||||||
pages,
|
pages,
|
||||||
pageLinks,
|
pageLinks,
|
||||||
addToQueue,
|
addToQueue,
|
||||||
shouldPreloadAggressively,
|
|
||||||
maxNeighborDepth,
|
maxNeighborDepth,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
158
frontend/src/lib/mediaHelpers.ts
Normal file
158
frontend/src/lib/mediaHelpers.ts
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
136
frontend/src/lib/navigationHelpers.ts
Normal file
136
frontend/src/lib/navigationHelpers.ts
Normal 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'
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -37,10 +37,20 @@ import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
|
|||||||
import { extractPageLinksAndElements } from '../lib/extractPageLinks';
|
import { extractPageLinksAndElements } from '../lib/extractPageLinks';
|
||||||
import { usePageSwitch } from '../hooks/usePageSwitch';
|
import { usePageSwitch } from '../hooks/usePageSwitch';
|
||||||
import { useTransitionPlayback } from '../hooks/useTransitionPlayback';
|
import { useTransitionPlayback } from '../hooks/useTransitionPlayback';
|
||||||
|
import { useBackgroundTransition } from '../hooks/useBackgroundTransition';
|
||||||
import { logger } from '../lib/logger';
|
import { logger } from '../lib/logger';
|
||||||
import { resolveAssetPlaybackUrl } from '../lib/assetUrl';
|
import { resolveAssetPlaybackUrl } from '../lib/assetUrl';
|
||||||
import { parseJsonObject } from '../lib/parseJson';
|
import { parseJsonObject } from '../lib/parseJson';
|
||||||
import { buildElementStyle } from '../lib/elementStyles';
|
import { buildElementStyle } from '../lib/elementStyles';
|
||||||
|
import {
|
||||||
|
resolveNavigationTarget,
|
||||||
|
hasPlayableTransition,
|
||||||
|
getNavigationDirection,
|
||||||
|
} from '../lib/navigationHelpers';
|
||||||
|
import {
|
||||||
|
formatDurationNote,
|
||||||
|
resolveDurationWithFallback,
|
||||||
|
} from '../lib/mediaHelpers';
|
||||||
import {
|
import {
|
||||||
createDefaultElement,
|
createDefaultElement,
|
||||||
mergeElementWithDefaults,
|
mergeElementWithDefaults,
|
||||||
@ -142,113 +152,6 @@ const getAssetLabel = (asset: ProjectAsset) => {
|
|||||||
const getAssetSourceValue = (asset: ProjectAsset) =>
|
const getAssetSourceValue = (asset: ProjectAsset) =>
|
||||||
String(asset.storage_key || asset.cdn_url || '').trim();
|
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) => {
|
const isBackgroundImageAsset = (asset: ProjectAsset) => {
|
||||||
if (asset.type) return asset.type === 'background_image';
|
if (asset.type) return asset.type === 'background_image';
|
||||||
const normalizedName = String(asset.name || '').toLowerCase();
|
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)
|
// Use shared background transition hook for direct navigation clearing
|
||||||
useEffect(() => {
|
// (No fade-out needed in constructor - transitions complete immediately)
|
||||||
if (
|
useBackgroundTransition({ pageSwitch });
|
||||||
pageSwitch.isSwitching &&
|
|
||||||
pageSwitch.isNewBgReady &&
|
|
||||||
pageSwitch.previousBgImageUrl
|
|
||||||
) {
|
|
||||||
pageSwitch.clearPreviousBackground();
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
pageSwitch.isSwitching,
|
|
||||||
pageSwitch.isNewBgReady,
|
|
||||||
pageSwitch.previousBgImageUrl,
|
|
||||||
pageSwitch.clearPreviousBackground,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const isConstructorEditMode = constructorInteractionMode === 'edit';
|
const isConstructorEditMode = constructorInteractionMode === 'edit';
|
||||||
const allowedNavigationTypes = useMemo<NavigationElementType[]>(() => {
|
const allowedNavigationTypes = useMemo<NavigationElementType[]>(() => {
|
||||||
@ -1856,42 +1747,24 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
if (element.navDisabled) {
|
if (element.navDisabled) {
|
||||||
return;
|
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
|
// Use shared navigation helpers
|
||||||
const targetPage = targetPageSlug
|
const direction = getNavigationDirection(element);
|
||||||
? pages.find((p) => p.slug === targetPageSlug)
|
const navTarget = resolveNavigationTarget(element, pages);
|
||||||
: legacyTargetPageId
|
|
||||||
? pages.find((p) => p.id === legacyTargetPageId)
|
|
||||||
: null;
|
|
||||||
const targetPageId = targetPage?.id || '';
|
|
||||||
|
|
||||||
if (!targetPageId) {
|
if (!navTarget) {
|
||||||
setErrorMessage(
|
setErrorMessage(
|
||||||
'No target page configured for this navigation button.',
|
'No target page configured for this navigation button.',
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasPlayableTransition =
|
// Check if transition can be played using shared helper
|
||||||
Boolean(element.transitionVideoUrl) &&
|
if (!hasPlayableTransition(element, direction)) {
|
||||||
!(
|
|
||||||
direction === 'back' &&
|
|
||||||
element.transitionReverseMode === 'separate_video' &&
|
|
||||||
!element.reverseVideoUrl
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!hasPlayableTransition) {
|
|
||||||
setPendingNavigationPageId('');
|
setPendingNavigationPageId('');
|
||||||
setTransitionPreview(null);
|
setTransitionPreview(null);
|
||||||
// Use switchToPage which resolves blob URLs via usePageSwitch (reduces flash)
|
// Use switchToPage which resolves blob URLs via usePageSwitch (reduces flash)
|
||||||
switchToPage(targetPage).then(() => {
|
switchToPage(navTarget.page).then(() => {
|
||||||
setSelectedElementId('');
|
setSelectedElementId('');
|
||||||
setSelectedMenuItem('none');
|
setSelectedMenuItem('none');
|
||||||
setErrorMessage('');
|
setErrorMessage('');
|
||||||
@ -1899,7 +1772,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setPendingNavigationPageId(targetPageId);
|
setPendingNavigationPageId(navTarget.pageId);
|
||||||
openTransitionPreviewForElement(element, direction);
|
openTransitionPreviewForElement(element, direction);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -11,6 +11,7 @@ export interface PreloadPage {
|
|||||||
id: string;
|
id: string;
|
||||||
background_image_url?: string;
|
background_image_url?: string;
|
||||||
background_video_url?: string;
|
background_video_url?: string;
|
||||||
|
background_audio_url?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
107
frontend/src/types/presentation.ts
Normal file
107
frontend/src/types/presentation.ts
Normal 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;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user