preloading logic
This commit is contained in:
parent
f12ec7c8bb
commit
9159f467f5
@ -4,7 +4,6 @@ DB_PASS=88dbeaf8-e906-405e-9e41-c3baadeda5c6
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=5432
|
||||
PORT=3000
|
||||
PLATFORM_ROOT_DOMAIN=tour-builder-platform-2eb6.dev.flatlogic.app
|
||||
GOOGLE_CLIENT_ID=671001533244-kf1k1gmp6mnl0r030qmvdu6v36ghmim6.apps.googleusercontent.com
|
||||
GOOGLE_CLIENT_SECRET=Yo4qbKZniqvojzUQ60iKlxqR
|
||||
MS_CLIENT_ID=4696f457-31af-40de-897c-e00d7d4cff73
|
||||
|
||||
@ -44,10 +44,16 @@ function extractUrlsFromContent(contentJson) {
|
||||
const urls = [];
|
||||
|
||||
const urlFields = [
|
||||
'image_url',
|
||||
'video_url',
|
||||
'audio_url',
|
||||
'background_url',
|
||||
'iconUrl',
|
||||
'imageUrl',
|
||||
'mediaUrl',
|
||||
'videoUrl',
|
||||
'audioUrl',
|
||||
'transitionVideoUrl',
|
||||
'backgroundImageUrl',
|
||||
'reverseVideoUrl',
|
||||
'carouselPrevIconUrl',
|
||||
'carouselNextIconUrl',
|
||||
'src',
|
||||
'url',
|
||||
'poster',
|
||||
@ -58,11 +64,13 @@ function extractUrlsFromContent(contentJson) {
|
||||
if (depth > 5 || !obj || typeof obj !== 'object') return;
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (typeof value === 'string' && urlFields.includes(key) && value.startsWith('http')) {
|
||||
urls.push({
|
||||
url: value,
|
||||
fieldType: key,
|
||||
});
|
||||
if (typeof value === 'string' && value && urlFields.includes(key)) {
|
||||
if (value.startsWith('http') || value.startsWith('/')) {
|
||||
urls.push({
|
||||
url: value,
|
||||
fieldType: key,
|
||||
});
|
||||
}
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
checkObject(value, depth + 1);
|
||||
}
|
||||
@ -105,6 +113,12 @@ class PWAManifestService {
|
||||
const manifestAssets = [];
|
||||
const seenUrls = new Set();
|
||||
|
||||
// Helper to convert size_mb to bytes
|
||||
const mbToBytes = (sizeMb) => {
|
||||
if (!sizeMb || isNaN(sizeMb)) return 0;
|
||||
return Math.round(parseFloat(sizeMb) * 1024 * 1024);
|
||||
};
|
||||
|
||||
// Helper to add an asset to the manifest
|
||||
const addAsset = (id, url, filename, variantType, assetType, mimeType, sizeBytes, pageIds) => {
|
||||
if (!url || seenUrls.has(url)) return;
|
||||
@ -139,21 +153,21 @@ class PWAManifestService {
|
||||
variant.variant_type,
|
||||
asset.type || getAssetType(asset.mime_type, asset.filename),
|
||||
variant.mime_type || asset.mime_type,
|
||||
variant.size_bytes || 0,
|
||||
variant.size_bytes || mbToBytes(variant.size_mb),
|
||||
asset.pages?.map((p) => p.id) || []
|
||||
);
|
||||
}
|
||||
|
||||
// If no variants, add original
|
||||
if (selectedVariants.length === 0 && asset.url) {
|
||||
// If no variants, add original (use cdn_url as primary, fall back to storage_key)
|
||||
if (selectedVariants.length === 0 && (asset.cdn_url || asset.storage_key)) {
|
||||
addAsset(
|
||||
asset.id,
|
||||
asset.url,
|
||||
asset.filename,
|
||||
asset.cdn_url || asset.storage_key,
|
||||
asset.name || asset.filename,
|
||||
'original',
|
||||
asset.type || getAssetType(asset.mime_type, asset.filename),
|
||||
asset.type || getAssetType(asset.mime_type, asset.name),
|
||||
asset.mime_type,
|
||||
asset.size_bytes || 0,
|
||||
mbToBytes(asset.size_mb),
|
||||
asset.pages?.map((p) => p.id) || []
|
||||
);
|
||||
}
|
||||
|
||||
3
frontend/.gitignore
vendored
3
frontend/.gitignore
vendored
@ -31,3 +31,6 @@ yarn-error.log*
|
||||
# vercel
|
||||
.vercel
|
||||
/.idea/
|
||||
|
||||
# typescript
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -5,6 +5,11 @@ import FileUploader from '../Uploaders/UploadService';
|
||||
import type { AssetSection } from './AssetSectionCard';
|
||||
import type { UploadQueueItem } from './UploadProgressList';
|
||||
import { logger } from '../../lib/logger';
|
||||
import {
|
||||
probeMediaDuration,
|
||||
isVideoMimeType,
|
||||
isAudioMimeType,
|
||||
} from '../../lib/mediaDuration';
|
||||
|
||||
interface UseAssetUploaderOptions {
|
||||
selectedProjectId: string;
|
||||
@ -106,6 +111,25 @@ export function useAssetUploader({
|
||||
},
|
||||
);
|
||||
|
||||
// Probe media duration for video/audio files
|
||||
let durationSec: number | null = null;
|
||||
let widthPx: number | null = null;
|
||||
let heightPx: number | null = null;
|
||||
|
||||
if (isVideoMimeType(file.type)) {
|
||||
const probeResult = await probeMediaDuration(file, 'video');
|
||||
if (probeResult) {
|
||||
durationSec = probeResult.duration;
|
||||
widthPx = probeResult.width ?? null;
|
||||
heightPx = probeResult.height ?? null;
|
||||
}
|
||||
} else if (isAudioMimeType(file.type)) {
|
||||
const probeResult = await probeMediaDuration(file, 'audio');
|
||||
if (probeResult) {
|
||||
durationSec = probeResult.duration;
|
||||
}
|
||||
}
|
||||
|
||||
await axios.post('/assets', {
|
||||
data: {
|
||||
project: projectId,
|
||||
@ -117,6 +141,9 @@ export function useAssetUploader({
|
||||
mime_type: file.type || null,
|
||||
size_mb: Number((file.size / (1024 * 1024)).toFixed(4)),
|
||||
is_public: false,
|
||||
duration_sec: durationSec,
|
||||
width_px: widthPx,
|
||||
height_px: heightPx,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
796
frontend/src/components/RuntimePresentation.tsx
Normal file
796
frontend/src/components/RuntimePresentation.tsx
Normal file
@ -0,0 +1,796 @@
|
||||
/**
|
||||
* RuntimePresentation Component
|
||||
*
|
||||
* Renders a presentation for a specific project and environment.
|
||||
* Used by /p/[projectSlug] and /p/[projectSlug]/stage routes.
|
||||
*/
|
||||
|
||||
import { mdiFullscreen, mdiFullscreenExit } from '@mdi/js';
|
||||
import axios from 'axios';
|
||||
import Head from 'next/head';
|
||||
import Image from 'next/image';
|
||||
import React, {
|
||||
ReactElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import BaseButton from './BaseButton';
|
||||
import CardBox from './CardBox';
|
||||
import { OfflineToggle } from './Offline/OfflineToggle';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import { getPageTitle } from '../config';
|
||||
import { PRELOAD_CONFIG } from '../config/preload.config';
|
||||
import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
|
||||
import { useReversePlayback } from '../hooks/useReversePlayback';
|
||||
import { logger } from '../lib/logger';
|
||||
import { resolveAssetPlaybackUrl } from '../lib/assetUrl';
|
||||
import { buildElementStyle } from '../lib/elementStyles';
|
||||
import type {
|
||||
RuntimeProject,
|
||||
RuntimePage,
|
||||
RuntimePageLink,
|
||||
TransitionOverlayState,
|
||||
} from '../types/runtime';
|
||||
|
||||
interface RuntimePresentationProps {
|
||||
projectSlug: string;
|
||||
environment: 'stage' | 'production';
|
||||
}
|
||||
|
||||
const getRows = (response: any) =>
|
||||
Array.isArray(response?.data?.rows) ? response.data.rows : [];
|
||||
|
||||
/**
|
||||
* Wait for all images on a page to be decoded before switching.
|
||||
*/
|
||||
const waitForPageImages = async (
|
||||
page: RuntimePage | null,
|
||||
timeoutMs = 2000,
|
||||
): Promise<void> => {
|
||||
if (!page) return;
|
||||
|
||||
const imageUrls: string[] = [];
|
||||
|
||||
if (page.background_image_url) {
|
||||
const url = resolveAssetPlaybackUrl(page.background_image_url);
|
||||
if (url) imageUrls.push(url);
|
||||
}
|
||||
|
||||
try {
|
||||
const uiSchema =
|
||||
typeof page.ui_schema_json === 'string'
|
||||
? JSON.parse(page.ui_schema_json)
|
||||
: page.ui_schema_json;
|
||||
|
||||
const pageElements = Array.isArray(uiSchema?.elements)
|
||||
? uiSchema.elements
|
||||
: [];
|
||||
|
||||
const { images: imageFields, nested: nestedFields } =
|
||||
PRELOAD_CONFIG.assetFields;
|
||||
|
||||
pageElements.forEach((el: Record<string, unknown>) => {
|
||||
// Direct image fields
|
||||
imageFields.forEach((field) => {
|
||||
const value = el[field];
|
||||
if (typeof value === 'string' && value) {
|
||||
const url = resolveAssetPlaybackUrl(value);
|
||||
if (url && !imageUrls.includes(url)) imageUrls.push(url);
|
||||
}
|
||||
});
|
||||
|
||||
// Nested arrays (galleryCards, carouselSlides)
|
||||
nestedFields.forEach((nestedField) => {
|
||||
const items = el[nestedField];
|
||||
if (Array.isArray(items)) {
|
||||
items.forEach((item: Record<string, unknown>) => {
|
||||
if (typeof item.imageUrl === 'string' && item.imageUrl) {
|
||||
const url = resolveAssetPlaybackUrl(item.imageUrl);
|
||||
if (url && !imageUrls.includes(url)) imageUrls.push(url);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
|
||||
if (imageUrls.length === 0) return;
|
||||
|
||||
const decodePromises = imageUrls.map(
|
||||
(url) =>
|
||||
new Promise<void>((resolve) => {
|
||||
const img = new window.Image();
|
||||
img.src = url;
|
||||
if (typeof img.decode === 'function') {
|
||||
img
|
||||
.decode()
|
||||
.then(() => resolve())
|
||||
.catch(() => resolve());
|
||||
} else {
|
||||
img.onload = () => resolve();
|
||||
img.onerror = () => resolve();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.race([
|
||||
Promise.all(decodePromises),
|
||||
new Promise<void>((resolve) => setTimeout(resolve, timeoutMs)),
|
||||
]);
|
||||
};
|
||||
|
||||
export default function RuntimePresentation({
|
||||
projectSlug,
|
||||
environment,
|
||||
}: RuntimePresentationProps) {
|
||||
const [project, setProject] = useState<RuntimeProject | null>(null);
|
||||
const [pages, setPages] = useState<RuntimePage[]>([]);
|
||||
const [pageLinks, setPageLinks] = useState<RuntimePageLink[]>([]);
|
||||
const [selectedPageId, setSelectedPageId] = useState<string | null>(null);
|
||||
const [pageHistory, setPageHistory] = useState<string[]>([]);
|
||||
const [overlayTransition, setOverlayTransition] =
|
||||
useState<TransitionOverlayState | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
const overlayVideoRef = useRef<HTMLVideoElement | 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],
|
||||
);
|
||||
|
||||
// Transform elements from ui_schema_json for preloading
|
||||
// (Elements in RuntimePresentation come from page's ui_schema_json, not raw API)
|
||||
const preloadElements = useMemo(() => {
|
||||
const result: Array<{
|
||||
id: string;
|
||||
pageId: string;
|
||||
element_type: string;
|
||||
content_json: string;
|
||||
}> = [];
|
||||
|
||||
pages.forEach((page) => {
|
||||
try {
|
||||
const uiSchema =
|
||||
typeof page.ui_schema_json === 'string'
|
||||
? JSON.parse(page.ui_schema_json)
|
||||
: page.ui_schema_json;
|
||||
|
||||
const pageElements = Array.isArray(uiSchema?.elements)
|
||||
? uiSchema.elements
|
||||
: [];
|
||||
|
||||
pageElements.forEach((el: Record<string, unknown>) => {
|
||||
// Build content_json from config fields
|
||||
const contentObj: Record<string, unknown> = {};
|
||||
PRELOAD_CONFIG.assetFields.all.forEach((field) => {
|
||||
if (el[field] !== undefined) {
|
||||
contentObj[field] = el[field];
|
||||
}
|
||||
});
|
||||
PRELOAD_CONFIG.assetFields.nested.forEach((field) => {
|
||||
if (el[field] !== undefined) {
|
||||
contentObj[field] = el[field];
|
||||
}
|
||||
});
|
||||
|
||||
result.push({
|
||||
id: String(el.id || `${page.id}-${result.length}`),
|
||||
pageId: page.id,
|
||||
element_type: String(el.type || ''),
|
||||
content_json: JSON.stringify(contentObj),
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [pages]);
|
||||
|
||||
// Initialize preload orchestrator with transformed data
|
||||
const preloadOrchestrator = usePreloadOrchestrator({
|
||||
pages,
|
||||
pageLinks,
|
||||
elements: preloadElements,
|
||||
currentPageId: selectedPageId,
|
||||
pageHistory,
|
||||
enabled: !isLoading && !error,
|
||||
});
|
||||
|
||||
const toggleFullscreen = useCallback(async () => {
|
||||
try {
|
||||
if (!document.fullscreenElement) {
|
||||
await document.documentElement.requestFullscreen();
|
||||
setIsFullscreen(true);
|
||||
} else {
|
||||
await document.exitFullscreen();
|
||||
setIsFullscreen(false);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
'Fullscreen toggle failed:',
|
||||
err instanceof Error ? err : { error: err },
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleFullscreenChange = () => {
|
||||
setIsFullscreen(Boolean(document.fullscreenElement));
|
||||
};
|
||||
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||
return () =>
|
||||
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||
}, []);
|
||||
|
||||
// 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 and links for this project
|
||||
// (Elements are extracted from ui_schema_json, transitions are eagerly loaded by API)
|
||||
const [pagesResponse, pageLinksResponse] = await Promise.all([
|
||||
axios.get('/tour_pages', {
|
||||
...apiConfig,
|
||||
params: { project: foundProject.id },
|
||||
}),
|
||||
axios.get('/page_links', {
|
||||
...apiConfig,
|
||||
params: { project: foundProject.id },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (isCancelled) return;
|
||||
|
||||
const pageRows = getRows(pagesResponse);
|
||||
const linkRows = getRows(pageLinksResponse);
|
||||
|
||||
// Filter by environment and sort by sort_order
|
||||
const envFilteredPages = pageRows
|
||||
.filter(
|
||||
(p: any) =>
|
||||
!p.environment ||
|
||||
p.environment === environment ||
|
||||
p.environment === 'dev',
|
||||
)
|
||||
.sort((a: any, b: any) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
|
||||
|
||||
setPages(envFilteredPages);
|
||||
setPageLinks(linkRows);
|
||||
|
||||
// Set initial page
|
||||
if (envFilteredPages.length > 0) {
|
||||
const entryPage = foundProject.entry_page_slug
|
||||
? envFilteredPages.find(
|
||||
(p: RuntimePage) => p.slug === foundProject.entry_page_slug,
|
||||
)
|
||||
: null;
|
||||
const firstPage = entryPage || 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],
|
||||
);
|
||||
|
||||
const pageElements = useMemo(() => {
|
||||
if (!selectedPage) return [];
|
||||
|
||||
try {
|
||||
const uiSchema =
|
||||
typeof selectedPage.ui_schema_json === 'string'
|
||||
? JSON.parse(selectedPage.ui_schema_json)
|
||||
: selectedPage.ui_schema_json;
|
||||
|
||||
return Array.isArray(uiSchema?.elements) ? uiSchema.elements : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}, [selectedPage]);
|
||||
|
||||
const navigateToPage = useCallback(
|
||||
async (
|
||||
targetPageId: string,
|
||||
transitionVideoUrl?: string,
|
||||
isBack = false,
|
||||
) => {
|
||||
const targetPage = pages.find((p) => p.id === targetPageId);
|
||||
if (!targetPage) return;
|
||||
|
||||
if (transitionVideoUrl) {
|
||||
// Play transition (forward or reverse)
|
||||
setOverlayTransition({
|
||||
targetPageId,
|
||||
transitionName: 'Transition',
|
||||
videoUrl: resolveAssetPlaybackUrl(transitionVideoUrl),
|
||||
isReverse: isBack,
|
||||
});
|
||||
} else {
|
||||
// Direct navigation - wait for images first
|
||||
await waitForPageImages(targetPage);
|
||||
setSelectedPageId(targetPageId);
|
||||
setPageHistory((prev) => [...prev, targetPageId]);
|
||||
}
|
||||
},
|
||||
[pages],
|
||||
);
|
||||
|
||||
const handleElementClick = useCallback(
|
||||
(element: any) => {
|
||||
if (element.targetPageId) {
|
||||
const isBack =
|
||||
element.navType === 'back' || element.type === 'navigation_prev';
|
||||
// Get transition video URL from element itself
|
||||
const transitionVideoUrl = element.transitionVideoUrl;
|
||||
navigateToPage(element.targetPageId, transitionVideoUrl, isBack);
|
||||
}
|
||||
},
|
||||
[navigateToPage],
|
||||
);
|
||||
|
||||
const finishOverlayTransition = useCallback(async () => {
|
||||
if (!overlayTransition) return;
|
||||
|
||||
const targetPage = pages.find(
|
||||
(p) => p.id === overlayTransition.targetPageId,
|
||||
);
|
||||
|
||||
// Wait for images while showing last frame
|
||||
await waitForPageImages(targetPage || null);
|
||||
|
||||
// Switch page
|
||||
setSelectedPageId(overlayTransition.targetPageId);
|
||||
setPageHistory((prev) => [...prev, overlayTransition.targetPageId]);
|
||||
|
||||
// Hide transition after React renders
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
setOverlayTransition(null);
|
||||
});
|
||||
});
|
||||
}, [overlayTransition, pages]);
|
||||
|
||||
// Use the reverse playback hook (same as constructor.tsx)
|
||||
const { startReverse, stopReverse } = useReversePlayback({
|
||||
videoRef: overlayVideoRef,
|
||||
onComplete: finishOverlayTransition,
|
||||
preloadedUrls: preloadOrchestrator.preloadedUrls,
|
||||
videoUrl: overlayTransition?.videoUrl,
|
||||
getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl,
|
||||
});
|
||||
|
||||
// Handle reverse playback when transition starts in reverse mode
|
||||
useEffect(() => {
|
||||
if (!overlayTransition?.isReverse) return;
|
||||
|
||||
const video = overlayVideoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
// Start reverse when video data is ready (preloaded assets load instantly)
|
||||
const handleLoadedData = () => {
|
||||
startReverse();
|
||||
};
|
||||
|
||||
if (video.readyState >= 2) {
|
||||
handleLoadedData();
|
||||
} else {
|
||||
video.addEventListener('loadeddata', handleLoadedData, { once: true });
|
||||
}
|
||||
|
||||
return () => {
|
||||
stopReverse();
|
||||
video.removeEventListener('loadeddata', handleLoadedData);
|
||||
};
|
||||
}, [
|
||||
overlayTransition?.isReverse,
|
||||
overlayTransition?.videoUrl,
|
||||
overlayTransition?.durationSec,
|
||||
startReverse,
|
||||
stopReverse,
|
||||
]);
|
||||
|
||||
// Render element content based on type
|
||||
const renderElementContent = (element: any) => {
|
||||
// Navigation buttons
|
||||
if (
|
||||
element.type === 'navigation_next' ||
|
||||
element.type === 'navigation_prev'
|
||||
) {
|
||||
if (element.iconUrl) {
|
||||
return (
|
||||
<div className='relative w-full h-full min-w-[40px] min-h-[40px]'>
|
||||
<Image
|
||||
src={resolveAssetPlaybackUrl(element.iconUrl)}
|
||||
alt='Navigation'
|
||||
fill
|
||||
className='object-contain'
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className='px-4 py-2 bg-white/80 rounded text-black text-sm'>
|
||||
{element.navLabel ||
|
||||
(element.type === 'navigation_next' ? 'Next' : 'Back')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Description element
|
||||
if (element.type === 'description') {
|
||||
if (element.iconUrl) {
|
||||
return (
|
||||
<div className='relative w-full h-full'>
|
||||
<Image
|
||||
src={resolveAssetPlaybackUrl(element.iconUrl)}
|
||||
alt='Description'
|
||||
fill
|
||||
className='object-contain'
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const bgColor = element.descriptionBackgroundColor || 'transparent';
|
||||
return (
|
||||
<div className='p-4 rounded' style={{ backgroundColor: bgColor }}>
|
||||
<p
|
||||
className='font-bold'
|
||||
style={{
|
||||
fontSize: element.descriptionTitleFontSize || '24px',
|
||||
fontFamily: element.descriptionTitleFontFamily || 'inherit',
|
||||
color: element.descriptionTitleColor || '#ffffff',
|
||||
}}
|
||||
>
|
||||
{element.descriptionTitle || ''}
|
||||
</p>
|
||||
{element.descriptionText && (
|
||||
<p
|
||||
style={{
|
||||
fontSize: element.descriptionTextFontSize || '16px',
|
||||
fontFamily: element.descriptionTextFontFamily || 'inherit',
|
||||
color: element.descriptionTextColor || '#ffffff',
|
||||
}}
|
||||
>
|
||||
{element.descriptionText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Tooltip
|
||||
if (element.type === 'tooltip') {
|
||||
if (element.iconUrl) {
|
||||
return (
|
||||
<div className='relative w-full h-full'>
|
||||
<Image
|
||||
src={resolveAssetPlaybackUrl(element.iconUrl)}
|
||||
alt='Tooltip'
|
||||
fill
|
||||
className='object-contain'
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className='bg-white/90 p-3 rounded max-w-[200px]'>
|
||||
<p className='font-bold text-black text-sm'>{element.tooltipTitle}</p>
|
||||
<p className='text-gray-600 text-xs'>{element.tooltipText}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Video player
|
||||
if (element.type === 'video_player' && element.mediaUrl) {
|
||||
return (
|
||||
<video
|
||||
className='w-full h-full object-cover rounded'
|
||||
src={resolveAssetPlaybackUrl(element.mediaUrl)}
|
||||
controls
|
||||
autoPlay={Boolean(element.mediaAutoplay)}
|
||||
loop={Boolean(element.mediaLoop)}
|
||||
muted={Boolean(element.mediaMuted)}
|
||||
playsInline
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Audio player
|
||||
if (element.type === 'audio_player' && element.mediaUrl) {
|
||||
return (
|
||||
<audio
|
||||
className='w-full'
|
||||
src={resolveAssetPlaybackUrl(element.mediaUrl)}
|
||||
controls
|
||||
autoPlay={Boolean(element.mediaAutoplay)}
|
||||
loop={Boolean(element.mediaLoop)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Gallery
|
||||
if (element.type === 'gallery') {
|
||||
const cards = element.galleryCards || [];
|
||||
return (
|
||||
<div className='grid grid-cols-3 gap-2 p-2 bg-black/50 rounded'>
|
||||
{cards.map((card: any) => (
|
||||
<div key={card.id} className='relative aspect-square'>
|
||||
{card.imageUrl && (
|
||||
<Image
|
||||
src={resolveAssetPlaybackUrl(card.imageUrl)}
|
||||
alt={card.title || ''}
|
||||
fill
|
||||
className='object-cover rounded'
|
||||
unoptimized
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Carousel
|
||||
if (element.type === 'carousel') {
|
||||
const slides = element.carouselSlides || [];
|
||||
const firstSlide = slides[0];
|
||||
if (firstSlide?.imageUrl) {
|
||||
return (
|
||||
<div className='relative w-full h-full'>
|
||||
<Image
|
||||
src={resolveAssetPlaybackUrl(firstSlide.imageUrl)}
|
||||
alt={firstSlide.caption || ''}
|
||||
fill
|
||||
className='object-cover rounded'
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Default: icon or image
|
||||
if (element.iconUrl) {
|
||||
return (
|
||||
<div className='relative w-full h-full'>
|
||||
<Image
|
||||
src={resolveAssetPlaybackUrl(element.iconUrl)}
|
||||
alt=''
|
||||
fill
|
||||
className='object-contain'
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (element.imageUrl) {
|
||||
return (
|
||||
<div className='relative w-full h-full'>
|
||||
<Image
|
||||
src={resolveAssetPlaybackUrl(element.imageUrl)}
|
||||
alt=''
|
||||
fill
|
||||
className='object-contain'
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Text fallback
|
||||
if (element.text) {
|
||||
return <div className='text-white'>{element.text}</div>;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const backgroundImageUrl = selectedPage?.background_image_url
|
||||
? resolveAssetPlaybackUrl(selectedPage.background_image_url)
|
||||
: '';
|
||||
const backgroundVideoUrl = selectedPage?.background_video_url
|
||||
? resolveAssetPlaybackUrl(selectedPage.background_video_url)
|
||||
: '';
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className='flex items-center justify-center min-h-screen bg-gray-900'>
|
||||
<div className='text-white text-xl'>Loading presentation...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className='flex items-center justify-center min-h-screen bg-gray-900'>
|
||||
<CardBox className='max-w-md'>
|
||||
<h2 className='text-xl font-bold text-red-500 mb-4'>Error</h2>
|
||||
<p className='text-gray-300'>{error}</p>
|
||||
</CardBox>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle(project?.name || 'Presentation')}</title>
|
||||
</Head>
|
||||
|
||||
<div
|
||||
className='relative w-screen h-screen overflow-hidden bg-black'
|
||||
style={{
|
||||
backgroundImage: backgroundImageUrl
|
||||
? `url("${backgroundImageUrl}")`
|
||||
: undefined,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
>
|
||||
{/* Background video */}
|
||||
{backgroundVideoUrl && (
|
||||
<video
|
||||
key={backgroundVideoUrl}
|
||||
className='absolute inset-0 w-full h-full object-cover'
|
||||
src={backgroundVideoUrl}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Page elements */}
|
||||
<div className='absolute inset-0'>
|
||||
{pageElements.map((element: any) => {
|
||||
const xPercent = element.xPercent ?? 0;
|
||||
const yPercent = element.yPercent ?? 0;
|
||||
const rotation = element.rotation ?? 0;
|
||||
|
||||
// Build element style using shared utility
|
||||
const elementStyle: React.CSSProperties = {
|
||||
left: `${xPercent}%`,
|
||||
top: `${yPercent}%`,
|
||||
transform: `translate(-50%, -50%)${rotation ? ` rotate(${rotation}deg)` : ''}`,
|
||||
...buildElementStyle(element),
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={element.id}
|
||||
className='absolute cursor-pointer'
|
||||
style={elementStyle}
|
||||
onClick={() => handleElementClick(element)}
|
||||
>
|
||||
{renderElementContent(element)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Controls: Offline toggle and Fullscreen button */}
|
||||
<div className='absolute top-4 right-4 z-50 flex items-center gap-2'>
|
||||
<OfflineToggle
|
||||
projectId={project?.id || null}
|
||||
projectSlug={projectSlug}
|
||||
projectName={project?.name}
|
||||
showLabel={false}
|
||||
size='small'
|
||||
/>
|
||||
<BaseButton
|
||||
icon={isFullscreen ? mdiFullscreenExit : mdiFullscreen}
|
||||
color='info'
|
||||
small
|
||||
onClick={toggleFullscreen}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Environment badge */}
|
||||
<div className='absolute top-4 left-4 z-50'>
|
||||
<span
|
||||
className={`px-2 py-1 rounded text-xs font-bold ${
|
||||
environment === 'stage'
|
||||
? 'bg-yellow-500 text-black'
|
||||
: 'bg-green-500 text-white'
|
||||
}`}
|
||||
>
|
||||
{environment.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Transition overlay */}
|
||||
{overlayTransition && (
|
||||
<div className='absolute inset-0 z-40 bg-black'>
|
||||
<video
|
||||
ref={overlayVideoRef}
|
||||
className='w-full h-full object-cover'
|
||||
src={overlayTransition.videoUrl}
|
||||
autoPlay={!overlayTransition.isReverse}
|
||||
muted
|
||||
playsInline
|
||||
preload='auto'
|
||||
onEnded={
|
||||
overlayTransition.isReverse
|
||||
? undefined
|
||||
: finishOverlayTransition
|
||||
}
|
||||
onError={finishOverlayTransition}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Layout wrapper for standalone usage
|
||||
RuntimePresentation.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
@ -55,6 +55,40 @@ export const PRELOAD_CONFIG = {
|
||||
maxDepth: 2, // How far to look ahead
|
||||
constructorMaxDepth: 1, // Reduced depth for constructor preview
|
||||
},
|
||||
|
||||
// Asset URL field names in element content_json (camelCase)
|
||||
assetFields: {
|
||||
// All asset URL fields for preloading extraction
|
||||
all: [
|
||||
'iconUrl',
|
||||
'imageUrl',
|
||||
'mediaUrl',
|
||||
'videoUrl',
|
||||
'audioUrl',
|
||||
'transitionVideoUrl',
|
||||
'backgroundImageUrl',
|
||||
'reverseVideoUrl',
|
||||
'carouselPrevIconUrl',
|
||||
'carouselNextIconUrl',
|
||||
'src',
|
||||
'url',
|
||||
'poster',
|
||||
'thumbnail',
|
||||
] as const,
|
||||
// Image-only fields for decode before page switch
|
||||
images: [
|
||||
'iconUrl',
|
||||
'imageUrl',
|
||||
'backgroundImageUrl',
|
||||
'carouselPrevIconUrl',
|
||||
'carouselNextIconUrl',
|
||||
'src',
|
||||
] as const,
|
||||
// Nested array fields containing assets
|
||||
nested: ['galleryCards', 'carouselSlides'] as const,
|
||||
// Fields within nested items that contain URLs
|
||||
nestedUrlFields: ['imageUrl', 'videoUrl'] as const,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type PreloadConfig = typeof PRELOAD_CONFIG;
|
||||
|
||||
@ -6,3 +6,17 @@ export { useFilterItems } from './useFilterItems';
|
||||
export { useCSVHandling } from './useCSVHandling';
|
||||
export { useFormSync } from './useFormSync';
|
||||
export { useEntityTable } from './useEntityTable';
|
||||
export { useTransitionPlayback } from './useTransitionPlayback';
|
||||
export type {
|
||||
ReverseMode,
|
||||
TransitionConfig,
|
||||
UseTransitionPlaybackOptions,
|
||||
PlaybackPhase,
|
||||
UseTransitionPlaybackResult,
|
||||
} from './useTransitionPlayback';
|
||||
export { usePageNavigation } from './usePageNavigation';
|
||||
export type {
|
||||
NavigablePage,
|
||||
UsePageNavigationOptions,
|
||||
UsePageNavigationResult,
|
||||
} from './usePageNavigation';
|
||||
|
||||
@ -7,59 +7,34 @@
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { PRELOAD_CONFIG } from '../config/preload.config';
|
||||
|
||||
interface PageLink {
|
||||
id: string;
|
||||
from_pageId?: string;
|
||||
to_pageId?: string;
|
||||
is_active?: boolean;
|
||||
transition?: {
|
||||
id: string;
|
||||
video_url?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Page {
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface Element {
|
||||
id: string;
|
||||
pageId?: string;
|
||||
element_type?: string;
|
||||
content_json?: string;
|
||||
}
|
||||
|
||||
import type {
|
||||
PreloadPage,
|
||||
PreloadPageLink,
|
||||
PreloadElement,
|
||||
PreloadAssetInfo,
|
||||
PreloadNeighborInfo,
|
||||
} from '../types/preload';
|
||||
|
||||
interface UseNeighborGraphOptions {
|
||||
pages: Page[];
|
||||
pageLinks: PageLink[];
|
||||
elements: Element[];
|
||||
pages: PreloadPage[];
|
||||
pageLinks: PreloadPageLink[];
|
||||
elements: PreloadElement[];
|
||||
maxDepth?: number;
|
||||
}
|
||||
|
||||
interface NeighborInfo {
|
||||
pageId: string;
|
||||
distance: number;
|
||||
}
|
||||
|
||||
interface AssetInfo {
|
||||
url: string;
|
||||
pageId: string;
|
||||
assetType: 'image' | 'video' | 'audio' | 'transition' | 'other';
|
||||
priority: number;
|
||||
}
|
||||
|
||||
interface NeighborGraphResult {
|
||||
/**
|
||||
* Get neighboring page IDs within maxDepth hops
|
||||
*/
|
||||
getNeighbors: (currentPageId: string, maxDepth?: number) => NeighborInfo[];
|
||||
getNeighbors: (
|
||||
currentPageId: string,
|
||||
maxDepth?: number,
|
||||
) => PreloadNeighborInfo[];
|
||||
|
||||
/**
|
||||
* Get all assets that should be preloaded for given pages
|
||||
*/
|
||||
getAssetsForPages: (pageIds: string[]) => AssetInfo[];
|
||||
getAssetsForPages: (pageIds: string[]) => PreloadAssetInfo[];
|
||||
|
||||
/**
|
||||
* Get prioritized assets for preloading based on current page
|
||||
@ -67,7 +42,7 @@ interface NeighborGraphResult {
|
||||
getPrioritizedAssets: (
|
||||
currentPageId: string,
|
||||
maxDepth?: number,
|
||||
) => AssetInfo[];
|
||||
) => PreloadAssetInfo[];
|
||||
|
||||
/**
|
||||
* Raw adjacency list for debugging
|
||||
@ -81,31 +56,17 @@ interface NeighborGraphResult {
|
||||
function extractAssetsFromContent(
|
||||
contentJson: string | undefined,
|
||||
pageId: string,
|
||||
): AssetInfo[] {
|
||||
): PreloadAssetInfo[] {
|
||||
if (!contentJson) return [];
|
||||
|
||||
try {
|
||||
const content =
|
||||
typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
|
||||
|
||||
const assets: AssetInfo[] = [];
|
||||
const assets: PreloadAssetInfo[] = [];
|
||||
|
||||
// Check for common asset URL fields (both snake_case and camelCase)
|
||||
const urlFields = [
|
||||
'image_url',
|
||||
'video_url',
|
||||
'audio_url',
|
||||
'background_url',
|
||||
'src',
|
||||
'url',
|
||||
'poster',
|
||||
'thumbnail',
|
||||
'transitionVideoUrl', // For transition videos in constructor
|
||||
'videoUrl', // camelCase variant
|
||||
'audioUrl', // camelCase variant
|
||||
'iconUrl', // icon images
|
||||
'backgroundImageUrl', // background images
|
||||
];
|
||||
// Asset URL fields in element content_json
|
||||
const urlFields = PRELOAD_CONFIG.assetFields.all as readonly string[];
|
||||
|
||||
const checkObject = (obj: Record<string, unknown>, depth = 0) => {
|
||||
if (depth > 5 || !obj || typeof obj !== 'object') return;
|
||||
@ -174,9 +135,9 @@ export function useNeighborGraph(
|
||||
|
||||
// BFS to find neighbors within depth
|
||||
const getNeighbors = useMemo(() => {
|
||||
return (currentPageId: string, depth = maxDepth): NeighborInfo[] => {
|
||||
return (currentPageId: string, depth = maxDepth): PreloadNeighborInfo[] => {
|
||||
const visited = new Set<string>();
|
||||
const result: NeighborInfo[] = [];
|
||||
const result: PreloadNeighborInfo[] = [];
|
||||
const queue: { pageId: string; distance: number }[] = [
|
||||
{ pageId: currentPageId, distance: 0 },
|
||||
];
|
||||
@ -210,8 +171,8 @@ export function useNeighborGraph(
|
||||
|
||||
// Get assets for a set of pages
|
||||
const getAssetsForPages = useMemo(() => {
|
||||
return (pageIds: string[]): AssetInfo[] => {
|
||||
const assets: AssetInfo[] = [];
|
||||
return (pageIds: string[]): PreloadAssetInfo[] => {
|
||||
const assets: PreloadAssetInfo[] = [];
|
||||
const seenUrls = new Set<string>();
|
||||
|
||||
pageIds.forEach((pageId) => {
|
||||
@ -236,8 +197,7 @@ export function useNeighborGraph(
|
||||
// Add transition videos (transition is eagerly loaded in page_links)
|
||||
const matchingLinks = pageLinks.filter(
|
||||
(link) =>
|
||||
link.is_active !== false &&
|
||||
pageIds.includes(link.from_pageId || ''),
|
||||
link.is_active !== false && pageIds.includes(link.from_pageId || ''),
|
||||
);
|
||||
|
||||
matchingLinks.forEach((link) => {
|
||||
@ -259,7 +219,7 @@ export function useNeighborGraph(
|
||||
|
||||
// Get prioritized assets for preloading
|
||||
const getPrioritizedAssets = useMemo(() => {
|
||||
return (currentPageId: string, depth = maxDepth): AssetInfo[] => {
|
||||
return (currentPageId: string, depth = maxDepth): PreloadAssetInfo[] => {
|
||||
// Get current page assets (highest priority)
|
||||
const currentPageAssets = getAssetsForPages([currentPageId]).map(
|
||||
(asset) => ({
|
||||
@ -272,7 +232,7 @@ export function useNeighborGraph(
|
||||
|
||||
// Get neighbor page assets
|
||||
const neighbors = getNeighbors(currentPageId, depth);
|
||||
const neighborAssets: AssetInfo[] = [];
|
||||
const neighborAssets: PreloadAssetInfo[] = [];
|
||||
|
||||
neighbors.forEach(({ pageId, distance }) => {
|
||||
const assets = getAssetsForPages([pageId]);
|
||||
@ -292,7 +252,7 @@ export function useNeighborGraph(
|
||||
const allAssets = [...currentPageAssets, ...neighborAssets];
|
||||
|
||||
// Deduplicate by URL, keeping highest priority
|
||||
const urlToPriority = new Map<string, AssetInfo>();
|
||||
const urlToPriority = new Map<string, PreloadAssetInfo>();
|
||||
allAssets.forEach((asset) => {
|
||||
const existing = urlToPriority.get(asset.url);
|
||||
if (!existing || asset.priority > existing.priority) {
|
||||
|
||||
185
frontend/src/hooks/usePageNavigation.ts
Normal file
185
frontend/src/hooks/usePageNavigation.ts
Normal file
@ -0,0 +1,185 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Minimal page interface for navigation
|
||||
*/
|
||||
export interface NavigablePage {
|
||||
id: string;
|
||||
sort_order?: number;
|
||||
slug?: string;
|
||||
}
|
||||
|
||||
export interface UsePageNavigationOptions<TPage extends NavigablePage> {
|
||||
pages: TPage[];
|
||||
defaultPageId?: string;
|
||||
entryPageSlug?: string;
|
||||
trackHistory?: boolean;
|
||||
onPageChange?: (pageId: string, isBack: boolean) => void;
|
||||
}
|
||||
|
||||
export interface UsePageNavigationResult<TPage extends NavigablePage> {
|
||||
currentPageId: string | null;
|
||||
currentPage: TPage | null;
|
||||
pageHistory: string[];
|
||||
previousPageId: string | null;
|
||||
defaultPage: TPage | null;
|
||||
setCurrentPageId: (pageId: string) => void;
|
||||
applyPageSelection: (targetPageId: string, isBack?: boolean) => void;
|
||||
isBackNavigation: (targetPageId: string) => boolean;
|
||||
goBack: () => boolean;
|
||||
resetHistory: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing page navigation state with optional history tracking.
|
||||
*
|
||||
* @example
|
||||
* // Basic usage (runtime)
|
||||
* const nav = usePageNavigation({
|
||||
* pages,
|
||||
* entryPageSlug: project?.entry_page_slug,
|
||||
* trackHistory: true,
|
||||
* });
|
||||
*
|
||||
* // Editor usage (constructor) - no history
|
||||
* const nav = usePageNavigation({
|
||||
* pages,
|
||||
* defaultPageId: initialPageId,
|
||||
* trackHistory: false,
|
||||
* });
|
||||
*/
|
||||
export function usePageNavigation<TPage extends NavigablePage>(
|
||||
options: UsePageNavigationOptions<TPage>,
|
||||
): UsePageNavigationResult<TPage> {
|
||||
const {
|
||||
pages,
|
||||
defaultPageId,
|
||||
entryPageSlug,
|
||||
trackHistory = true,
|
||||
onPageChange,
|
||||
} = options;
|
||||
|
||||
const [currentPageId, setCurrentPageIdState] = useState<string | null>(null);
|
||||
const [pageHistory, setPageHistory] = useState<string[]>([]);
|
||||
|
||||
// Compute default page (by slug or sort order)
|
||||
const defaultPage = useMemo(() => {
|
||||
if (!pages.length) return null;
|
||||
|
||||
// Try entry page slug first
|
||||
if (entryPageSlug) {
|
||||
const bySlug = pages.find((p) => p.slug === entryPageSlug);
|
||||
if (bySlug) return bySlug;
|
||||
}
|
||||
|
||||
// Try explicit default page ID
|
||||
if (defaultPageId) {
|
||||
const byId = pages.find((p) => p.id === defaultPageId);
|
||||
if (byId) return byId;
|
||||
}
|
||||
|
||||
// Fall back to first by sort order
|
||||
return [...pages].sort(
|
||||
(a, b) => (a.sort_order || 0) - (b.sort_order || 0),
|
||||
)[0];
|
||||
}, [pages, defaultPageId, entryPageSlug]);
|
||||
|
||||
// Get current page object
|
||||
const currentPage = useMemo(() => {
|
||||
if (!pages.length) return null;
|
||||
if (currentPageId) {
|
||||
return pages.find((p) => p.id === currentPageId) || null;
|
||||
}
|
||||
return defaultPage;
|
||||
}, [pages, currentPageId, defaultPage]);
|
||||
|
||||
// Previous page ID from history
|
||||
const previousPageId = useMemo(() => {
|
||||
if (pageHistory.length < 2) return null;
|
||||
return pageHistory[pageHistory.length - 2];
|
||||
}, [pageHistory]);
|
||||
|
||||
// Check if navigating to a page would be a "back" navigation
|
||||
const isBackNavigation = useCallback(
|
||||
(targetPageId: string): boolean => {
|
||||
return previousPageId === targetPageId;
|
||||
},
|
||||
[previousPageId],
|
||||
);
|
||||
|
||||
// Apply page selection with history tracking
|
||||
const applyPageSelection = useCallback(
|
||||
(targetPageId: string, isBack = false) => {
|
||||
setCurrentPageIdState(targetPageId);
|
||||
|
||||
if (trackHistory) {
|
||||
setPageHistory((prev) => {
|
||||
if (!prev.length) return [targetPageId];
|
||||
const currentId = prev[prev.length - 1];
|
||||
if (currentId === targetPageId) return prev;
|
||||
|
||||
// If going back and target matches previous, pop history
|
||||
if (
|
||||
isBack &&
|
||||
prev.length > 1 &&
|
||||
prev[prev.length - 2] === targetPageId
|
||||
) {
|
||||
return prev.slice(0, -1);
|
||||
}
|
||||
|
||||
return [...prev, targetPageId];
|
||||
});
|
||||
}
|
||||
|
||||
onPageChange?.(targetPageId, isBack);
|
||||
},
|
||||
[trackHistory, onPageChange],
|
||||
);
|
||||
|
||||
// Simple setter (no history tracking)
|
||||
const setCurrentPageId = useCallback(
|
||||
(pageId: string) => {
|
||||
setCurrentPageIdState(pageId);
|
||||
if (trackHistory && pageHistory.length === 0) {
|
||||
setPageHistory([pageId]);
|
||||
}
|
||||
},
|
||||
[trackHistory, pageHistory.length],
|
||||
);
|
||||
|
||||
// Go back in history
|
||||
const goBack = useCallback((): boolean => {
|
||||
if (!trackHistory || pageHistory.length < 2) return false;
|
||||
const targetPageId = pageHistory[pageHistory.length - 2];
|
||||
applyPageSelection(targetPageId, true);
|
||||
return true;
|
||||
}, [trackHistory, pageHistory, applyPageSelection]);
|
||||
|
||||
// Reset history
|
||||
const resetHistory = useCallback(() => {
|
||||
setPageHistory(currentPageId ? [currentPageId] : []);
|
||||
}, [currentPageId]);
|
||||
|
||||
// Initialize to default page
|
||||
useEffect(() => {
|
||||
if (defaultPage?.id && !currentPageId) {
|
||||
setCurrentPageIdState(defaultPage.id);
|
||||
if (trackHistory) {
|
||||
setPageHistory((prev) => (prev.length ? prev : [defaultPage.id]));
|
||||
}
|
||||
}
|
||||
}, [defaultPage, currentPageId, trackHistory]);
|
||||
|
||||
return {
|
||||
currentPageId,
|
||||
currentPage,
|
||||
pageHistory,
|
||||
previousPageId,
|
||||
defaultPage,
|
||||
setCurrentPageId,
|
||||
applyPageSelection,
|
||||
isBackNavigation,
|
||||
goBack,
|
||||
resetHistory,
|
||||
};
|
||||
}
|
||||
@ -9,36 +9,21 @@ import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { useNeighborGraph } from './useNeighborGraph';
|
||||
import { useNetworkAware } from './useNetworkAware';
|
||||
import { downloadEventBus } from '../lib/offline/DownloadEventBus';
|
||||
import { StorageManager } from '../lib/offline/StorageManager';
|
||||
import { PRELOAD_CONFIG } from '../config/preload.config';
|
||||
import { OFFLINE_CONFIG } from '../config/offline.config';
|
||||
import { resolveAssetPlaybackUrl } from '../lib/assetUrl';
|
||||
import { logger } from '../lib/logger';
|
||||
|
||||
interface Page {
|
||||
id: string;
|
||||
background_image_url?: string;
|
||||
background_video_url?: string;
|
||||
}
|
||||
|
||||
interface Element {
|
||||
id: string;
|
||||
pageId?: string;
|
||||
element_type?: string;
|
||||
content_json?: string;
|
||||
}
|
||||
|
||||
interface PageLink {
|
||||
id: string;
|
||||
from_pageId?: string;
|
||||
to_pageId?: string;
|
||||
transitionId?: string;
|
||||
is_active?: boolean;
|
||||
}
|
||||
import type {
|
||||
PreloadPage,
|
||||
PreloadPageLink,
|
||||
PreloadElement,
|
||||
} from '../types/preload';
|
||||
|
||||
interface UsePreloadOrchestratorOptions {
|
||||
pages: Page[];
|
||||
pageLinks: PageLink[];
|
||||
elements: Element[];
|
||||
pages: PreloadPage[];
|
||||
pageLinks: PreloadPageLink[];
|
||||
elements: PreloadElement[];
|
||||
currentPageId: string | null;
|
||||
pageHistory?: string[];
|
||||
enabled?: boolean;
|
||||
@ -71,19 +56,12 @@ const generateJobId = (): string => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a URL is already cached (simplified check)
|
||||
* Check if a URL is already cached (checks both IndexedDB and Cache API)
|
||||
*/
|
||||
const isUrlCached = async (url: string): Promise<boolean> => {
|
||||
if (typeof caches === 'undefined') return false;
|
||||
|
||||
try {
|
||||
const cacheNames = await caches.keys();
|
||||
for (const cacheName of cacheNames) {
|
||||
const cache = await caches.open(cacheName);
|
||||
const response = await cache.match(url);
|
||||
if (response) return true;
|
||||
}
|
||||
return false;
|
||||
// StorageManager.hasAsset checks both IndexedDB (large files) and Cache API (small files)
|
||||
return StorageManager.hasAsset(url);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@ -117,7 +95,15 @@ const decodeImage = async (url: string): Promise<void> => {
|
||||
* Check if URL is an image based on extension or content type
|
||||
*/
|
||||
const isImageUrl = (url: string): boolean => {
|
||||
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.avif', '.svg'];
|
||||
const imageExtensions = [
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
'.png',
|
||||
'.gif',
|
||||
'.webp',
|
||||
'.avif',
|
||||
'.svg',
|
||||
];
|
||||
const lowerUrl = url.toLowerCase();
|
||||
return imageExtensions.some((ext) => lowerUrl.includes(ext));
|
||||
};
|
||||
@ -198,13 +184,17 @@ const preloadWithProgress = async (
|
||||
const cache = await caches.open(OFFLINE_CONFIG.cacheNames.assets);
|
||||
// Create a new response from collected chunks
|
||||
const blob = new Blob(chunks as BlobPart[], {
|
||||
type: responseToCache.headers.get('content-type') || 'application/octet-stream',
|
||||
type:
|
||||
responseToCache.headers.get('content-type') ||
|
||||
'application/octet-stream',
|
||||
});
|
||||
const cachedResponse = new Response(blob, {
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: {
|
||||
'Content-Type': responseToCache.headers.get('content-type') || 'application/octet-stream',
|
||||
'Content-Type':
|
||||
responseToCache.headers.get('content-type') ||
|
||||
'application/octet-stream',
|
||||
'Content-Length': String(bytesLoaded),
|
||||
},
|
||||
});
|
||||
@ -304,15 +294,23 @@ export function usePreloadOrchestrator(
|
||||
activeDownloadsRef.current++;
|
||||
|
||||
const jobId = generateJobId();
|
||||
logger.info('[PRELOAD] Starting download', { url: item.url.slice(-50), assetType: item.assetType });
|
||||
logger.info('[PRELOAD] Starting download', {
|
||||
url: item.url.slice(-50),
|
||||
assetType: item.assetType,
|
||||
});
|
||||
|
||||
preloadWithProgress(item.url, jobId, item.id)
|
||||
.then(() => {
|
||||
logger.info('[PRELOAD] Download complete', { url: item.url.slice(-50) });
|
||||
logger.info('[PRELOAD] Download complete', {
|
||||
url: item.url.slice(-50),
|
||||
});
|
||||
preloadedUrls.add(item.url);
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error('[PRELOAD] Download failed', { url: item.url.slice(-50), error: err?.message });
|
||||
logger.error('[PRELOAD] Download failed', {
|
||||
url: item.url.slice(-50),
|
||||
error: err?.message,
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
activeDownloadsRef.current--;
|
||||
@ -340,7 +338,10 @@ export function usePreloadOrchestrator(
|
||||
preloadedUrls.has(item.url) ||
|
||||
queueRef.current.some((q) => q.url === item.url)
|
||||
) {
|
||||
logger.info('[PRELOAD] Skipping (already queued/preloaded)', { url: item.url.slice(-50), assetType: item.assetType });
|
||||
logger.info('[PRELOAD] Skipping (already queued/preloaded)', {
|
||||
url: item.url.slice(-50),
|
||||
assetType: item.assetType,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@ -348,7 +349,7 @@ export function usePreloadOrchestrator(
|
||||
url: item.url.slice(-60),
|
||||
assetType: item.assetType,
|
||||
priority: item.priority,
|
||||
queueLength: queueRef.current.length + 1
|
||||
queueLength: queueRef.current.length + 1,
|
||||
});
|
||||
|
||||
// Insert in priority order (higher priority first)
|
||||
@ -389,29 +390,33 @@ export function usePreloadOrchestrator(
|
||||
}, []);
|
||||
|
||||
// Get a cached asset as a blob URL (for video playback)
|
||||
const getCachedBlobUrl = useCallback(async (url: string): Promise<string | null> => {
|
||||
if (typeof caches === 'undefined') return null;
|
||||
|
||||
try {
|
||||
const cache = await caches.open(OFFLINE_CONFIG.cacheNames.assets);
|
||||
const response = await cache.match(url);
|
||||
if (!response) return null;
|
||||
|
||||
const blob = await response.blob();
|
||||
return URL.createObjectURL(blob);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
// StorageManager.getAsset checks both IndexedDB (large files ≥ 5MB) and Cache API (small files)
|
||||
const getCachedBlobUrl = useCallback(
|
||||
async (url: string): Promise<string | null> => {
|
||||
try {
|
||||
const blob = await StorageManager.getAsset(url);
|
||||
if (blob) {
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Check if URL is preloaded (in cache)
|
||||
const isUrlPreloaded = useCallback(async (url: string): Promise<boolean> => {
|
||||
// First check in-memory set
|
||||
if (preloadedUrls.has(url)) return true;
|
||||
const isUrlPreloaded = useCallback(
|
||||
async (url: string): Promise<boolean> => {
|
||||
// First check in-memory set
|
||||
if (preloadedUrls.has(url)) return true;
|
||||
|
||||
// Then check Cache API
|
||||
return isUrlCached(url);
|
||||
}, [preloadedUrls]);
|
||||
// Then check Cache API
|
||||
return isUrlCached(url);
|
||||
},
|
||||
[preloadedUrls],
|
||||
);
|
||||
|
||||
// React to page changes - preload neighbors
|
||||
useEffect(() => {
|
||||
@ -432,7 +437,10 @@ export function usePreloadOrchestrator(
|
||||
lastPreloadedPageRef.current = currentPageId;
|
||||
lastPreloadedLinksCountRef.current = currentLinksCount;
|
||||
|
||||
logger.info('[PRELOAD] Starting preload for page', { currentPageId, maxNeighborDepth });
|
||||
logger.info('[PRELOAD] Starting preload for page', {
|
||||
currentPageId,
|
||||
maxNeighborDepth,
|
||||
});
|
||||
|
||||
// Get prioritized assets based on current page
|
||||
const assets = neighborGraph.getPrioritizedAssets(
|
||||
@ -442,7 +450,7 @@ export function usePreloadOrchestrator(
|
||||
|
||||
logger.info('[PRELOAD] Found assets from neighbor graph', {
|
||||
assetCount: assets.length,
|
||||
assets: assets.map(a => ({ type: a.assetType, url: a.url.slice(-50) }))
|
||||
assets: assets.map((a) => ({ type: a.assetType, url: a.url.slice(-50) })),
|
||||
});
|
||||
|
||||
// Add background assets from pages
|
||||
@ -496,7 +504,9 @@ export function usePreloadOrchestrator(
|
||||
neighbors.forEach(({ pageId }) => {
|
||||
const page = pages.find((p) => p.id === pageId);
|
||||
if (page?.background_image_url) {
|
||||
const resolvedUrl = resolveAssetPlaybackUrl(page.background_image_url);
|
||||
const resolvedUrl = resolveAssetPlaybackUrl(
|
||||
page.background_image_url,
|
||||
);
|
||||
if (resolvedUrl) {
|
||||
addToQueue({
|
||||
id: `bg-img-${pageId}`,
|
||||
|
||||
@ -4,26 +4,26 @@ import {
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type MutableRefObject,
|
||||
type RefObject,
|
||||
} from 'react';
|
||||
import { logger } from '../lib/logger';
|
||||
|
||||
interface UseReversePlaybackOptions {
|
||||
videoRef: RefObject<HTMLVideoElement | null>;
|
||||
duration: number;
|
||||
onComplete: () => void;
|
||||
preloadedUrls?: Set<string>;
|
||||
videoUrl?: string;
|
||||
getCachedBlobUrl?: (url: string) => Promise<string | null>;
|
||||
}
|
||||
|
||||
interface UseReversePlaybackResult {
|
||||
startReverse: () => Promise<void>;
|
||||
stopReverse: () => void;
|
||||
isReversing: boolean;
|
||||
isBuffering: boolean;
|
||||
canUseNativeReverse: boolean;
|
||||
}
|
||||
|
||||
// Feature detection for native reverse playback (Chrome 141+, Safari 16+)
|
||||
function checkNativeReverseSupport(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
try {
|
||||
@ -35,14 +35,13 @@ function checkNativeReverseSupport(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
// Native playbackRate = -1 (Chrome 141+, Safari 16+)
|
||||
async function startNativeReverse(
|
||||
video: HTMLVideoElement,
|
||||
duration: number,
|
||||
onComplete: () => void
|
||||
): Promise<void> {
|
||||
onComplete: () => void,
|
||||
didFinishRef: { current: boolean },
|
||||
): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
// Seek to end
|
||||
video.currentTime = duration;
|
||||
video.playbackRate = -1;
|
||||
|
||||
@ -52,188 +51,348 @@ async function startNativeReverse(
|
||||
};
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
if (didFinishRef.current) {
|
||||
cleanup();
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
if (video.currentTime <= 0.05) {
|
||||
cleanup();
|
||||
video.pause();
|
||||
video.currentTime = 0;
|
||||
onComplete();
|
||||
resolve();
|
||||
resolve(true);
|
||||
}
|
||||
};
|
||||
|
||||
video.addEventListener('timeupdate', onTimeUpdate);
|
||||
|
||||
video.play().catch(() => {
|
||||
// Fallback if native reverse fails
|
||||
video.play().catch((err) => {
|
||||
logger.error('Native reverse play failed:', err);
|
||||
cleanup();
|
||||
resolve();
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// When video is already cached, seeking to end is instant
|
||||
async function startPreloadedReverse(
|
||||
video: HTMLVideoElement,
|
||||
duration: number,
|
||||
onComplete: () => void,
|
||||
intervalRef: MutableRefObject<number | null>
|
||||
): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
video.pause();
|
||||
video.currentTime = duration; // Instant seek (video is cached)
|
||||
|
||||
// Use 30 fps for smoother reverse when video is preloaded
|
||||
const fps = 30;
|
||||
const stepSize = 1 / fps;
|
||||
|
||||
intervalRef.current = window.setInterval(() => {
|
||||
const newTime = video.currentTime - stepSize;
|
||||
|
||||
if (newTime <= 0) {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
video.currentTime = 0;
|
||||
onComplete();
|
||||
resolve();
|
||||
} else {
|
||||
video.currentTime = newTime;
|
||||
}
|
||||
}, 1000 / fps);
|
||||
});
|
||||
}
|
||||
|
||||
// For non-preloaded videos, wait for canplaythrough then reverse
|
||||
async function startBufferedReverse(
|
||||
video: HTMLVideoElement,
|
||||
duration: number,
|
||||
onComplete: () => void,
|
||||
intervalRef: MutableRefObject<number | null>
|
||||
): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const startFrameStepping = () => {
|
||||
video.pause();
|
||||
video.currentTime = duration;
|
||||
|
||||
// Lower fps for non-cached videos (less seeking overhead)
|
||||
const fps = 15;
|
||||
const stepSize = 1 / fps;
|
||||
|
||||
intervalRef.current = window.setInterval(() => {
|
||||
const newTime = video.currentTime - stepSize;
|
||||
|
||||
if (newTime <= 0) {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
video.currentTime = 0;
|
||||
onComplete();
|
||||
resolve();
|
||||
} else {
|
||||
video.currentTime = newTime;
|
||||
}
|
||||
}, 1000 / fps);
|
||||
};
|
||||
|
||||
// Check if video is already buffered enough
|
||||
const isFullyBuffered =
|
||||
video.readyState >= 4 ||
|
||||
(video.buffered.length > 0 &&
|
||||
video.buffered.end(video.buffered.length - 1) >= duration - 0.1);
|
||||
|
||||
if (isFullyBuffered) {
|
||||
startFrameStepping();
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for buffering to complete
|
||||
const onCanPlayThrough = () => {
|
||||
video.removeEventListener('canplaythrough', onCanPlayThrough);
|
||||
startFrameStepping();
|
||||
};
|
||||
|
||||
video.addEventListener('canplaythrough', onCanPlayThrough);
|
||||
|
||||
// Also try seeking to end to force buffering
|
||||
video.currentTime = duration;
|
||||
video.play().catch(() => {
|
||||
// If play fails, just wait for canplaythrough
|
||||
});
|
||||
|
||||
// Timeout fallback - if canplaythrough doesn't fire within 3s, start anyway
|
||||
setTimeout(() => {
|
||||
video.removeEventListener('canplaythrough', onCanPlayThrough);
|
||||
if (!intervalRef.current) {
|
||||
startFrameStepping();
|
||||
}
|
||||
}, 3000);
|
||||
});
|
||||
function getBufferedEnd(video: HTMLVideoElement): number {
|
||||
return video.buffered.length > 0
|
||||
? video.buffered.end(video.buffered.length - 1)
|
||||
: 0;
|
||||
}
|
||||
|
||||
export function useReversePlayback(
|
||||
options: UseReversePlaybackOptions
|
||||
options: UseReversePlaybackOptions,
|
||||
): UseReversePlaybackResult {
|
||||
const [isReversing, setIsReversing] = useState(false);
|
||||
const intervalRef = useRef<number | null>(null);
|
||||
const [isBuffering, setIsBuffering] = useState(false);
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
const didFinishRef = useRef(false);
|
||||
const cleanupFnsRef = useRef<Array<() => void>>([]);
|
||||
|
||||
// Feature detection for native reverse
|
||||
const canUseNativeReverse = useMemo(() => checkNativeReverseSupport(), []);
|
||||
|
||||
const stopReverse = useCallback(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
const cleanup = useCallback(() => {
|
||||
didFinishRef.current = true;
|
||||
if (animationFrameRef.current !== null) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
animationFrameRef.current = null;
|
||||
}
|
||||
setIsReversing(false);
|
||||
|
||||
// Reset playback rate if it was modified
|
||||
cleanupFnsRef.current.forEach((fn) => fn());
|
||||
cleanupFnsRef.current = [];
|
||||
const video = options.videoRef.current;
|
||||
if (video && video.playbackRate !== 1) {
|
||||
video.playbackRate = 1;
|
||||
}
|
||||
}, [options.videoRef]);
|
||||
|
||||
const stopReverse = useCallback(() => {
|
||||
cleanup();
|
||||
setIsReversing(false);
|
||||
setIsBuffering(false);
|
||||
}, [cleanup]);
|
||||
|
||||
const startReverse = useCallback(async () => {
|
||||
const video = options.videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
// Stop any existing reverse playback
|
||||
stopReverse();
|
||||
|
||||
didFinishRef.current = false;
|
||||
setIsReversing(true);
|
||||
|
||||
const { duration, onComplete, preloadedUrls, videoUrl } = options;
|
||||
const { onComplete, preloadedUrls, videoUrl, getCachedBlobUrl } = options;
|
||||
|
||||
// Strategy 1: Native playbackRate = -1 (smoothest)
|
||||
if (canUseNativeReverse) {
|
||||
await startNativeReverse(video, duration, () => {
|
||||
setIsReversing(false);
|
||||
onComplete();
|
||||
const actualDuration = video.duration;
|
||||
if (!Number.isFinite(actualDuration) || actualDuration <= 0) {
|
||||
logger.error('Invalid video duration for reverse playback', {
|
||||
videoDuration: video.duration,
|
||||
readyState: video.readyState,
|
||||
videoUrl,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Strategy 2: Check if video is preloaded (instant seek)
|
||||
const isPreloaded = videoUrl && preloadedUrls?.has(videoUrl);
|
||||
if (isPreloaded) {
|
||||
await startPreloadedReverse(video, duration, () => {
|
||||
setIsReversing(false);
|
||||
onComplete();
|
||||
}, intervalRef);
|
||||
return;
|
||||
}
|
||||
|
||||
// Strategy 3: Wait for buffering then reverse
|
||||
await startBufferedReverse(video, duration, () => {
|
||||
setIsReversing(false);
|
||||
onComplete();
|
||||
}, intervalRef);
|
||||
}, [canUseNativeReverse, options, stopReverse]);
|
||||
return;
|
||||
}
|
||||
|
||||
const finishReverse = (reason: string) => {
|
||||
if (didFinishRef.current) return;
|
||||
didFinishRef.current = true;
|
||||
cleanup();
|
||||
setIsReversing(false);
|
||||
setIsBuffering(false);
|
||||
logger.info('Reverse playback finished', { reason });
|
||||
onComplete();
|
||||
};
|
||||
|
||||
// Strategy 1: Native playbackRate = -1
|
||||
if (canUseNativeReverse) {
|
||||
logger.info('Using native playbackRate = -1');
|
||||
const success = await startNativeReverse(
|
||||
video,
|
||||
actualDuration,
|
||||
() => finishReverse('native-complete'),
|
||||
didFinishRef,
|
||||
);
|
||||
if (success) return;
|
||||
logger.info('Native reverse failed, falling back to frame-stepping');
|
||||
}
|
||||
|
||||
// Check if video is preloaded
|
||||
const isPreloaded = videoUrl && preloadedUrls?.has(videoUrl);
|
||||
const fps = isPreloaded ? 30 : 15;
|
||||
const stepSize = 1 / fps;
|
||||
|
||||
// Try to get blob URL from cache for better seeking
|
||||
if (getCachedBlobUrl && videoUrl && !video.src.startsWith('blob:')) {
|
||||
try {
|
||||
const blobUrl = await getCachedBlobUrl(videoUrl);
|
||||
if (blobUrl && !didFinishRef.current) {
|
||||
logger.info('Using cached blob URL for reverse');
|
||||
video.src = blobUrl;
|
||||
video.load();
|
||||
await new Promise<void>((resolve) => {
|
||||
const onLoaded = () => {
|
||||
video.removeEventListener('loadeddata', onLoaded);
|
||||
resolve();
|
||||
};
|
||||
video.addEventListener('loadeddata', onLoaded);
|
||||
setTimeout(resolve, 2000);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Failed to get cached blob URL', { err });
|
||||
}
|
||||
}
|
||||
|
||||
const startFrameStepping = (targetTime: number) => {
|
||||
const maxBuffered = getBufferedEnd(video);
|
||||
const safeTarget = Math.min(targetTime, maxBuffered);
|
||||
|
||||
logger.info('Preparing reverse seek', {
|
||||
requestedTarget: targetTime,
|
||||
maxBuffered,
|
||||
safeTarget,
|
||||
readyState: video.readyState,
|
||||
});
|
||||
|
||||
if (safeTarget < 0.1) {
|
||||
logger.info('No buffered range available');
|
||||
finishReverse('no-buffered');
|
||||
return;
|
||||
}
|
||||
|
||||
const beginFrameStepping = () => {
|
||||
video.pause();
|
||||
let lastFrameTime = performance.now();
|
||||
let stepCount = 0;
|
||||
|
||||
const step = (currentFrameTime: number) => {
|
||||
if (didFinishRef.current) {
|
||||
animationFrameRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!video.paused) {
|
||||
video.pause();
|
||||
}
|
||||
|
||||
const elapsed = currentFrameTime - lastFrameTime;
|
||||
if (elapsed < 1000 / fps) {
|
||||
animationFrameRef.current = requestAnimationFrame(step);
|
||||
return;
|
||||
}
|
||||
lastFrameTime = currentFrameTime;
|
||||
stepCount++;
|
||||
|
||||
const currentTime = video.currentTime;
|
||||
const newTime = currentTime - stepSize;
|
||||
|
||||
if (stepCount % 30 === 0) {
|
||||
logger.info('Frame-stepping progress', { stepCount, currentTime });
|
||||
}
|
||||
|
||||
if (newTime <= 0) {
|
||||
animationFrameRef.current = null;
|
||||
video.currentTime = 0;
|
||||
finishReverse('reverse-complete');
|
||||
} else {
|
||||
video.currentTime = newTime;
|
||||
animationFrameRef.current = requestAnimationFrame(step);
|
||||
}
|
||||
};
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(step);
|
||||
};
|
||||
|
||||
const onSeeked = () => {
|
||||
video.removeEventListener('seeked', onSeeked);
|
||||
if (didFinishRef.current) return;
|
||||
|
||||
if (video.currentTime < 0.1) {
|
||||
logger.info('Seek failed, retrying');
|
||||
const onRetry = () => {
|
||||
video.removeEventListener('seeked', onRetry);
|
||||
if (didFinishRef.current) return;
|
||||
if (video.currentTime >= 0.1) {
|
||||
beginFrameStepping();
|
||||
} else {
|
||||
finishReverse('seek-failed');
|
||||
}
|
||||
};
|
||||
video.addEventListener('seeked', onRetry);
|
||||
cleanupFnsRef.current.push(() =>
|
||||
video.removeEventListener('seeked', onRetry),
|
||||
);
|
||||
video.currentTime = safeTarget;
|
||||
return;
|
||||
}
|
||||
|
||||
beginFrameStepping();
|
||||
};
|
||||
|
||||
video.addEventListener('seeked', onSeeked);
|
||||
cleanupFnsRef.current.push(() =>
|
||||
video.removeEventListener('seeked', onSeeked),
|
||||
);
|
||||
|
||||
if (!video.paused) {
|
||||
const onPause = () => {
|
||||
video.removeEventListener('pause', onPause);
|
||||
if (didFinishRef.current) return;
|
||||
requestAnimationFrame(() => {
|
||||
if (!didFinishRef.current) {
|
||||
video.currentTime = safeTarget;
|
||||
}
|
||||
});
|
||||
};
|
||||
video.addEventListener('pause', onPause);
|
||||
cleanupFnsRef.current.push(() =>
|
||||
video.removeEventListener('pause', onPause),
|
||||
);
|
||||
video.pause();
|
||||
} else {
|
||||
video.currentTime = safeTarget;
|
||||
}
|
||||
};
|
||||
|
||||
const bufferedEnd = getBufferedEnd(video);
|
||||
|
||||
logger.info('Starting reverse playback', {
|
||||
duration: actualDuration,
|
||||
bufferedEnd,
|
||||
isPreloaded,
|
||||
readyState: video.readyState,
|
||||
});
|
||||
|
||||
// If already fully buffered, start immediately
|
||||
if (bufferedEnd >= actualDuration - 0.1) {
|
||||
logger.info('Video fully buffered, starting immediately');
|
||||
startFrameStepping(actualDuration);
|
||||
return;
|
||||
}
|
||||
|
||||
// Need to buffer more
|
||||
setIsBuffering(true);
|
||||
let progressCheckCount = 0;
|
||||
const maxProgressChecks = 160; // ~8 seconds at 20 checks/sec
|
||||
|
||||
const onProgress = () => {
|
||||
if (didFinishRef.current) return;
|
||||
progressCheckCount++;
|
||||
const currentBuffered = getBufferedEnd(video);
|
||||
|
||||
if (currentBuffered >= actualDuration - 0.1) {
|
||||
setIsBuffering(false);
|
||||
startFrameStepping(actualDuration);
|
||||
return;
|
||||
}
|
||||
|
||||
if (progressCheckCount >= maxProgressChecks) {
|
||||
setIsBuffering(false);
|
||||
if (currentBuffered >= 0.5) {
|
||||
startFrameStepping(currentBuffered);
|
||||
} else {
|
||||
finishReverse('buffer-timeout');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onCanPlayThrough = () => {
|
||||
if (didFinishRef.current) return;
|
||||
const currentBuffered = getBufferedEnd(video);
|
||||
if (currentBuffered >= 0.5) {
|
||||
setIsBuffering(false);
|
||||
const targetTime = Math.min(currentBuffered, actualDuration);
|
||||
startFrameStepping(targetTime);
|
||||
}
|
||||
};
|
||||
|
||||
const onEnded = () => {
|
||||
if (didFinishRef.current) return;
|
||||
setIsBuffering(false);
|
||||
const currentBuffered = getBufferedEnd(video);
|
||||
if (currentBuffered >= 0.5) {
|
||||
startFrameStepping(currentBuffered);
|
||||
} else {
|
||||
startFrameStepping(actualDuration);
|
||||
}
|
||||
};
|
||||
|
||||
video.addEventListener('progress', onProgress);
|
||||
video.addEventListener('canplaythrough', onCanPlayThrough);
|
||||
video.addEventListener('ended', onEnded);
|
||||
cleanupFnsRef.current.push(
|
||||
() => video.removeEventListener('progress', onProgress),
|
||||
() => video.removeEventListener('canplaythrough', onCanPlayThrough),
|
||||
() => video.removeEventListener('ended', onEnded),
|
||||
);
|
||||
|
||||
// Start playback to trigger buffering
|
||||
video.muted = true;
|
||||
video.currentTime = 0;
|
||||
video.play().catch(() => undefined);
|
||||
|
||||
// Fallback timeout
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (didFinishRef.current) return;
|
||||
setIsBuffering(false);
|
||||
const currentBuffered = getBufferedEnd(video);
|
||||
if (currentBuffered >= 0.5) {
|
||||
startFrameStepping(currentBuffered);
|
||||
} else {
|
||||
finishReverse('buffer-timeout');
|
||||
}
|
||||
}, 8000);
|
||||
cleanupFnsRef.current.push(() => clearTimeout(timeoutId));
|
||||
}, [canUseNativeReverse, options, stopReverse, cleanup]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => () => stopReverse(), [stopReverse]);
|
||||
|
||||
return { startReverse, stopReverse, isReversing, canUseNativeReverse };
|
||||
return {
|
||||
startReverse,
|
||||
stopReverse,
|
||||
isReversing,
|
||||
isBuffering,
|
||||
canUseNativeReverse,
|
||||
};
|
||||
}
|
||||
|
||||
635
frontend/src/hooks/useTransitionPlayback.ts
Normal file
635
frontend/src/hooks/useTransitionPlayback.ts
Normal file
@ -0,0 +1,635 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type RefObject,
|
||||
} from 'react';
|
||||
import axios from 'axios';
|
||||
import { logger } from '../lib/logger';
|
||||
import { useReversePlayback } from './useReversePlayback';
|
||||
|
||||
export type ReverseMode = 'none' | 'reverse' | 'separate';
|
||||
|
||||
export interface TransitionConfig {
|
||||
videoUrl: string;
|
||||
reverseMode: ReverseMode;
|
||||
reverseVideoUrl?: string;
|
||||
durationSec?: number;
|
||||
targetPageId?: string;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
export interface UseTransitionPlaybackOptions {
|
||||
videoRef: RefObject<HTMLVideoElement | null>;
|
||||
transition: TransitionConfig | null;
|
||||
onComplete: (targetPageId?: string) => void;
|
||||
onError?: (reason: string) => void;
|
||||
|
||||
timeouts?: {
|
||||
playbackStartMs?: number;
|
||||
durationBufferMs?: number;
|
||||
hardTimeoutMs?: number;
|
||||
};
|
||||
features?: {
|
||||
useBlobUrl?: boolean;
|
||||
preDecodeImages?: boolean;
|
||||
getTargetPageImages?: () => string[];
|
||||
};
|
||||
preload?: {
|
||||
preloadedUrls?: Set<string>;
|
||||
getCachedBlobUrl?: (url: string) => Promise<string | null>;
|
||||
};
|
||||
}
|
||||
|
||||
export type PlaybackPhase =
|
||||
| 'idle'
|
||||
| 'preparing'
|
||||
| 'playing'
|
||||
| 'reversing'
|
||||
| 'finishing'
|
||||
| 'completed';
|
||||
|
||||
export interface UseTransitionPlaybackResult {
|
||||
phase: PlaybackPhase;
|
||||
isBuffering: boolean;
|
||||
isReversing: boolean;
|
||||
cancel: () => void;
|
||||
forceComplete: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUTS = {
|
||||
playbackStartMs: 3000,
|
||||
durationBufferMs: 200,
|
||||
hardTimeoutMs: 45000,
|
||||
};
|
||||
|
||||
function getBufferedEnd(video: HTMLVideoElement): number {
|
||||
return video.buffered.length > 0
|
||||
? video.buffered.end(video.buffered.length - 1)
|
||||
: 0;
|
||||
}
|
||||
|
||||
function shouldLoadViaBlob(
|
||||
url: string,
|
||||
reverseMode: ReverseMode,
|
||||
useBlobUrlOption?: boolean,
|
||||
): boolean {
|
||||
if (useBlobUrlOption === false) return false;
|
||||
if (reverseMode === 'reverse') return true;
|
||||
if (useBlobUrlOption === true) return true;
|
||||
|
||||
try {
|
||||
const parsedUrl = new URL(url, window.location.origin);
|
||||
const isSameOrigin = parsedUrl.origin === window.location.origin;
|
||||
if (!isSameOrigin) return false;
|
||||
return (
|
||||
parsedUrl.pathname === '/api/file/download' ||
|
||||
parsedUrl.pathname === '/file/download'
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function buildBlobRequestUrl(url: string): string {
|
||||
if (url.startsWith('/api/')) {
|
||||
return url.replace(/^\/api(?=\/)/, '');
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
async function waitForImages(urls: string[], timeoutMs = 2000): Promise<void> {
|
||||
if (urls.length === 0) return;
|
||||
|
||||
const decodePromises = urls.map(
|
||||
(url) =>
|
||||
new Promise<void>((resolve) => {
|
||||
const img = new Image();
|
||||
img.src = url;
|
||||
if (typeof img.decode === 'function') {
|
||||
img
|
||||
.decode()
|
||||
.then(() => resolve())
|
||||
.catch(() => resolve());
|
||||
} else {
|
||||
img.onload = () => resolve();
|
||||
img.onerror = () => resolve();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.race([
|
||||
Promise.all(decodePromises),
|
||||
new Promise<void>((resolve) => setTimeout(resolve, timeoutMs)),
|
||||
]);
|
||||
}
|
||||
|
||||
export function useTransitionPlayback(
|
||||
options: UseTransitionPlaybackOptions,
|
||||
): UseTransitionPlaybackResult {
|
||||
const {
|
||||
videoRef,
|
||||
transition,
|
||||
onComplete,
|
||||
onError,
|
||||
timeouts: customTimeouts,
|
||||
features,
|
||||
preload,
|
||||
} = options;
|
||||
|
||||
const playbackStartMs =
|
||||
customTimeouts?.playbackStartMs ?? DEFAULT_TIMEOUTS.playbackStartMs;
|
||||
const durationBufferMs =
|
||||
customTimeouts?.durationBufferMs ?? DEFAULT_TIMEOUTS.durationBufferMs;
|
||||
const hardTimeoutMs =
|
||||
customTimeouts?.hardTimeoutMs ?? DEFAULT_TIMEOUTS.hardTimeoutMs;
|
||||
|
||||
const [phase, setPhase] = useState<PlaybackPhase>('idle');
|
||||
const [isReverseBufferingLocal, setIsReverseBufferingLocal] = useState(false);
|
||||
|
||||
const didFinishRef = useRef(false);
|
||||
const didStartPlaybackRef = useRef(false);
|
||||
const activeSourceUrlRef = useRef<string | null>(null);
|
||||
const lastLoadedBlobUrlRef = useRef<string | null>(null);
|
||||
const lastLoadedSourceUrlRef = useRef<string | null>(null);
|
||||
const startWatchdogTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
const finishTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const hardTimeoutTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const onCompleteRef = useRef(onComplete);
|
||||
const onErrorRef = useRef(onError);
|
||||
const transitionRef = useRef(transition);
|
||||
const featuresRef = useRef(features);
|
||||
const preloadRef = useRef(preload);
|
||||
const startReverseRef = useRef<(() => Promise<void>) | null>(null);
|
||||
const stopReverseRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const sourceUrl = useMemo(() => {
|
||||
if (!transition) return '';
|
||||
return transition.reverseMode === 'separate' && transition.reverseVideoUrl
|
||||
? transition.reverseVideoUrl
|
||||
: transition.videoUrl;
|
||||
}, [transition]);
|
||||
|
||||
const clearTimers = useCallback(() => {
|
||||
if (startWatchdogTimerRef.current) {
|
||||
clearTimeout(startWatchdogTimerRef.current);
|
||||
startWatchdogTimerRef.current = null;
|
||||
}
|
||||
if (finishTimerRef.current) {
|
||||
clearTimeout(finishTimerRef.current);
|
||||
finishTimerRef.current = null;
|
||||
}
|
||||
if (hardTimeoutTimerRef.current) {
|
||||
clearTimeout(hardTimeoutTimerRef.current);
|
||||
hardTimeoutTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const revokeBlobUrl = useCallback((force = false) => {
|
||||
if (!force || !lastLoadedBlobUrlRef.current) return;
|
||||
URL.revokeObjectURL(lastLoadedBlobUrlRef.current);
|
||||
lastLoadedBlobUrlRef.current = null;
|
||||
}, []);
|
||||
|
||||
const finishPlayback = useCallback(
|
||||
async (reason: string) => {
|
||||
if (didFinishRef.current) return;
|
||||
didFinishRef.current = true;
|
||||
activeSourceUrlRef.current = null;
|
||||
clearTimers();
|
||||
|
||||
const video = videoRef.current;
|
||||
if (video) {
|
||||
video.pause();
|
||||
}
|
||||
|
||||
const currentTransition = transitionRef.current;
|
||||
const currentFeatures = featuresRef.current;
|
||||
|
||||
logger.info('Transition playback finished', {
|
||||
reason,
|
||||
displayName: currentTransition?.displayName,
|
||||
targetPageId: currentTransition?.targetPageId,
|
||||
});
|
||||
|
||||
setPhase('finishing');
|
||||
|
||||
if (
|
||||
currentFeatures?.preDecodeImages &&
|
||||
currentFeatures.getTargetPageImages &&
|
||||
currentTransition?.targetPageId
|
||||
) {
|
||||
try {
|
||||
const imageUrls = currentFeatures.getTargetPageImages();
|
||||
await waitForImages(imageUrls);
|
||||
} catch {
|
||||
// Ignore pre-decode errors
|
||||
}
|
||||
}
|
||||
|
||||
setPhase('completed');
|
||||
onCompleteRef.current(currentTransition?.targetPageId);
|
||||
},
|
||||
[clearTimers, videoRef],
|
||||
);
|
||||
|
||||
const handleError = useCallback(
|
||||
(reason: string) => {
|
||||
if (didFinishRef.current) return;
|
||||
logger.error('Transition playback error', { reason });
|
||||
onErrorRef.current?.(reason);
|
||||
finishPlayback(reason);
|
||||
},
|
||||
[finishPlayback],
|
||||
);
|
||||
|
||||
const handleReverseComplete = useCallback(() => {
|
||||
finishPlayback('reverse-complete');
|
||||
}, [finishPlayback]);
|
||||
|
||||
const {
|
||||
startReverse,
|
||||
stopReverse,
|
||||
isReversing,
|
||||
isBuffering: isReverseBuffering,
|
||||
} = useReversePlayback({
|
||||
videoRef,
|
||||
onComplete: handleReverseComplete,
|
||||
preloadedUrls: preload?.preloadedUrls,
|
||||
videoUrl: sourceUrl,
|
||||
getCachedBlobUrl: preload?.getCachedBlobUrl,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
onCompleteRef.current = onComplete;
|
||||
onErrorRef.current = onError;
|
||||
transitionRef.current = transition;
|
||||
featuresRef.current = features;
|
||||
preloadRef.current = preload;
|
||||
startReverseRef.current = startReverse;
|
||||
stopReverseRef.current = stopReverse;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setIsReverseBufferingLocal(isReverseBuffering);
|
||||
}, [isReverseBuffering]);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
if (phase === 'idle') return;
|
||||
clearTimers();
|
||||
stopReverseRef.current?.();
|
||||
didFinishRef.current = true;
|
||||
setPhase('idle');
|
||||
const video = videoRef.current;
|
||||
if (video) {
|
||||
video.pause();
|
||||
video.removeAttribute('src');
|
||||
video.load();
|
||||
}
|
||||
revokeBlobUrl(true);
|
||||
}, [phase, clearTimers, videoRef, revokeBlobUrl]);
|
||||
|
||||
const forceComplete = useCallback(() => {
|
||||
finishPlayback('forced');
|
||||
}, [finishPlayback]);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
const currentTransition = transitionRef.current;
|
||||
if (!currentTransition || !video || !sourceUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeSourceUrlRef.current === sourceUrl) {
|
||||
logger.info('Skipping duplicate effect for same source', { sourceUrl });
|
||||
return;
|
||||
}
|
||||
|
||||
activeSourceUrlRef.current = sourceUrl;
|
||||
didFinishRef.current = false;
|
||||
didStartPlaybackRef.current = false;
|
||||
setPhase('preparing');
|
||||
|
||||
const isReverseMode = currentTransition.reverseMode === 'reverse';
|
||||
const configuredDurationSec = Number(currentTransition.durationSec);
|
||||
|
||||
const getMediaErrorDetails = () => {
|
||||
if (!video.error) return null;
|
||||
const mediaError = video.error as MediaError & { message?: string };
|
||||
return {
|
||||
code: mediaError.code,
|
||||
message: mediaError.message || '',
|
||||
};
|
||||
};
|
||||
|
||||
const logIssue = (reason: string, error?: unknown) => {
|
||||
logger.error('Transition playback issue:', {
|
||||
reason,
|
||||
src: video.currentSrc || sourceUrl,
|
||||
readyState: video.readyState,
|
||||
networkState: video.networkState,
|
||||
duration: video.duration,
|
||||
configuredDurationSec,
|
||||
reverseMode: currentTransition.reverseMode,
|
||||
mediaError: getMediaErrorDetails(),
|
||||
error: error instanceof Error ? error : { error },
|
||||
});
|
||||
};
|
||||
|
||||
const scheduleFinishByDuration = (durationSec: number) => {
|
||||
if (
|
||||
!Number.isFinite(durationSec) ||
|
||||
durationSec <= 0 ||
|
||||
finishTimerRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
finishTimerRef.current = setTimeout(
|
||||
() => finishPlayback('duration-timer'),
|
||||
durationSec * 1000 + durationBufferMs,
|
||||
);
|
||||
};
|
||||
|
||||
const attemptPlay = () => {
|
||||
video.play().catch((playError) => {
|
||||
if (!isReverseMode) {
|
||||
logIssue('play-failed', playError);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const resolvePlayableSource = async (): Promise<string> => {
|
||||
if (
|
||||
lastLoadedBlobUrlRef.current &&
|
||||
lastLoadedSourceUrlRef.current === sourceUrl
|
||||
) {
|
||||
logger.info('Reusing cached blob URL');
|
||||
return lastLoadedBlobUrlRef.current;
|
||||
}
|
||||
|
||||
if (
|
||||
lastLoadedBlobUrlRef.current &&
|
||||
lastLoadedSourceUrlRef.current !== sourceUrl
|
||||
) {
|
||||
revokeBlobUrl(true);
|
||||
}
|
||||
|
||||
const needsBlobUrl = shouldLoadViaBlob(
|
||||
sourceUrl,
|
||||
currentTransition.reverseMode,
|
||||
featuresRef.current?.useBlobUrl,
|
||||
);
|
||||
|
||||
if (!needsBlobUrl) {
|
||||
return sourceUrl;
|
||||
}
|
||||
|
||||
const getCachedBlobUrl = preloadRef.current?.getCachedBlobUrl;
|
||||
if (getCachedBlobUrl) {
|
||||
try {
|
||||
const cachedBlobUrl = await getCachedBlobUrl(sourceUrl);
|
||||
if (cachedBlobUrl) {
|
||||
logger.info('Using preloaded blob URL from cache', {
|
||||
reverseMode: currentTransition.reverseMode,
|
||||
});
|
||||
lastLoadedBlobUrlRef.current = cachedBlobUrl;
|
||||
lastLoadedSourceUrlRef.current = sourceUrl;
|
||||
return cachedBlobUrl;
|
||||
}
|
||||
} catch (cacheError) {
|
||||
logger.warn('Cache lookup failed, falling back to fetch', {
|
||||
cacheError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Fetching video as blob for seeking support', {
|
||||
reverseMode: currentTransition.reverseMode,
|
||||
});
|
||||
const token =
|
||||
typeof window !== 'undefined'
|
||||
? localStorage.getItem('token') || ''
|
||||
: '';
|
||||
const requestUrl = buildBlobRequestUrl(sourceUrl);
|
||||
const response = await axios.get(requestUrl, {
|
||||
responseType: 'blob',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||
});
|
||||
|
||||
const blobUrl = URL.createObjectURL(response.data);
|
||||
lastLoadedBlobUrlRef.current = blobUrl;
|
||||
lastLoadedSourceUrlRef.current = sourceUrl;
|
||||
logger.info('Created blob URL for video', {
|
||||
blobUrl: blobUrl.substring(0, 50),
|
||||
});
|
||||
return blobUrl;
|
||||
};
|
||||
|
||||
const loadAndPlay = async () => {
|
||||
logger.info('loadAndPlay called', {
|
||||
reverseMode: currentTransition.reverseMode,
|
||||
sourceUrl,
|
||||
});
|
||||
|
||||
didStartPlaybackRef.current = false;
|
||||
|
||||
if (startWatchdogTimerRef.current) {
|
||||
clearTimeout(startWatchdogTimerRef.current);
|
||||
}
|
||||
|
||||
try {
|
||||
const playableSourceUrl = await resolvePlayableSource();
|
||||
if (didFinishRef.current) return;
|
||||
|
||||
video.pause();
|
||||
stopReverseRef.current?.();
|
||||
|
||||
const isSameSource =
|
||||
lastLoadedSourceUrlRef.current === playableSourceUrl;
|
||||
|
||||
if (isReverseMode && isSameSource && video.readyState >= 2) {
|
||||
logger.info('Reusing buffered video for reverse', {
|
||||
readyState: video.readyState,
|
||||
duration: video.duration,
|
||||
bufferedEnd: getBufferedEnd(video),
|
||||
});
|
||||
setPhase('reversing');
|
||||
void startReverseRef.current?.();
|
||||
return;
|
||||
}
|
||||
|
||||
video.src = playableSourceUrl;
|
||||
video.currentTime = 0;
|
||||
video.load();
|
||||
lastLoadedSourceUrlRef.current = playableSourceUrl;
|
||||
|
||||
attemptPlay();
|
||||
|
||||
startWatchdogTimerRef.current = setTimeout(() => {
|
||||
if (didStartPlaybackRef.current || didFinishRef.current) return;
|
||||
logIssue('playback-start-slow');
|
||||
if (isReverseMode) {
|
||||
didStartPlaybackRef.current = true;
|
||||
setPhase('reversing');
|
||||
void startReverseRef.current?.();
|
||||
} else {
|
||||
attemptPlay();
|
||||
}
|
||||
}, playbackStartMs);
|
||||
} catch (error) {
|
||||
logIssue('source-prepare-failed', error);
|
||||
handleError('source-prepare-failed');
|
||||
}
|
||||
};
|
||||
|
||||
const onLoadedMetadata = () => {
|
||||
if (didFinishRef.current) return;
|
||||
if (!isReverseMode) {
|
||||
video.currentTime = 0;
|
||||
attemptPlay();
|
||||
}
|
||||
};
|
||||
|
||||
const onCanPlayThrough = () => {
|
||||
if (didFinishRef.current) return;
|
||||
logger.info('canplaythrough fired', {
|
||||
reverseMode: currentTransition.reverseMode,
|
||||
didStartPlayback: didStartPlaybackRef.current,
|
||||
});
|
||||
|
||||
if (isReverseMode && !didStartPlaybackRef.current) {
|
||||
didStartPlaybackRef.current = true;
|
||||
if (startWatchdogTimerRef.current) {
|
||||
clearTimeout(startWatchdogTimerRef.current);
|
||||
startWatchdogTimerRef.current = null;
|
||||
}
|
||||
video.pause();
|
||||
setPhase('reversing');
|
||||
void startReverseRef.current?.();
|
||||
}
|
||||
};
|
||||
|
||||
const onCanPlay = () => {
|
||||
if (didFinishRef.current) return;
|
||||
if (isReverseMode && didStartPlaybackRef.current) return;
|
||||
attemptPlay();
|
||||
};
|
||||
|
||||
const onPlaying = () => {
|
||||
logger.info('onPlaying fired', {
|
||||
reverseMode: currentTransition.reverseMode,
|
||||
didStartPlayback: didStartPlaybackRef.current,
|
||||
didFinish: didFinishRef.current,
|
||||
});
|
||||
|
||||
if (didFinishRef.current) return;
|
||||
|
||||
if (isReverseMode && !didStartPlaybackRef.current) {
|
||||
logger.info('Triggering reverse from onPlaying');
|
||||
didStartPlaybackRef.current = true;
|
||||
if (startWatchdogTimerRef.current) {
|
||||
clearTimeout(startWatchdogTimerRef.current);
|
||||
startWatchdogTimerRef.current = null;
|
||||
}
|
||||
video.pause();
|
||||
setPhase('reversing');
|
||||
void startReverseRef.current?.();
|
||||
return;
|
||||
}
|
||||
|
||||
didStartPlaybackRef.current = true;
|
||||
setPhase('playing');
|
||||
|
||||
if (startWatchdogTimerRef.current) {
|
||||
clearTimeout(startWatchdogTimerRef.current);
|
||||
startWatchdogTimerRef.current = null;
|
||||
}
|
||||
|
||||
if (!isReverseMode) {
|
||||
const mediaDurationSec = Number(video.duration);
|
||||
const durationSec =
|
||||
Number.isFinite(configuredDurationSec) && configuredDurationSec > 0
|
||||
? configuredDurationSec
|
||||
: Number.isFinite(mediaDurationSec) && mediaDurationSec > 0
|
||||
? mediaDurationSec
|
||||
: NaN;
|
||||
if (Number.isFinite(durationSec) && durationSec > 0) {
|
||||
scheduleFinishByDuration(durationSec);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onEnded = () => finishPlayback('ended');
|
||||
|
||||
const onVideoError = () => {
|
||||
if (didFinishRef.current) return;
|
||||
logIssue('video-error');
|
||||
handleError('video-error');
|
||||
};
|
||||
|
||||
const onAbort = () => {
|
||||
if (didFinishRef.current) return;
|
||||
logIssue('video-abort');
|
||||
handleError('video-abort');
|
||||
};
|
||||
|
||||
const onStalled = () => {
|
||||
if (didFinishRef.current) return;
|
||||
logIssue('video-stalled');
|
||||
};
|
||||
|
||||
video.addEventListener('loadedmetadata', onLoadedMetadata);
|
||||
video.addEventListener('canplaythrough', onCanPlayThrough);
|
||||
video.addEventListener('canplay', onCanPlay);
|
||||
video.addEventListener('playing', onPlaying);
|
||||
video.addEventListener('ended', onEnded);
|
||||
video.addEventListener('error', onVideoError);
|
||||
video.addEventListener('abort', onAbort);
|
||||
video.addEventListener('stalled', onStalled);
|
||||
|
||||
hardTimeoutTimerRef.current = setTimeout(() => {
|
||||
if (didFinishRef.current) return;
|
||||
logIssue('hard-timeout');
|
||||
handleError('hard-timeout');
|
||||
}, hardTimeoutMs);
|
||||
|
||||
void loadAndPlay();
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||
video.removeEventListener('canplaythrough', onCanPlayThrough);
|
||||
video.removeEventListener('canplay', onCanPlay);
|
||||
video.removeEventListener('playing', onPlaying);
|
||||
video.removeEventListener('ended', onEnded);
|
||||
video.removeEventListener('error', onVideoError);
|
||||
video.removeEventListener('abort', onAbort);
|
||||
video.removeEventListener('stalled', onStalled);
|
||||
clearTimers();
|
||||
stopReverseRef.current?.();
|
||||
};
|
||||
}, [sourceUrl, videoRef, playbackStartMs, durationBufferMs, hardTimeoutMs, clearTimers, revokeBlobUrl, finishPlayback, handleError]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!transition) {
|
||||
setPhase('idle');
|
||||
activeSourceUrlRef.current = null;
|
||||
didFinishRef.current = false;
|
||||
didStartPlaybackRef.current = false;
|
||||
}
|
||||
}, [transition]);
|
||||
|
||||
return {
|
||||
phase,
|
||||
isBuffering: isReverseBufferingLocal,
|
||||
isReversing,
|
||||
cancel,
|
||||
forceComplete,
|
||||
};
|
||||
}
|
||||
135
frontend/src/lib/elementStyles.ts
Normal file
135
frontend/src/lib/elementStyles.ts
Normal file
@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Element Styles
|
||||
*
|
||||
* Unified types and utilities for UI element CSS styling.
|
||||
* Used by constructor, RuntimePresentation, and ui-elements admin pages.
|
||||
*/
|
||||
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
/**
|
||||
* CSS style properties supported by UI elements.
|
||||
* These properties can be set in ui-elements defaults and applied at runtime.
|
||||
*/
|
||||
export interface ElementStyleProperties {
|
||||
width?: string;
|
||||
height?: string;
|
||||
minWidth?: string;
|
||||
maxWidth?: string;
|
||||
minHeight?: string;
|
||||
maxHeight?: string;
|
||||
margin?: string;
|
||||
padding?: string;
|
||||
gap?: string;
|
||||
fontSize?: string;
|
||||
lineHeight?: string;
|
||||
fontWeight?: string;
|
||||
border?: string;
|
||||
borderRadius?: string;
|
||||
opacity?: string;
|
||||
boxShadow?: string;
|
||||
display?: string;
|
||||
position?: string;
|
||||
justifyContent?: string;
|
||||
alignItems?: string;
|
||||
textAlign?: string;
|
||||
zIndex?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Array of CSS property names for iteration.
|
||||
* Used when applying styles from element data to CSSProperties.
|
||||
*/
|
||||
export const ELEMENT_STYLE_PROPS = [
|
||||
'width',
|
||||
'height',
|
||||
'minWidth',
|
||||
'maxWidth',
|
||||
'minHeight',
|
||||
'maxHeight',
|
||||
'margin',
|
||||
'padding',
|
||||
'gap',
|
||||
'fontSize',
|
||||
'lineHeight',
|
||||
'fontWeight',
|
||||
'border',
|
||||
'borderRadius',
|
||||
'boxShadow',
|
||||
'display',
|
||||
'position',
|
||||
'justifyContent',
|
||||
'alignItems',
|
||||
'textAlign',
|
||||
'zIndex',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Properties that need numeric conversion
|
||||
*/
|
||||
const NUMERIC_PROPS = ['opacity', 'zIndex'] as const;
|
||||
|
||||
/**
|
||||
* Get trimmed CSS value from unknown input
|
||||
*/
|
||||
const getTrimmedValue = (value: unknown): string => {
|
||||
if (value === null || value === undefined) return '';
|
||||
return String(value).trim();
|
||||
};
|
||||
|
||||
/**
|
||||
* Build React CSSProperties from element style properties.
|
||||
* Handles type coercion for special properties like opacity and zIndex.
|
||||
*
|
||||
* @param element - Object containing style properties
|
||||
* @returns React CSSProperties object
|
||||
*
|
||||
* @example
|
||||
* const style = buildElementStyle({
|
||||
* width: '100px',
|
||||
* opacity: '0.5',
|
||||
* display: 'flex',
|
||||
* });
|
||||
*/
|
||||
export function buildElementStyle(
|
||||
element: Partial<ElementStyleProperties>,
|
||||
): CSSProperties {
|
||||
const style: CSSProperties = {};
|
||||
const source = element as Record<string, unknown>;
|
||||
|
||||
// Apply string properties
|
||||
ELEMENT_STYLE_PROPS.forEach((prop) => {
|
||||
const value = getTrimmedValue(source[prop]);
|
||||
if (value) {
|
||||
(style as Record<string, unknown>)[prop] = value;
|
||||
}
|
||||
});
|
||||
|
||||
// Handle opacity (needs numeric conversion)
|
||||
const opacityValue = getTrimmedValue(source.opacity);
|
||||
if (opacityValue) {
|
||||
const parsed = Number(opacityValue);
|
||||
if (Number.isFinite(parsed)) {
|
||||
style.opacity = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle zIndex (needs numeric conversion, already in style from loop but override with number)
|
||||
const zIndexValue = getTrimmedValue(source.zIndex);
|
||||
if (zIndexValue) {
|
||||
const parsed = Number(zIndexValue);
|
||||
if (Number.isFinite(parsed)) {
|
||||
style.zIndex = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return style;
|
||||
}
|
||||
|
||||
/**
|
||||
* All style property names including numeric ones.
|
||||
* Used for form state management in ui-elements admin.
|
||||
*/
|
||||
export const ALL_STYLE_PROPS = [...ELEMENT_STYLE_PROPS, 'opacity'] as const;
|
||||
|
||||
export type StylePropName = (typeof ALL_STYLE_PROPS)[number];
|
||||
141
frontend/src/lib/imagePreDecode.ts
Normal file
141
frontend/src/lib/imagePreDecode.ts
Normal file
@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Image Pre-Decode Utilities
|
||||
*
|
||||
* Utilities for pre-decoding images before displaying to prevent white flashes
|
||||
* during page transitions.
|
||||
*/
|
||||
|
||||
import { resolveAssetPlaybackUrl } from './assetUrl';
|
||||
import { PRELOAD_CONFIG } from '../config/preload.config';
|
||||
|
||||
/**
|
||||
* Minimal page interface for image extraction
|
||||
*/
|
||||
export interface PageWithImages {
|
||||
background_image_url?: string;
|
||||
ui_schema_json?: string | Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a list of image URLs in parallel with a timeout.
|
||||
*
|
||||
* @param urls - Array of image URLs to decode
|
||||
* @param timeoutMs - Maximum time to wait for all images (default: 2000ms)
|
||||
* @returns Promise that resolves when all images are decoded or timeout is reached
|
||||
*
|
||||
* @example
|
||||
* await decodeImages(['/image1.jpg', '/image2.jpg']);
|
||||
*/
|
||||
export const decodeImages = async (
|
||||
urls: string[],
|
||||
timeoutMs = 2000,
|
||||
): Promise<void> => {
|
||||
if (urls.length === 0) return;
|
||||
|
||||
const decodePromises = urls.map(
|
||||
(url) =>
|
||||
new Promise<void>((resolve) => {
|
||||
const img = new Image();
|
||||
img.src = url;
|
||||
if (typeof img.decode === 'function') {
|
||||
img
|
||||
.decode()
|
||||
.then(() => resolve())
|
||||
.catch(() => resolve());
|
||||
} else {
|
||||
img.onload = () => resolve();
|
||||
img.onerror = () => resolve();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.race([
|
||||
Promise.all(decodePromises),
|
||||
new Promise<void>((resolve) => setTimeout(resolve, timeoutMs)),
|
||||
]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts all image URLs from a page (background + element images).
|
||||
*
|
||||
* @param page - Page object with background_image_url and ui_schema_json
|
||||
* @returns Array of resolved image URLs
|
||||
*
|
||||
* @example
|
||||
* const imageUrls = extractPageImageUrls(page);
|
||||
*/
|
||||
export const extractPageImageUrls = (
|
||||
page: PageWithImages | null,
|
||||
): string[] => {
|
||||
if (!page) return [];
|
||||
|
||||
const imageUrls: string[] = [];
|
||||
|
||||
// Background image
|
||||
if (page.background_image_url) {
|
||||
const url = resolveAssetPlaybackUrl(page.background_image_url);
|
||||
if (url) imageUrls.push(url);
|
||||
}
|
||||
|
||||
// Parse ui_schema_json for element images
|
||||
try {
|
||||
const uiSchema =
|
||||
typeof page.ui_schema_json === 'string'
|
||||
? JSON.parse(page.ui_schema_json)
|
||||
: page.ui_schema_json;
|
||||
|
||||
const pageElements = Array.isArray(uiSchema?.elements)
|
||||
? uiSchema.elements
|
||||
: [];
|
||||
|
||||
const { images: imageFields, nested: nestedFields } =
|
||||
PRELOAD_CONFIG.assetFields;
|
||||
|
||||
pageElements.forEach((el: Record<string, unknown>) => {
|
||||
// Direct image fields
|
||||
imageFields.forEach((field) => {
|
||||
const value = el[field];
|
||||
if (typeof value === 'string' && value) {
|
||||
const url = resolveAssetPlaybackUrl(value);
|
||||
if (url && !imageUrls.includes(url)) imageUrls.push(url);
|
||||
}
|
||||
});
|
||||
|
||||
// Nested arrays (galleryCards, carouselSlides)
|
||||
nestedFields.forEach((nestedField) => {
|
||||
const items = el[nestedField];
|
||||
if (Array.isArray(items)) {
|
||||
items.forEach((item: Record<string, unknown>) => {
|
||||
if (typeof item.imageUrl === 'string' && item.imageUrl) {
|
||||
const url = resolveAssetPlaybackUrl(item.imageUrl);
|
||||
if (url && !imageUrls.includes(url)) imageUrls.push(url);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
|
||||
return imageUrls;
|
||||
};
|
||||
|
||||
/**
|
||||
* Waits for all images on a page to be decoded before continuing.
|
||||
* This eliminates white flash by ensuring images are ready to paint.
|
||||
*
|
||||
* @param page - Page object with images to decode
|
||||
* @param timeoutMs - Maximum time to wait (default: 2000ms)
|
||||
*
|
||||
* @example
|
||||
* await waitForPageImages(targetPage);
|
||||
* setActivePage(targetPage);
|
||||
*/
|
||||
export const waitForPageImages = async (
|
||||
page: PageWithImages | null,
|
||||
timeoutMs = 2000,
|
||||
): Promise<void> => {
|
||||
const imageUrls = extractPageImageUrls(page);
|
||||
await decodeImages(imageUrls, timeoutMs);
|
||||
};
|
||||
119
frontend/src/lib/mediaDuration.ts
Normal file
119
frontend/src/lib/mediaDuration.ts
Normal file
@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Probe media duration from a URL or File using HTML5 media element.
|
||||
* This extracts the actual duration from the media file metadata.
|
||||
*/
|
||||
|
||||
import { logger } from './logger';
|
||||
|
||||
export interface MediaDurationResult {
|
||||
duration: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe duration from a media URL
|
||||
*/
|
||||
export function probeMediaDuration(
|
||||
source: string | File,
|
||||
mediaType: 'video' | 'audio',
|
||||
timeoutMs = 10000,
|
||||
): Promise<MediaDurationResult | null> {
|
||||
return new Promise((resolve) => {
|
||||
const mediaElement =
|
||||
mediaType === 'video'
|
||||
? document.createElement('video')
|
||||
: document.createElement('audio');
|
||||
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
let resolved = false;
|
||||
|
||||
const cleanup = () => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
mediaElement.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||
mediaElement.removeEventListener('error', onError);
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
mediaElement.pause();
|
||||
if (mediaElement.src.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(mediaElement.src);
|
||||
}
|
||||
mediaElement.removeAttribute('src');
|
||||
mediaElement.load();
|
||||
};
|
||||
|
||||
const onLoadedMetadata = () => {
|
||||
const duration = mediaElement.duration;
|
||||
if (!Number.isFinite(duration) || duration <= 0) {
|
||||
logger.warn('Invalid duration from media metadata', {
|
||||
duration,
|
||||
source: typeof source === 'string' ? source : source.name,
|
||||
});
|
||||
cleanup();
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const result: MediaDurationResult = { duration };
|
||||
|
||||
if (mediaType === 'video' && mediaElement instanceof HTMLVideoElement) {
|
||||
if (mediaElement.videoWidth > 0) {
|
||||
result.width = mediaElement.videoWidth;
|
||||
}
|
||||
if (mediaElement.videoHeight > 0) {
|
||||
result.height = mediaElement.videoHeight;
|
||||
}
|
||||
}
|
||||
|
||||
cleanup();
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const onError = () => {
|
||||
logger.warn('Failed to load media for duration probe', {
|
||||
source: typeof source === 'string' ? source : source.name,
|
||||
error: mediaElement.error?.message,
|
||||
});
|
||||
cleanup();
|
||||
resolve(null);
|
||||
};
|
||||
|
||||
mediaElement.addEventListener('loadedmetadata', onLoadedMetadata);
|
||||
mediaElement.addEventListener('error', onError);
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
logger.warn('Duration probe timeout', {
|
||||
source: typeof source === 'string' ? source : source.name,
|
||||
timeoutMs,
|
||||
});
|
||||
cleanup();
|
||||
resolve(null);
|
||||
}, timeoutMs);
|
||||
|
||||
mediaElement.preload = 'metadata';
|
||||
|
||||
if (typeof source === 'string') {
|
||||
mediaElement.src = source;
|
||||
} else {
|
||||
mediaElement.src = URL.createObjectURL(source);
|
||||
}
|
||||
|
||||
mediaElement.load();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a MIME type is a video type
|
||||
*/
|
||||
export function isVideoMimeType(mimeType: string | null | undefined): boolean {
|
||||
if (!mimeType) return false;
|
||||
return mimeType.startsWith('video/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a MIME type is an audio type
|
||||
*/
|
||||
export function isAudioMimeType(mimeType: string | null | undefined): boolean {
|
||||
if (!mimeType) return false;
|
||||
return mimeType.startsWith('audio/');
|
||||
}
|
||||
89
frontend/src/lib/parseJson.ts
Normal file
89
frontend/src/lib/parseJson.ts
Normal file
@ -0,0 +1,89 @@
|
||||
/**
|
||||
* JSON Parsing Utilities
|
||||
*
|
||||
* Shared utilities for safely parsing JSON values that may be strings or already parsed.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parses a value that might be a JSON string or already an object.
|
||||
* Returns a typed object with fallback support.
|
||||
*
|
||||
* @param value - The value to parse (string, object, or undefined)
|
||||
* @param fallback - Fallback value if parsing fails (defaults to empty object)
|
||||
* @returns The parsed object or fallback
|
||||
*
|
||||
* @example
|
||||
* const schema = parseJsonObject<ConstructorSchema>(page.ui_schema_json, {});
|
||||
* const settings = parseJsonObject<Settings>(settingsValue, { enabled: false });
|
||||
*/
|
||||
export const parseJsonObject = <T>(value?: unknown, fallback?: T): T => {
|
||||
if (!value) return (fallback || ({} as T)) as T;
|
||||
|
||||
try {
|
||||
if (typeof value === 'string') {
|
||||
const parsed = JSON.parse(value);
|
||||
return (parsed || fallback || {}) as T;
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
return value as T;
|
||||
}
|
||||
|
||||
return (fallback || ({} as T)) as T;
|
||||
} catch {
|
||||
return (fallback || ({} as T)) as T;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses a JSON field that might be a string, object, or null.
|
||||
* More lenient than parseJsonObject - returns the original value on parse error.
|
||||
*
|
||||
* @param value - The value to parse
|
||||
* @returns The parsed value, original value, or null
|
||||
*
|
||||
* @example
|
||||
* const content = parseJsonField(element.content_json);
|
||||
* const style = parseJsonField(element.style_json);
|
||||
*/
|
||||
export const parseJsonField = (value: unknown): unknown => {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (typeof value !== 'string') return value;
|
||||
if (!value.trim()) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts preview text from parsed content by checking common text field names.
|
||||
*
|
||||
* @param content - Parsed content object
|
||||
* @returns The first text value found, or null
|
||||
*
|
||||
* @example
|
||||
* const previewText = getElementPreviewText(parseJsonField(element.content_json));
|
||||
*/
|
||||
export const getElementPreviewText = (content: unknown): string | null => {
|
||||
if (typeof content === 'string') return content;
|
||||
if (!content || typeof content !== 'object') return null;
|
||||
|
||||
const candidateKeys = [
|
||||
'title',
|
||||
'text',
|
||||
'subtitle',
|
||||
'description',
|
||||
'body',
|
||||
'label',
|
||||
'value',
|
||||
];
|
||||
const contentRecord = content as Record<string, unknown>;
|
||||
const firstTextKey = candidateKeys.find(
|
||||
(key) => typeof contentRecord[key] === 'string',
|
||||
);
|
||||
|
||||
return firstTextKey ? String(contentRecord[firstTextKey]) : null;
|
||||
};
|
||||
@ -274,7 +274,7 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
|
||||
<link rel='icon' href='/favicon.svg' />
|
||||
<link rel='manifest' href='/manifest.json' />
|
||||
<meta name='theme-color' content='#3B82F6' />
|
||||
<meta name='apple-mobile-web-app-capable' content='yes' />
|
||||
<meta name='mobile-web-app-capable' content='yes' />
|
||||
<meta
|
||||
name='apple-mobile-web-app-status-bar-style'
|
||||
content='default'
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -201,7 +201,7 @@ export default function Login() {
|
||||
</BaseButtons>
|
||||
<br />
|
||||
<p className={'text-center'}>
|
||||
Don't have an account yet?{' '}
|
||||
Don't have an account yet?{' '}
|
||||
<Link className={`${textColor}`} href={'/register'}>
|
||||
New Account
|
||||
</Link>
|
||||
|
||||
30
frontend/src/pages/p/[projectSlug]/index.tsx
Normal file
30
frontend/src/pages/p/[projectSlug]/index.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Production Presentation Page
|
||||
* URL: /p/[projectSlug] (e.g., /p/cardiff)
|
||||
*/
|
||||
|
||||
import { ReactElement } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import RuntimePresentation from '../../../components/RuntimePresentation';
|
||||
import LayoutGuest from '../../../layouts/Guest';
|
||||
|
||||
export default function ProductionPresentation() {
|
||||
const router = useRouter();
|
||||
const { projectSlug } = router.query;
|
||||
|
||||
if (!projectSlug || typeof projectSlug !== 'string') {
|
||||
return (
|
||||
<div className='flex items-center justify-center min-h-screen bg-gray-900'>
|
||||
<div className='text-white'>Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<RuntimePresentation projectSlug={projectSlug} environment='production' />
|
||||
);
|
||||
}
|
||||
|
||||
ProductionPresentation.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
28
frontend/src/pages/p/[projectSlug]/stage.tsx
Normal file
28
frontend/src/pages/p/[projectSlug]/stage.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Stage Presentation Page
|
||||
* URL: /p/[projectSlug]/stage (e.g., /p/cardiff/stage)
|
||||
*/
|
||||
|
||||
import { ReactElement } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import RuntimePresentation from '../../../components/RuntimePresentation';
|
||||
import LayoutGuest from '../../../layouts/Guest';
|
||||
|
||||
export default function StagePresentation() {
|
||||
const router = useRouter();
|
||||
const { projectSlug } = router.query;
|
||||
|
||||
if (!projectSlug || typeof projectSlug !== 'string') {
|
||||
return (
|
||||
<div className='flex items-center justify-center min-h-screen bg-gray-900'>
|
||||
<div className='text-white'>Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <RuntimePresentation projectSlug={projectSlug} environment='stage' />;
|
||||
}
|
||||
|
||||
StagePresentation.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
@ -16,6 +16,7 @@ import SectionMain from '../../components/SectionMain';
|
||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
|
||||
import { getPageTitle } from '../../config';
|
||||
import { logger } from '../../lib/logger';
|
||||
import { parseJsonObject } from '../../lib/parseJson';
|
||||
|
||||
type TourPage = {
|
||||
id: string;
|
||||
@ -45,29 +46,6 @@ type ProjectElementItem = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
const parseJsonObject = (value?: unknown): Record<string, any> => {
|
||||
if (!value) return {};
|
||||
|
||||
try {
|
||||
if (typeof value === 'string') {
|
||||
const parsed = JSON.parse(value);
|
||||
return typeof parsed === 'object' && parsed !== null ? parsed : {};
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
return value as Record<string, any>;
|
||||
}
|
||||
|
||||
return {};
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
'Failed to parse page schema JSON on pages elements list:',
|
||||
error instanceof Error ? error : { error },
|
||||
);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const toElementLabel = (value: string) =>
|
||||
value
|
||||
.split('_')
|
||||
|
||||
@ -25,6 +25,12 @@ import { getPageTitle } from '../../config';
|
||||
import { hasPermission } from '../../helpers/userPermissions';
|
||||
import { useAppSelector } from '../../stores/hooks';
|
||||
import { logger } from '../../lib/logger';
|
||||
import { parseJsonObject } from '../../lib/parseJson';
|
||||
import type {
|
||||
BaseCanvasElement,
|
||||
GalleryCard,
|
||||
CarouselSlide,
|
||||
} from '../../types/constructor';
|
||||
|
||||
type TourPage = {
|
||||
id: string;
|
||||
@ -34,57 +40,14 @@ type TourPage = {
|
||||
environment?: string;
|
||||
source_key?: string;
|
||||
requires_auth?: boolean;
|
||||
ui_schema_json?: Record<string, any> | string;
|
||||
ui_schema_json?: Record<string, unknown> | string;
|
||||
background_image_url?: string;
|
||||
background_video_url?: string;
|
||||
background_audio_url?: string;
|
||||
background_loop?: boolean;
|
||||
};
|
||||
|
||||
type GalleryCard = {
|
||||
id: string;
|
||||
imageUrl: string;
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
type CarouselSlide = {
|
||||
id: string;
|
||||
imageUrl: string;
|
||||
caption: string;
|
||||
};
|
||||
|
||||
type ConstructorElement = {
|
||||
id: string;
|
||||
type: string;
|
||||
label?: string;
|
||||
xPercent?: number;
|
||||
yPercent?: number;
|
||||
width?: string;
|
||||
height?: string;
|
||||
minWidth?: string;
|
||||
maxWidth?: string;
|
||||
minHeight?: string;
|
||||
maxHeight?: string;
|
||||
margin?: string;
|
||||
padding?: string;
|
||||
gap?: string;
|
||||
fontSize?: string;
|
||||
lineHeight?: string;
|
||||
fontWeight?: string;
|
||||
border?: string;
|
||||
borderRadius?: string;
|
||||
opacity?: string;
|
||||
boxShadow?: string;
|
||||
display?: string;
|
||||
position?: string;
|
||||
justifyContent?: string;
|
||||
alignItems?: string;
|
||||
textAlign?: string;
|
||||
zIndex?: string;
|
||||
appearDelaySec?: number;
|
||||
appearDurationSec?: number | null;
|
||||
iconUrl?: string;
|
||||
type ConstructorElement = BaseCanvasElement & {
|
||||
navLabel?: string;
|
||||
navType?: 'forward' | 'back';
|
||||
navDisabled?: boolean;
|
||||
@ -118,29 +81,6 @@ type ConstructorSchema = {
|
||||
elements?: ConstructorElement[];
|
||||
};
|
||||
|
||||
const parseJsonObject = <T,>(value?: unknown, fallback?: T): T => {
|
||||
if (!value) return (fallback || ({} as T)) as T;
|
||||
|
||||
try {
|
||||
if (typeof value === 'string') {
|
||||
const parsed = JSON.parse(value);
|
||||
return (parsed || fallback || {}) as T;
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
return value as T;
|
||||
}
|
||||
|
||||
return (fallback || ({} as T)) as T;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
'Failed to parse JSON on element edit page:',
|
||||
error instanceof Error ? error : { error },
|
||||
);
|
||||
return (fallback || ({} as T)) as T;
|
||||
}
|
||||
};
|
||||
|
||||
const toStringQuery = (value: string | string[] | undefined) => {
|
||||
if (Array.isArray(value)) return String(value[0] || '').trim();
|
||||
return String(value || '').trim();
|
||||
|
||||
@ -31,10 +31,6 @@ type ProjectData = {
|
||||
description?: string | null;
|
||||
};
|
||||
|
||||
type RuntimeContextData = {
|
||||
rootDomain?: string | null;
|
||||
};
|
||||
|
||||
const ProjectWorkspacePage = () => {
|
||||
const router = useRouter();
|
||||
const { projectsId } = router.query;
|
||||
@ -45,8 +41,6 @@ const ProjectWorkspacePage = () => {
|
||||
}, [projectsId]);
|
||||
|
||||
const [project, setProject] = useState<ProjectData | null>(null);
|
||||
const [runtimeContext, setRuntimeContext] =
|
||||
useState<RuntimeContextData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
const [isPublishModalActive, setIsPublishModalActive] = useState(false);
|
||||
@ -63,34 +57,6 @@ const ProjectWorkspacePage = () => {
|
||||
try {
|
||||
const response = await axios.get(`/projects/${projectId}`);
|
||||
setProject(response?.data || null);
|
||||
|
||||
try {
|
||||
const runtimeContextResponse = await axios.get('/runtime-context', {
|
||||
validateStatus: (status) =>
|
||||
(status >= 200 && status < 300) || status === 503,
|
||||
});
|
||||
|
||||
if (runtimeContextResponse.status === 503) {
|
||||
setRuntimeContext(null);
|
||||
} else {
|
||||
setRuntimeContext(runtimeContextResponse?.data || null);
|
||||
}
|
||||
} catch (runtimeContextError) {
|
||||
if (
|
||||
axios.isAxiosError(runtimeContextError) &&
|
||||
runtimeContextError.response?.status === 503
|
||||
) {
|
||||
setRuntimeContext(null);
|
||||
return;
|
||||
}
|
||||
logger.error(
|
||||
'Failed to load runtime context:',
|
||||
runtimeContextError instanceof Error
|
||||
? runtimeContextError
|
||||
: { error: runtimeContextError },
|
||||
);
|
||||
setRuntimeContext(null);
|
||||
}
|
||||
} catch (error: any) {
|
||||
setErrorMessage(
|
||||
error?.response?.data?.message ||
|
||||
@ -166,38 +132,17 @@ const ProjectWorkspacePage = () => {
|
||||
|
||||
const presentationLinks = useMemo(() => {
|
||||
const projectSlug = project?.slug?.trim();
|
||||
const inferredRootDomain =
|
||||
typeof window !== 'undefined'
|
||||
? (() => {
|
||||
const host = window.location.hostname;
|
||||
if (!host || host === 'localhost' || host === '127.0.0.1')
|
||||
return '';
|
||||
|
||||
const hostParts = host.split('.').filter(Boolean);
|
||||
if (hostParts.length <= 2) return host;
|
||||
if (hostParts[0] === 'admin') return hostParts.slice(1).join('.');
|
||||
if (hostParts[0] === 'stage' && hostParts.length > 2)
|
||||
return hostParts.slice(2).join('.');
|
||||
|
||||
return hostParts.slice(1).join('.');
|
||||
})()
|
||||
: '';
|
||||
const rootDomain = runtimeContext?.rootDomain?.trim() || inferredRootDomain;
|
||||
|
||||
if (!projectSlug || !rootDomain) {
|
||||
if (!projectSlug) {
|
||||
return { production: '', stage: '' };
|
||||
}
|
||||
|
||||
const protocol =
|
||||
typeof window !== 'undefined' && window.location.protocol === 'http:'
|
||||
? 'http'
|
||||
: 'https';
|
||||
|
||||
// Use path-based URLs that work without DNS configuration
|
||||
return {
|
||||
production: `${protocol}://${projectSlug}.${rootDomain}`,
|
||||
stage: `${protocol}://stage.${projectSlug}.${rootDomain}`,
|
||||
production: `/p/${projectSlug}`,
|
||||
stage: `/p/${projectSlug}/stage`,
|
||||
};
|
||||
}, [project?.slug, runtimeContext?.rootDomain]);
|
||||
}, [project?.slug]);
|
||||
|
||||
const openPresentation = (url: string, label: string) => {
|
||||
if (!url) {
|
||||
|
||||
@ -15,10 +15,20 @@ import CardBox from '../components/CardBox';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import { getPageTitle } from '../config';
|
||||
import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
|
||||
import { useReversePlayback } from '../hooks/useReversePlayback';
|
||||
import { useTransitionPlayback } from '../hooks/useTransitionPlayback';
|
||||
import { usePageNavigation } from '../hooks/usePageNavigation';
|
||||
import { OfflineToggle, OfflineStatusIndicator } from '../components/Offline';
|
||||
import { logger } from '../lib/logger';
|
||||
import { resolveAssetPlaybackUrl } from '../lib/assetUrl';
|
||||
import { parseJsonField, getElementPreviewText } from '../lib/parseJson';
|
||||
import type {
|
||||
RuntimeProject,
|
||||
RuntimePage,
|
||||
RuntimeElement,
|
||||
RuntimePageLink,
|
||||
RuntimeTransition,
|
||||
TransitionOverlayState,
|
||||
} from '../types/runtime';
|
||||
|
||||
type RuntimeMode = 'admin' | 'stage' | 'production' | 'unknown';
|
||||
|
||||
@ -27,106 +37,8 @@ type RuntimeContext = {
|
||||
projectSlug: string | null;
|
||||
};
|
||||
|
||||
type RuntimeProject = {
|
||||
id: string;
|
||||
name?: string;
|
||||
slug?: string;
|
||||
description?: string;
|
||||
entry_page_slug?: string;
|
||||
};
|
||||
|
||||
type RuntimePage = {
|
||||
id: string;
|
||||
slug?: string;
|
||||
name?: string;
|
||||
sort_order?: number;
|
||||
background_image_url?: string;
|
||||
background_video_url?: string;
|
||||
ui_schema_json?: string;
|
||||
};
|
||||
|
||||
type RuntimeElement = {
|
||||
id: string;
|
||||
pageId?: string;
|
||||
name?: string;
|
||||
element_type?: string;
|
||||
sort_order?: number;
|
||||
is_visible?: boolean;
|
||||
x_percent?: number;
|
||||
y_percent?: number;
|
||||
width_percent?: number;
|
||||
height_percent?: number;
|
||||
rotation_deg?: number;
|
||||
style_json?: string;
|
||||
content_json?: string;
|
||||
};
|
||||
|
||||
type RuntimePageLink = {
|
||||
id: string;
|
||||
from_pageId?: string;
|
||||
to_pageId?: string;
|
||||
transitionId?: string;
|
||||
transition?: {
|
||||
id: string;
|
||||
video_url?: string;
|
||||
};
|
||||
direction?: string;
|
||||
external_url?: string;
|
||||
is_active?: boolean;
|
||||
trigger_selector?: string;
|
||||
};
|
||||
|
||||
type RuntimeTransition = {
|
||||
id: string;
|
||||
name?: string;
|
||||
slug?: string;
|
||||
video_url?: string;
|
||||
duration_sec?: number;
|
||||
supports_reverse?: boolean;
|
||||
};
|
||||
|
||||
type TransitionOverlayState = {
|
||||
targetPageId: string;
|
||||
transitionName: string;
|
||||
videoUrl: string;
|
||||
isReverse: boolean;
|
||||
durationSec?: number;
|
||||
};
|
||||
|
||||
const getRows = (response: any) =>
|
||||
Array.isArray(response?.data?.rows) ? response.data.rows : [];
|
||||
const parseJsonField = (value: unknown) => {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (typeof value !== 'string') return value;
|
||||
if (!value.trim()) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (error) {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
const getElementPreviewText = (content: unknown) => {
|
||||
if (typeof content === 'string') return content;
|
||||
if (!content || typeof content !== 'object') return null;
|
||||
|
||||
const candidateKeys = [
|
||||
'title',
|
||||
'text',
|
||||
'subtitle',
|
||||
'description',
|
||||
'body',
|
||||
'label',
|
||||
'value',
|
||||
];
|
||||
const contentRecord = content as Record<string, unknown>;
|
||||
const firstTextKey = candidateKeys.find(
|
||||
(key) => typeof contentRecord[key] === 'string',
|
||||
);
|
||||
|
||||
return firstTextKey ? String(contentRecord[firstTextKey]) : null;
|
||||
};
|
||||
|
||||
const RuntimePageView = () => {
|
||||
const [context, setContext] = useState<RuntimeContext | null>(null);
|
||||
@ -135,8 +47,6 @@ const RuntimePageView = () => {
|
||||
const [elements, setElements] = useState<RuntimeElement[]>([]);
|
||||
const [pageLinks, setPageLinks] = useState<RuntimePageLink[]>([]);
|
||||
const [transitions, setTransitions] = useState<RuntimeTransition[]>([]);
|
||||
const [selectedPageId, setSelectedPageId] = useState<string | null>(null);
|
||||
const [pageHistory, setPageHistory] = useState<string[]>([]);
|
||||
const [overlayTransition, setOverlayTransition] =
|
||||
useState<TransitionOverlayState | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
@ -144,14 +54,30 @@ const RuntimePageView = () => {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
const overlayVideoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const reverseAnimationFrame = useRef<number | null>(null);
|
||||
|
||||
const currentProject = useMemo(() => projects[0] || null, [projects]);
|
||||
|
||||
// Page navigation with history tracking
|
||||
const {
|
||||
currentPageId,
|
||||
currentPage,
|
||||
pageHistory,
|
||||
previousPageId,
|
||||
defaultPage,
|
||||
applyPageSelection,
|
||||
isBackNavigation,
|
||||
} = usePageNavigation({
|
||||
pages,
|
||||
entryPageSlug: currentProject?.entry_page_slug,
|
||||
trackHistory: true,
|
||||
});
|
||||
|
||||
// Initialize preload orchestrator for neighbor-based asset preloading
|
||||
const preloadOrchestrator = usePreloadOrchestrator({
|
||||
pages,
|
||||
pageLinks,
|
||||
elements,
|
||||
currentPageId: selectedPageId,
|
||||
currentPageId,
|
||||
pageHistory,
|
||||
enabled: !isLoading && !error,
|
||||
});
|
||||
@ -257,56 +183,6 @@ const RuntimePageView = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const currentProject = useMemo(() => projects[0] || null, [projects]);
|
||||
const defaultPage = useMemo(() => {
|
||||
if (!pages.length) return null;
|
||||
if (currentProject?.entry_page_slug) {
|
||||
const byEntrySlug = pages.find(
|
||||
(page) => page.slug === currentProject.entry_page_slug,
|
||||
);
|
||||
if (byEntrySlug) return byEntrySlug;
|
||||
}
|
||||
return [...pages].sort(
|
||||
(a, b) => (a.sort_order || 0) - (b.sort_order || 0),
|
||||
)[0];
|
||||
}, [pages, currentProject]);
|
||||
const currentPage = useMemo(() => {
|
||||
if (!pages.length) return null;
|
||||
if (selectedPageId) {
|
||||
return pages.find((page) => page.id === selectedPageId) || null;
|
||||
}
|
||||
return defaultPage;
|
||||
}, [pages, selectedPageId, defaultPage]);
|
||||
|
||||
const applyPageSelection = useCallback(
|
||||
(targetPageId: string, isBack: boolean) => {
|
||||
setSelectedPageId(targetPageId);
|
||||
setPageHistory((prev) => {
|
||||
if (!prev.length) return [targetPageId];
|
||||
const currentId = prev[prev.length - 1];
|
||||
if (currentId === targetPageId) return prev;
|
||||
|
||||
if (
|
||||
isBack &&
|
||||
prev.length > 1 &&
|
||||
prev[prev.length - 2] === targetPageId
|
||||
) {
|
||||
return prev.slice(0, -1);
|
||||
}
|
||||
|
||||
return [...prev, targetPageId];
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultPage?.id && !selectedPageId) {
|
||||
setSelectedPageId(defaultPage.id);
|
||||
setPageHistory((prev) => (prev.length ? prev : [defaultPage.id]));
|
||||
}
|
||||
}, [defaultPage, selectedPageId]);
|
||||
|
||||
const pageElements = useMemo(() => {
|
||||
if (!currentPage) return [];
|
||||
return elements
|
||||
@ -351,15 +227,13 @@ const RuntimePageView = () => {
|
||||
linkDirection?: string;
|
||||
transition?: RuntimeTransition | null;
|
||||
}) => {
|
||||
const currentId = selectedPageId || defaultPage?.id;
|
||||
const currentId = currentPageId || defaultPage?.id;
|
||||
if (!targetPageId || !currentId || targetPageId === currentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousPageId =
|
||||
pageHistory.length > 1 ? pageHistory[pageHistory.length - 2] : null;
|
||||
const isBack =
|
||||
linkDirection === 'back' || previousPageId === targetPageId;
|
||||
linkDirection === 'back' || isBackNavigation(targetPageId);
|
||||
const transitionName =
|
||||
transition?.name || transition?.slug || 'Transition';
|
||||
const canUseReverseVideo =
|
||||
@ -382,209 +256,31 @@ const RuntimePageView = () => {
|
||||
: Number(transition.duration_sec),
|
||||
});
|
||||
},
|
||||
[applyPageSelection, defaultPage?.id, pageHistory, selectedPageId],
|
||||
[applyPageSelection, currentPageId, defaultPage?.id, isBackNavigation],
|
||||
);
|
||||
|
||||
const finishOverlayTransition = useCallback(() => {
|
||||
if (reverseAnimationFrame.current !== null) {
|
||||
cancelAnimationFrame(reverseAnimationFrame.current);
|
||||
reverseAnimationFrame.current = null;
|
||||
}
|
||||
|
||||
setOverlayTransition((active) => {
|
||||
if (!active) return null;
|
||||
applyPageSelection(active.targetPageId, active.isReverse);
|
||||
return null;
|
||||
});
|
||||
}, [applyPageSelection]);
|
||||
|
||||
useEffect(() => {
|
||||
const video = overlayVideoRef.current;
|
||||
|
||||
if (!overlayTransition || !video) {
|
||||
return;
|
||||
}
|
||||
|
||||
const configuredDurationMs =
|
||||
(overlayTransition.durationSec && overlayTransition.durationSec > 0
|
||||
? overlayTransition.durationSec
|
||||
: 0.7) * 1000;
|
||||
let fallbackTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const cleanupReverseFrame = () => {
|
||||
if (reverseAnimationFrame.current !== null) {
|
||||
// Using clearInterval since we now use setInterval for reverse playback
|
||||
clearInterval(reverseAnimationFrame.current);
|
||||
reverseAnimationFrame.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Feature detection for native reverse playback (Chrome 141+, Safari 16+)
|
||||
const canUseNativeReverse = (() => {
|
||||
try {
|
||||
const testVideo = document.createElement('video');
|
||||
testVideo.playbackRate = -1;
|
||||
return testVideo.playbackRate === -1;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
const duration =
|
||||
Number.isFinite(video.duration) && video.duration > 0
|
||||
? video.duration
|
||||
: Math.max(configuredDurationMs / 1000, 0.7);
|
||||
|
||||
// Check if video URL is preloaded
|
||||
const videoUrl = overlayTransition.videoUrl || '';
|
||||
const isPreloaded = preloadOrchestrator.preloadedUrls.has(videoUrl);
|
||||
|
||||
// Improved reverse playback with multiple strategies
|
||||
const runReverse = () => {
|
||||
cleanupReverseFrame();
|
||||
video.pause();
|
||||
|
||||
logger.info('Starting reverse playback', {
|
||||
duration,
|
||||
canUseNativeReverse,
|
||||
isPreloaded,
|
||||
readyState: video.readyState,
|
||||
});
|
||||
|
||||
// Strategy 1: Native playbackRate = -1 (Chrome 141+, Safari 16+)
|
||||
if (canUseNativeReverse) {
|
||||
logger.info('Using native playbackRate = -1 strategy');
|
||||
video.currentTime = duration;
|
||||
video.playbackRate = -1;
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
if (video.currentTime <= 0.05) {
|
||||
video.removeEventListener('timeupdate', onTimeUpdate);
|
||||
video.playbackRate = 1;
|
||||
video.pause();
|
||||
video.currentTime = 0;
|
||||
finishOverlayTransition();
|
||||
}
|
||||
};
|
||||
|
||||
video.addEventListener('timeupdate', onTimeUpdate);
|
||||
|
||||
video.play().catch((err) => {
|
||||
logger.error('Native reverse play failed:', err instanceof Error ? err : { error: err });
|
||||
video.removeEventListener('timeupdate', onTimeUpdate);
|
||||
video.playbackRate = 1;
|
||||
// Fall back to frame-stepping
|
||||
startFrameStepping();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Strategy 2 & 3: Frame-stepping (preloaded or buffered)
|
||||
startFrameStepping();
|
||||
};
|
||||
|
||||
// Frame-stepping reverse playback
|
||||
const startFrameStepping = () => {
|
||||
logger.info('Using frame-stepping strategy', { isPreloaded });
|
||||
|
||||
video.pause();
|
||||
video.currentTime = duration;
|
||||
|
||||
// Check if video is fully buffered
|
||||
const isFullyBuffered =
|
||||
video.readyState >= 4 ||
|
||||
(video.buffered.length > 0 &&
|
||||
video.buffered.end(video.buffered.length - 1) >= duration - 0.1);
|
||||
|
||||
// Use higher fps if video is preloaded/buffered, lower if not
|
||||
const fps = isPreloaded || isFullyBuffered ? 30 : 15;
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
if (video.currentTime <= 0) {
|
||||
clearInterval(intervalId);
|
||||
reverseAnimationFrame.current = null;
|
||||
video.currentTime = 0;
|
||||
finishOverlayTransition();
|
||||
} else {
|
||||
video.currentTime -= 1 / fps;
|
||||
const { isBuffering: isTransitionBuffering } = useTransitionPlayback({
|
||||
videoRef: overlayVideoRef,
|
||||
transition: overlayTransition
|
||||
? {
|
||||
videoUrl: overlayTransition.videoUrl,
|
||||
reverseMode: overlayTransition.isReverse ? 'reverse' : 'none',
|
||||
durationSec: overlayTransition.durationSec,
|
||||
targetPageId: overlayTransition.targetPageId,
|
||||
displayName: overlayTransition.transitionName,
|
||||
}
|
||||
}, 1000 / fps);
|
||||
|
||||
// Store interval ID for cleanup
|
||||
reverseAnimationFrame.current = intervalId as unknown as number;
|
||||
};
|
||||
|
||||
let reverseStarted = false;
|
||||
|
||||
const onLoadedMetadata = () => {
|
||||
// For forward playback, we can start immediately
|
||||
if (!overlayTransition.isReverse) {
|
||||
video.currentTime = 0;
|
||||
video.play().catch((playError) => {
|
||||
logger.error(
|
||||
'Transition video playback failed:',
|
||||
playError instanceof Error ? playError : { error: playError },
|
||||
);
|
||||
fallbackTimer = setTimeout(
|
||||
finishOverlayTransition,
|
||||
configuredDurationMs,
|
||||
);
|
||||
});
|
||||
} else {
|
||||
// For reverse, start with a small delay to allow some buffering
|
||||
setTimeout(() => {
|
||||
if (!reverseStarted) {
|
||||
reverseStarted = true;
|
||||
runReverse();
|
||||
}
|
||||
}, 300);
|
||||
: null,
|
||||
onComplete: (targetPageId) => {
|
||||
if (targetPageId && overlayTransition) {
|
||||
applyPageSelection(targetPageId, overlayTransition.isReverse);
|
||||
}
|
||||
};
|
||||
|
||||
const onCanPlayThrough = () => {
|
||||
// For reverse playback, start immediately when enough data is buffered
|
||||
if (overlayTransition.isReverse && !reverseStarted) {
|
||||
reverseStarted = true;
|
||||
runReverse();
|
||||
}
|
||||
};
|
||||
|
||||
const onEnded = () => {
|
||||
finishOverlayTransition();
|
||||
};
|
||||
|
||||
video.addEventListener('loadedmetadata', onLoadedMetadata);
|
||||
video.addEventListener('canplaythrough', onCanPlayThrough);
|
||||
video.addEventListener('ended', onEnded);
|
||||
|
||||
// Fallback timeout in case video events don't fire
|
||||
// For reverse playback, add extra buffer since seeking is slower
|
||||
const fallbackMs = overlayTransition.isReverse
|
||||
? Math.max(
|
||||
(overlayTransition.durationSec && overlayTransition.durationSec > 0
|
||||
? overlayTransition.durationSec * 1000
|
||||
: configuredDurationMs) + 1500, // Extra buffer for seek delays
|
||||
2000,
|
||||
)
|
||||
: configuredDurationMs + 300;
|
||||
fallbackTimer = setTimeout(finishOverlayTransition, fallbackMs);
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||
video.removeEventListener('canplaythrough', onCanPlayThrough);
|
||||
video.removeEventListener('ended', onEnded);
|
||||
cleanupReverseFrame();
|
||||
if (fallbackTimer) clearTimeout(fallbackTimer);
|
||||
};
|
||||
}, [finishOverlayTransition, overlayTransition]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (reverseAnimationFrame.current !== null) {
|
||||
clearInterval(reverseAnimationFrame.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
setOverlayTransition(null);
|
||||
},
|
||||
preload: {
|
||||
preloadedUrls: preloadOrchestrator.preloadedUrls,
|
||||
getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -860,7 +556,8 @@ const RuntimePageView = () => {
|
||||
<video
|
||||
ref={overlayVideoRef}
|
||||
src={overlayTransition.videoUrl}
|
||||
className='w-full h-full object-cover'
|
||||
className='w-full h-full object-cover transition-opacity duration-300'
|
||||
style={{ opacity: isTransitionBuffering ? 0 : 1 }}
|
||||
muted
|
||||
playsInline
|
||||
autoPlay={!overlayTransition.isReverse}
|
||||
|
||||
@ -9,13 +9,12 @@ import SectionMain from '../components/SectionMain';
|
||||
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
|
||||
import { getPageTitle } from '../config';
|
||||
import { logger } from '../lib/logger';
|
||||
import type { UiElementDefault } from '../types/constructor';
|
||||
|
||||
type UiElementType = {
|
||||
id: string;
|
||||
type UiElementType = UiElementDefault & {
|
||||
element_type: string;
|
||||
name?: string;
|
||||
sort_order?: number;
|
||||
is_active?: boolean;
|
||||
};
|
||||
|
||||
const toHumanLabel = (value: string) =>
|
||||
|
||||
@ -23,61 +23,22 @@ import SectionMain from '../../components/SectionMain';
|
||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
|
||||
import { getPageTitle } from '../../config';
|
||||
import { logger } from '../../lib/logger';
|
||||
import { parseJsonObject } from '../../lib/parseJson';
|
||||
import type {
|
||||
CanvasElementType,
|
||||
GalleryCard,
|
||||
CarouselSlide,
|
||||
UiElementDefault,
|
||||
} from '../../types/constructor';
|
||||
import type { ElementStyleProperties } from '../../lib/elementStyles';
|
||||
|
||||
type UiElementType = {
|
||||
id: string;
|
||||
type UiElementType = UiElementDefault & {
|
||||
element_type: string;
|
||||
name?: string;
|
||||
sort_order?: number;
|
||||
is_active?: boolean;
|
||||
default_settings_json?: Record<string, any> | string | null;
|
||||
};
|
||||
|
||||
type ElementType =
|
||||
| 'navigation_next'
|
||||
| 'navigation_prev'
|
||||
| 'gallery'
|
||||
| 'carousel'
|
||||
| 'tooltip'
|
||||
| 'description'
|
||||
| 'video_player'
|
||||
| 'audio_player';
|
||||
|
||||
type GalleryCard = {
|
||||
id: string;
|
||||
imageUrl: string;
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
type CarouselSlide = {
|
||||
id: string;
|
||||
imageUrl: string;
|
||||
caption: string;
|
||||
};
|
||||
|
||||
const parseJsonObject = <T,>(value?: unknown, fallback?: T): T => {
|
||||
if (!value) return (fallback || ({} as T)) as T;
|
||||
|
||||
try {
|
||||
if (typeof value === 'string') {
|
||||
const parsed = JSON.parse(value);
|
||||
return (parsed || fallback || {}) as T;
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
return value as T;
|
||||
}
|
||||
|
||||
return (fallback || ({} as T)) as T;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
'Failed to parse UI element defaults JSON:',
|
||||
error instanceof Error ? error : { error },
|
||||
);
|
||||
return (fallback || ({} as T)) as T;
|
||||
}
|
||||
};
|
||||
type ElementType = CanvasElementType;
|
||||
|
||||
const clampPercent = (value: string) => {
|
||||
const parsed = Number(value);
|
||||
|
||||
147
frontend/src/types/constructor.ts
Normal file
147
frontend/src/types/constructor.ts
Normal file
@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Constructor Types
|
||||
*
|
||||
* Shared types for the constructor page and related UI elements.
|
||||
* Used in constructor.tsx, page_elements pages, and ui-elements pages.
|
||||
*/
|
||||
|
||||
import type { ElementStyleProperties } from '../lib/elementStyles';
|
||||
|
||||
/**
|
||||
* Element types available in the constructor canvas
|
||||
*/
|
||||
export type CanvasElementType =
|
||||
| 'navigation_next'
|
||||
| 'navigation_prev'
|
||||
| 'gallery'
|
||||
| 'carousel'
|
||||
| 'tooltip'
|
||||
| 'description'
|
||||
| 'video_player'
|
||||
| 'audio_player';
|
||||
|
||||
/**
|
||||
* Navigation button direction
|
||||
*/
|
||||
export type NavigationButtonKind = 'forward' | 'back';
|
||||
|
||||
/**
|
||||
* Gallery card item
|
||||
*/
|
||||
export interface GalleryCard {
|
||||
id: string;
|
||||
imageUrl: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Carousel slide item
|
||||
*/
|
||||
export interface CarouselSlide {
|
||||
id: string;
|
||||
imageUrl: string;
|
||||
caption: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base canvas element with common positioning and styling fields.
|
||||
* Extends ElementStyleProperties for CSS styling.
|
||||
*/
|
||||
export interface BaseCanvasElement extends ElementStyleProperties {
|
||||
id: string;
|
||||
type: CanvasElementType | string;
|
||||
label?: string;
|
||||
xPercent?: number;
|
||||
yPercent?: number;
|
||||
iconUrl?: string;
|
||||
appearDelaySec?: number;
|
||||
appearDurationSec?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full canvas element with all content fields
|
||||
*/
|
||||
export interface CanvasElement extends BaseCanvasElement {
|
||||
type: CanvasElementType;
|
||||
label: string;
|
||||
xPercent: number;
|
||||
yPercent: number;
|
||||
iconUrl?: string;
|
||||
mediaUrl?: string;
|
||||
mediaAutoplay?: boolean;
|
||||
mediaLoop?: boolean;
|
||||
mediaMuted?: boolean;
|
||||
backgroundImageUrl?: string;
|
||||
videoUrl?: string;
|
||||
audioUrl?: string;
|
||||
galleryCards?: GalleryCard[];
|
||||
carouselSlides?: CarouselSlide[];
|
||||
carouselPrevIconUrl?: string;
|
||||
carouselNextIconUrl?: string;
|
||||
tooltipTitle?: string;
|
||||
tooltipText?: string;
|
||||
descriptionTitle?: string;
|
||||
descriptionText?: string;
|
||||
descriptionTitleFontSize?: string;
|
||||
descriptionTextFontSize?: string;
|
||||
descriptionTitleFontFamily?: string;
|
||||
descriptionTextFontFamily?: string;
|
||||
descriptionTitleColor?: string;
|
||||
descriptionTextColor?: string;
|
||||
descriptionBackgroundColor?: string;
|
||||
navLabel?: string;
|
||||
navType?: NavigationButtonKind;
|
||||
navDisabled?: boolean;
|
||||
targetPageId?: string;
|
||||
transitionVideoUrl?: string;
|
||||
transitionReverseMode?: 'auto_reverse' | 'separate_video';
|
||||
reverseVideoUrl?: string;
|
||||
transitionDurationSec?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor schema containing elements array
|
||||
*/
|
||||
export interface ConstructorSchema {
|
||||
elements?: CanvasElement[];
|
||||
}
|
||||
|
||||
/**
|
||||
* UI element default settings from database
|
||||
*/
|
||||
export interface UiElementDefault {
|
||||
id: string;
|
||||
element_type?: string;
|
||||
is_active?: boolean;
|
||||
default_settings_json?: string | Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Project asset for constructor asset selection
|
||||
*/
|
||||
export interface ConstructorAsset {
|
||||
id: string;
|
||||
name?: string;
|
||||
asset_type?: 'image' | 'video' | 'audio' | 'file';
|
||||
type?:
|
||||
| 'icon'
|
||||
| 'background_image'
|
||||
| 'audio'
|
||||
| 'video'
|
||||
| 'transition'
|
||||
| 'logo'
|
||||
| 'favicon'
|
||||
| 'document'
|
||||
| 'general';
|
||||
cdn_url?: string | null;
|
||||
storage_key?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset option for select dropdowns
|
||||
*/
|
||||
export interface AssetOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
@ -8,3 +8,7 @@ export * from './redux';
|
||||
export * from './forms';
|
||||
export * from './filters';
|
||||
export * from './permissions';
|
||||
export * from './offline';
|
||||
export * from './preload';
|
||||
export * from './runtime';
|
||||
export * from './constructor';
|
||||
|
||||
56
frontend/src/types/preload.ts
Normal file
56
frontend/src/types/preload.ts
Normal file
@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Preload Types
|
||||
*
|
||||
* Shared types for asset preloading infrastructure.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Simplified page type for preloading (runtime subset of TourPage)
|
||||
*/
|
||||
export interface PreloadPage {
|
||||
id: string;
|
||||
background_image_url?: string;
|
||||
background_video_url?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplified page link type for preloading (runtime subset of PageLink)
|
||||
*/
|
||||
export interface PreloadPageLink {
|
||||
id: string;
|
||||
from_pageId?: string;
|
||||
to_pageId?: string;
|
||||
is_active?: boolean;
|
||||
transition?: {
|
||||
id: string;
|
||||
video_url?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplified element type for preloading
|
||||
*/
|
||||
export interface PreloadElement {
|
||||
id: string;
|
||||
pageId?: string;
|
||||
element_type?: string;
|
||||
content_json?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset info returned by neighbor graph
|
||||
*/
|
||||
export interface PreloadAssetInfo {
|
||||
url: string;
|
||||
pageId: string;
|
||||
assetType: 'image' | 'video' | 'audio' | 'transition' | 'other';
|
||||
priority: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Neighbor info for graph traversal
|
||||
*/
|
||||
export interface PreloadNeighborInfo {
|
||||
pageId: string;
|
||||
distance: number;
|
||||
}
|
||||
77
frontend/src/types/runtime.ts
Normal file
77
frontend/src/types/runtime.ts
Normal file
@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Runtime Types
|
||||
*
|
||||
* Shared types for runtime presentation (runtime.tsx, RuntimePresentation.tsx).
|
||||
* These extend the minimal preload types with additional runtime-specific fields.
|
||||
*/
|
||||
|
||||
import type { PreloadPage, PreloadPageLink, PreloadElement } from './preload';
|
||||
|
||||
/**
|
||||
* Runtime project data
|
||||
*/
|
||||
export interface RuntimeProject {
|
||||
id: string;
|
||||
name?: string;
|
||||
slug?: string;
|
||||
description?: string;
|
||||
entry_page_slug?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime page - extends PreloadPage with display/navigation fields
|
||||
*/
|
||||
export interface RuntimePage extends PreloadPage {
|
||||
slug?: string;
|
||||
name?: string;
|
||||
sort_order?: number;
|
||||
ui_schema_json?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime page link - extends PreloadPageLink with navigation fields
|
||||
*/
|
||||
export interface RuntimePageLink extends PreloadPageLink {
|
||||
transitionId?: string;
|
||||
direction?: string;
|
||||
external_url?: string;
|
||||
trigger_selector?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime element - extends PreloadElement with positioning/visibility
|
||||
*/
|
||||
export interface RuntimeElement extends PreloadElement {
|
||||
name?: string;
|
||||
sort_order?: number;
|
||||
is_visible?: boolean;
|
||||
x_percent?: number;
|
||||
y_percent?: number;
|
||||
width_percent?: number;
|
||||
height_percent?: number;
|
||||
rotation_deg?: number;
|
||||
style_json?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime transition data
|
||||
*/
|
||||
export interface RuntimeTransition {
|
||||
id: string;
|
||||
name?: string;
|
||||
slug?: string;
|
||||
video_url?: string;
|
||||
duration_sec?: number;
|
||||
supports_reverse?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* State for transition overlay during page navigation
|
||||
*/
|
||||
export interface TransitionOverlayState {
|
||||
targetPageId: string;
|
||||
transitionName: string;
|
||||
videoUrl: string;
|
||||
isReverse: boolean;
|
||||
durationSec?: number;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user