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_HOST=127.0.0.1
|
||||||
DB_PORT=5432
|
DB_PORT=5432
|
||||||
PORT=3000
|
PORT=3000
|
||||||
PLATFORM_ROOT_DOMAIN=tour-builder-platform-2eb6.dev.flatlogic.app
|
|
||||||
GOOGLE_CLIENT_ID=671001533244-kf1k1gmp6mnl0r030qmvdu6v36ghmim6.apps.googleusercontent.com
|
GOOGLE_CLIENT_ID=671001533244-kf1k1gmp6mnl0r030qmvdu6v36ghmim6.apps.googleusercontent.com
|
||||||
GOOGLE_CLIENT_SECRET=Yo4qbKZniqvojzUQ60iKlxqR
|
GOOGLE_CLIENT_SECRET=Yo4qbKZniqvojzUQ60iKlxqR
|
||||||
MS_CLIENT_ID=4696f457-31af-40de-897c-e00d7d4cff73
|
MS_CLIENT_ID=4696f457-31af-40de-897c-e00d7d4cff73
|
||||||
|
|||||||
@ -44,10 +44,16 @@ function extractUrlsFromContent(contentJson) {
|
|||||||
const urls = [];
|
const urls = [];
|
||||||
|
|
||||||
const urlFields = [
|
const urlFields = [
|
||||||
'image_url',
|
'iconUrl',
|
||||||
'video_url',
|
'imageUrl',
|
||||||
'audio_url',
|
'mediaUrl',
|
||||||
'background_url',
|
'videoUrl',
|
||||||
|
'audioUrl',
|
||||||
|
'transitionVideoUrl',
|
||||||
|
'backgroundImageUrl',
|
||||||
|
'reverseVideoUrl',
|
||||||
|
'carouselPrevIconUrl',
|
||||||
|
'carouselNextIconUrl',
|
||||||
'src',
|
'src',
|
||||||
'url',
|
'url',
|
||||||
'poster',
|
'poster',
|
||||||
@ -58,11 +64,13 @@ function extractUrlsFromContent(contentJson) {
|
|||||||
if (depth > 5 || !obj || typeof obj !== 'object') return;
|
if (depth > 5 || !obj || typeof obj !== 'object') return;
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(obj)) {
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
if (typeof value === 'string' && urlFields.includes(key) && value.startsWith('http')) {
|
if (typeof value === 'string' && value && urlFields.includes(key)) {
|
||||||
urls.push({
|
if (value.startsWith('http') || value.startsWith('/')) {
|
||||||
url: value,
|
urls.push({
|
||||||
fieldType: key,
|
url: value,
|
||||||
});
|
fieldType: key,
|
||||||
|
});
|
||||||
|
}
|
||||||
} else if (typeof value === 'object' && value !== null) {
|
} else if (typeof value === 'object' && value !== null) {
|
||||||
checkObject(value, depth + 1);
|
checkObject(value, depth + 1);
|
||||||
}
|
}
|
||||||
@ -105,6 +113,12 @@ class PWAManifestService {
|
|||||||
const manifestAssets = [];
|
const manifestAssets = [];
|
||||||
const seenUrls = new Set();
|
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
|
// Helper to add an asset to the manifest
|
||||||
const addAsset = (id, url, filename, variantType, assetType, mimeType, sizeBytes, pageIds) => {
|
const addAsset = (id, url, filename, variantType, assetType, mimeType, sizeBytes, pageIds) => {
|
||||||
if (!url || seenUrls.has(url)) return;
|
if (!url || seenUrls.has(url)) return;
|
||||||
@ -139,21 +153,21 @@ class PWAManifestService {
|
|||||||
variant.variant_type,
|
variant.variant_type,
|
||||||
asset.type || getAssetType(asset.mime_type, asset.filename),
|
asset.type || getAssetType(asset.mime_type, asset.filename),
|
||||||
variant.mime_type || asset.mime_type,
|
variant.mime_type || asset.mime_type,
|
||||||
variant.size_bytes || 0,
|
variant.size_bytes || mbToBytes(variant.size_mb),
|
||||||
asset.pages?.map((p) => p.id) || []
|
asset.pages?.map((p) => p.id) || []
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no variants, add original
|
// If no variants, add original (use cdn_url as primary, fall back to storage_key)
|
||||||
if (selectedVariants.length === 0 && asset.url) {
|
if (selectedVariants.length === 0 && (asset.cdn_url || asset.storage_key)) {
|
||||||
addAsset(
|
addAsset(
|
||||||
asset.id,
|
asset.id,
|
||||||
asset.url,
|
asset.cdn_url || asset.storage_key,
|
||||||
asset.filename,
|
asset.name || asset.filename,
|
||||||
'original',
|
'original',
|
||||||
asset.type || getAssetType(asset.mime_type, asset.filename),
|
asset.type || getAssetType(asset.mime_type, asset.name),
|
||||||
asset.mime_type,
|
asset.mime_type,
|
||||||
asset.size_bytes || 0,
|
mbToBytes(asset.size_mb),
|
||||||
asset.pages?.map((p) => p.id) || []
|
asset.pages?.map((p) => p.id) || []
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
3
frontend/.gitignore
vendored
3
frontend/.gitignore
vendored
@ -31,3 +31,6 @@ yarn-error.log*
|
|||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
/.idea/
|
/.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 { AssetSection } from './AssetSectionCard';
|
||||||
import type { UploadQueueItem } from './UploadProgressList';
|
import type { UploadQueueItem } from './UploadProgressList';
|
||||||
import { logger } from '../../lib/logger';
|
import { logger } from '../../lib/logger';
|
||||||
|
import {
|
||||||
|
probeMediaDuration,
|
||||||
|
isVideoMimeType,
|
||||||
|
isAudioMimeType,
|
||||||
|
} from '../../lib/mediaDuration';
|
||||||
|
|
||||||
interface UseAssetUploaderOptions {
|
interface UseAssetUploaderOptions {
|
||||||
selectedProjectId: string;
|
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', {
|
await axios.post('/assets', {
|
||||||
data: {
|
data: {
|
||||||
project: projectId,
|
project: projectId,
|
||||||
@ -117,6 +141,9 @@ export function useAssetUploader({
|
|||||||
mime_type: file.type || null,
|
mime_type: file.type || null,
|
||||||
size_mb: Number((file.size / (1024 * 1024)).toFixed(4)),
|
size_mb: Number((file.size / (1024 * 1024)).toFixed(4)),
|
||||||
is_public: false,
|
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
|
maxDepth: 2, // How far to look ahead
|
||||||
constructorMaxDepth: 1, // Reduced depth for constructor preview
|
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;
|
} as const;
|
||||||
|
|
||||||
export type PreloadConfig = typeof PRELOAD_CONFIG;
|
export type PreloadConfig = typeof PRELOAD_CONFIG;
|
||||||
|
|||||||
@ -6,3 +6,17 @@ export { useFilterItems } from './useFilterItems';
|
|||||||
export { useCSVHandling } from './useCSVHandling';
|
export { useCSVHandling } from './useCSVHandling';
|
||||||
export { useFormSync } from './useFormSync';
|
export { useFormSync } from './useFormSync';
|
||||||
export { useEntityTable } from './useEntityTable';
|
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 { useMemo } from 'react';
|
||||||
import { PRELOAD_CONFIG } from '../config/preload.config';
|
import { PRELOAD_CONFIG } from '../config/preload.config';
|
||||||
|
import type {
|
||||||
interface PageLink {
|
PreloadPage,
|
||||||
id: string;
|
PreloadPageLink,
|
||||||
from_pageId?: string;
|
PreloadElement,
|
||||||
to_pageId?: string;
|
PreloadAssetInfo,
|
||||||
is_active?: boolean;
|
PreloadNeighborInfo,
|
||||||
transition?: {
|
} from '../types/preload';
|
||||||
id: string;
|
|
||||||
video_url?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Page {
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Element {
|
|
||||||
id: string;
|
|
||||||
pageId?: string;
|
|
||||||
element_type?: string;
|
|
||||||
content_json?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
interface UseNeighborGraphOptions {
|
interface UseNeighborGraphOptions {
|
||||||
pages: Page[];
|
pages: PreloadPage[];
|
||||||
pageLinks: PageLink[];
|
pageLinks: PreloadPageLink[];
|
||||||
elements: Element[];
|
elements: PreloadElement[];
|
||||||
maxDepth?: number;
|
maxDepth?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NeighborInfo {
|
|
||||||
pageId: string;
|
|
||||||
distance: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AssetInfo {
|
|
||||||
url: string;
|
|
||||||
pageId: string;
|
|
||||||
assetType: 'image' | 'video' | 'audio' | 'transition' | 'other';
|
|
||||||
priority: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NeighborGraphResult {
|
interface NeighborGraphResult {
|
||||||
/**
|
/**
|
||||||
* Get neighboring page IDs within maxDepth hops
|
* 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
|
* 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
|
* Get prioritized assets for preloading based on current page
|
||||||
@ -67,7 +42,7 @@ interface NeighborGraphResult {
|
|||||||
getPrioritizedAssets: (
|
getPrioritizedAssets: (
|
||||||
currentPageId: string,
|
currentPageId: string,
|
||||||
maxDepth?: number,
|
maxDepth?: number,
|
||||||
) => AssetInfo[];
|
) => PreloadAssetInfo[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Raw adjacency list for debugging
|
* Raw adjacency list for debugging
|
||||||
@ -81,31 +56,17 @@ interface NeighborGraphResult {
|
|||||||
function extractAssetsFromContent(
|
function extractAssetsFromContent(
|
||||||
contentJson: string | undefined,
|
contentJson: string | undefined,
|
||||||
pageId: string,
|
pageId: string,
|
||||||
): AssetInfo[] {
|
): PreloadAssetInfo[] {
|
||||||
if (!contentJson) return [];
|
if (!contentJson) return [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content =
|
const content =
|
||||||
typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
|
typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
|
||||||
|
|
||||||
const assets: AssetInfo[] = [];
|
const assets: PreloadAssetInfo[] = [];
|
||||||
|
|
||||||
// Check for common asset URL fields (both snake_case and camelCase)
|
// Asset URL fields in element content_json
|
||||||
const urlFields = [
|
const urlFields = PRELOAD_CONFIG.assetFields.all as readonly string[];
|
||||||
'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
|
|
||||||
];
|
|
||||||
|
|
||||||
const checkObject = (obj: Record<string, unknown>, depth = 0) => {
|
const checkObject = (obj: Record<string, unknown>, depth = 0) => {
|
||||||
if (depth > 5 || !obj || typeof obj !== 'object') return;
|
if (depth > 5 || !obj || typeof obj !== 'object') return;
|
||||||
@ -174,9 +135,9 @@ export function useNeighborGraph(
|
|||||||
|
|
||||||
// BFS to find neighbors within depth
|
// BFS to find neighbors within depth
|
||||||
const getNeighbors = useMemo(() => {
|
const getNeighbors = useMemo(() => {
|
||||||
return (currentPageId: string, depth = maxDepth): NeighborInfo[] => {
|
return (currentPageId: string, depth = maxDepth): PreloadNeighborInfo[] => {
|
||||||
const visited = new Set<string>();
|
const visited = new Set<string>();
|
||||||
const result: NeighborInfo[] = [];
|
const result: PreloadNeighborInfo[] = [];
|
||||||
const queue: { pageId: string; distance: number }[] = [
|
const queue: { pageId: string; distance: number }[] = [
|
||||||
{ pageId: currentPageId, distance: 0 },
|
{ pageId: currentPageId, distance: 0 },
|
||||||
];
|
];
|
||||||
@ -210,8 +171,8 @@ export function useNeighborGraph(
|
|||||||
|
|
||||||
// Get assets for a set of pages
|
// Get assets for a set of pages
|
||||||
const getAssetsForPages = useMemo(() => {
|
const getAssetsForPages = useMemo(() => {
|
||||||
return (pageIds: string[]): AssetInfo[] => {
|
return (pageIds: string[]): PreloadAssetInfo[] => {
|
||||||
const assets: AssetInfo[] = [];
|
const assets: PreloadAssetInfo[] = [];
|
||||||
const seenUrls = new Set<string>();
|
const seenUrls = new Set<string>();
|
||||||
|
|
||||||
pageIds.forEach((pageId) => {
|
pageIds.forEach((pageId) => {
|
||||||
@ -236,8 +197,7 @@ export function useNeighborGraph(
|
|||||||
// Add transition videos (transition is eagerly loaded in page_links)
|
// Add transition videos (transition is eagerly loaded in page_links)
|
||||||
const matchingLinks = pageLinks.filter(
|
const matchingLinks = pageLinks.filter(
|
||||||
(link) =>
|
(link) =>
|
||||||
link.is_active !== false &&
|
link.is_active !== false && pageIds.includes(link.from_pageId || ''),
|
||||||
pageIds.includes(link.from_pageId || ''),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
matchingLinks.forEach((link) => {
|
matchingLinks.forEach((link) => {
|
||||||
@ -259,7 +219,7 @@ export function useNeighborGraph(
|
|||||||
|
|
||||||
// Get prioritized assets for preloading
|
// Get prioritized assets for preloading
|
||||||
const getPrioritizedAssets = useMemo(() => {
|
const getPrioritizedAssets = useMemo(() => {
|
||||||
return (currentPageId: string, depth = maxDepth): AssetInfo[] => {
|
return (currentPageId: string, depth = maxDepth): PreloadAssetInfo[] => {
|
||||||
// Get current page assets (highest priority)
|
// Get current page assets (highest priority)
|
||||||
const currentPageAssets = getAssetsForPages([currentPageId]).map(
|
const currentPageAssets = getAssetsForPages([currentPageId]).map(
|
||||||
(asset) => ({
|
(asset) => ({
|
||||||
@ -272,7 +232,7 @@ export function useNeighborGraph(
|
|||||||
|
|
||||||
// Get neighbor page assets
|
// Get neighbor page assets
|
||||||
const neighbors = getNeighbors(currentPageId, depth);
|
const neighbors = getNeighbors(currentPageId, depth);
|
||||||
const neighborAssets: AssetInfo[] = [];
|
const neighborAssets: PreloadAssetInfo[] = [];
|
||||||
|
|
||||||
neighbors.forEach(({ pageId, distance }) => {
|
neighbors.forEach(({ pageId, distance }) => {
|
||||||
const assets = getAssetsForPages([pageId]);
|
const assets = getAssetsForPages([pageId]);
|
||||||
@ -292,7 +252,7 @@ export function useNeighborGraph(
|
|||||||
const allAssets = [...currentPageAssets, ...neighborAssets];
|
const allAssets = [...currentPageAssets, ...neighborAssets];
|
||||||
|
|
||||||
// Deduplicate by URL, keeping highest priority
|
// Deduplicate by URL, keeping highest priority
|
||||||
const urlToPriority = new Map<string, AssetInfo>();
|
const urlToPriority = new Map<string, PreloadAssetInfo>();
|
||||||
allAssets.forEach((asset) => {
|
allAssets.forEach((asset) => {
|
||||||
const existing = urlToPriority.get(asset.url);
|
const existing = urlToPriority.get(asset.url);
|
||||||
if (!existing || asset.priority > existing.priority) {
|
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 { useNeighborGraph } from './useNeighborGraph';
|
||||||
import { useNetworkAware } from './useNetworkAware';
|
import { useNetworkAware } from './useNetworkAware';
|
||||||
import { downloadEventBus } from '../lib/offline/DownloadEventBus';
|
import { downloadEventBus } from '../lib/offline/DownloadEventBus';
|
||||||
|
import { StorageManager } from '../lib/offline/StorageManager';
|
||||||
import { PRELOAD_CONFIG } from '../config/preload.config';
|
import { PRELOAD_CONFIG } from '../config/preload.config';
|
||||||
import { OFFLINE_CONFIG } from '../config/offline.config';
|
import { OFFLINE_CONFIG } from '../config/offline.config';
|
||||||
import { resolveAssetPlaybackUrl } from '../lib/assetUrl';
|
import { resolveAssetPlaybackUrl } from '../lib/assetUrl';
|
||||||
import { logger } from '../lib/logger';
|
import { logger } from '../lib/logger';
|
||||||
|
import type {
|
||||||
interface Page {
|
PreloadPage,
|
||||||
id: string;
|
PreloadPageLink,
|
||||||
background_image_url?: string;
|
PreloadElement,
|
||||||
background_video_url?: string;
|
} from '../types/preload';
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UsePreloadOrchestratorOptions {
|
interface UsePreloadOrchestratorOptions {
|
||||||
pages: Page[];
|
pages: PreloadPage[];
|
||||||
pageLinks: PageLink[];
|
pageLinks: PreloadPageLink[];
|
||||||
elements: Element[];
|
elements: PreloadElement[];
|
||||||
currentPageId: string | null;
|
currentPageId: string | null;
|
||||||
pageHistory?: string[];
|
pageHistory?: string[];
|
||||||
enabled?: boolean;
|
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> => {
|
const isUrlCached = async (url: string): Promise<boolean> => {
|
||||||
if (typeof caches === 'undefined') return false;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cacheNames = await caches.keys();
|
// StorageManager.hasAsset checks both IndexedDB (large files) and Cache API (small files)
|
||||||
for (const cacheName of cacheNames) {
|
return StorageManager.hasAsset(url);
|
||||||
const cache = await caches.open(cacheName);
|
|
||||||
const response = await cache.match(url);
|
|
||||||
if (response) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
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
|
* Check if URL is an image based on extension or content type
|
||||||
*/
|
*/
|
||||||
const isImageUrl = (url: string): boolean => {
|
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();
|
const lowerUrl = url.toLowerCase();
|
||||||
return imageExtensions.some((ext) => lowerUrl.includes(ext));
|
return imageExtensions.some((ext) => lowerUrl.includes(ext));
|
||||||
};
|
};
|
||||||
@ -198,13 +184,17 @@ const preloadWithProgress = async (
|
|||||||
const cache = await caches.open(OFFLINE_CONFIG.cacheNames.assets);
|
const cache = await caches.open(OFFLINE_CONFIG.cacheNames.assets);
|
||||||
// Create a new response from collected chunks
|
// Create a new response from collected chunks
|
||||||
const blob = new Blob(chunks as BlobPart[], {
|
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, {
|
const cachedResponse = new Response(blob, {
|
||||||
status: 200,
|
status: 200,
|
||||||
statusText: 'OK',
|
statusText: 'OK',
|
||||||
headers: {
|
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),
|
'Content-Length': String(bytesLoaded),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -304,15 +294,23 @@ export function usePreloadOrchestrator(
|
|||||||
activeDownloadsRef.current++;
|
activeDownloadsRef.current++;
|
||||||
|
|
||||||
const jobId = generateJobId();
|
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)
|
preloadWithProgress(item.url, jobId, item.id)
|
||||||
.then(() => {
|
.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);
|
preloadedUrls.add(item.url);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.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(() => {
|
.finally(() => {
|
||||||
activeDownloadsRef.current--;
|
activeDownloadsRef.current--;
|
||||||
@ -340,7 +338,10 @@ export function usePreloadOrchestrator(
|
|||||||
preloadedUrls.has(item.url) ||
|
preloadedUrls.has(item.url) ||
|
||||||
queueRef.current.some((q) => q.url === 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -348,7 +349,7 @@ export function usePreloadOrchestrator(
|
|||||||
url: item.url.slice(-60),
|
url: item.url.slice(-60),
|
||||||
assetType: item.assetType,
|
assetType: item.assetType,
|
||||||
priority: item.priority,
|
priority: item.priority,
|
||||||
queueLength: queueRef.current.length + 1
|
queueLength: queueRef.current.length + 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Insert in priority order (higher priority first)
|
// 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)
|
// Get a cached asset as a blob URL (for video playback)
|
||||||
const getCachedBlobUrl = useCallback(async (url: string): Promise<string | null> => {
|
// StorageManager.getAsset checks both IndexedDB (large files ≥ 5MB) and Cache API (small files)
|
||||||
if (typeof caches === 'undefined') return null;
|
const getCachedBlobUrl = useCallback(
|
||||||
|
async (url: string): Promise<string | null> => {
|
||||||
try {
|
try {
|
||||||
const cache = await caches.open(OFFLINE_CONFIG.cacheNames.assets);
|
const blob = await StorageManager.getAsset(url);
|
||||||
const response = await cache.match(url);
|
if (blob) {
|
||||||
if (!response) return null;
|
return URL.createObjectURL(blob);
|
||||||
|
}
|
||||||
const blob = await response.blob();
|
return null;
|
||||||
return URL.createObjectURL(blob);
|
} catch {
|
||||||
} catch {
|
return null;
|
||||||
return null;
|
}
|
||||||
}
|
},
|
||||||
}, []);
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
// Check if URL is preloaded (in cache)
|
// Check if URL is preloaded (in cache)
|
||||||
const isUrlPreloaded = useCallback(async (url: string): Promise<boolean> => {
|
const isUrlPreloaded = useCallback(
|
||||||
// First check in-memory set
|
async (url: string): Promise<boolean> => {
|
||||||
if (preloadedUrls.has(url)) return true;
|
// First check in-memory set
|
||||||
|
if (preloadedUrls.has(url)) return true;
|
||||||
|
|
||||||
// Then check Cache API
|
// Then check Cache API
|
||||||
return isUrlCached(url);
|
return isUrlCached(url);
|
||||||
}, [preloadedUrls]);
|
},
|
||||||
|
[preloadedUrls],
|
||||||
|
);
|
||||||
|
|
||||||
// React to page changes - preload neighbors
|
// React to page changes - preload neighbors
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -432,7 +437,10 @@ export function usePreloadOrchestrator(
|
|||||||
lastPreloadedPageRef.current = currentPageId;
|
lastPreloadedPageRef.current = currentPageId;
|
||||||
lastPreloadedLinksCountRef.current = currentLinksCount;
|
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
|
// Get prioritized assets based on current page
|
||||||
const assets = neighborGraph.getPrioritizedAssets(
|
const assets = neighborGraph.getPrioritizedAssets(
|
||||||
@ -442,7 +450,7 @@ export function usePreloadOrchestrator(
|
|||||||
|
|
||||||
logger.info('[PRELOAD] Found assets from neighbor graph', {
|
logger.info('[PRELOAD] Found assets from neighbor graph', {
|
||||||
assetCount: assets.length,
|
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
|
// Add background assets from pages
|
||||||
@ -496,7 +504,9 @@ export function usePreloadOrchestrator(
|
|||||||
neighbors.forEach(({ pageId }) => {
|
neighbors.forEach(({ pageId }) => {
|
||||||
const page = pages.find((p) => p.id === pageId);
|
const page = pages.find((p) => p.id === pageId);
|
||||||
if (page?.background_image_url) {
|
if (page?.background_image_url) {
|
||||||
const resolvedUrl = resolveAssetPlaybackUrl(page.background_image_url);
|
const resolvedUrl = resolveAssetPlaybackUrl(
|
||||||
|
page.background_image_url,
|
||||||
|
);
|
||||||
if (resolvedUrl) {
|
if (resolvedUrl) {
|
||||||
addToQueue({
|
addToQueue({
|
||||||
id: `bg-img-${pageId}`,
|
id: `bg-img-${pageId}`,
|
||||||
|
|||||||
@ -4,26 +4,26 @@ import {
|
|||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type MutableRefObject,
|
|
||||||
type RefObject,
|
type RefObject,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
import { logger } from '../lib/logger';
|
||||||
|
|
||||||
interface UseReversePlaybackOptions {
|
interface UseReversePlaybackOptions {
|
||||||
videoRef: RefObject<HTMLVideoElement | null>;
|
videoRef: RefObject<HTMLVideoElement | null>;
|
||||||
duration: number;
|
|
||||||
onComplete: () => void;
|
onComplete: () => void;
|
||||||
preloadedUrls?: Set<string>;
|
preloadedUrls?: Set<string>;
|
||||||
videoUrl?: string;
|
videoUrl?: string;
|
||||||
|
getCachedBlobUrl?: (url: string) => Promise<string | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseReversePlaybackResult {
|
interface UseReversePlaybackResult {
|
||||||
startReverse: () => Promise<void>;
|
startReverse: () => Promise<void>;
|
||||||
stopReverse: () => void;
|
stopReverse: () => void;
|
||||||
isReversing: boolean;
|
isReversing: boolean;
|
||||||
|
isBuffering: boolean;
|
||||||
canUseNativeReverse: boolean;
|
canUseNativeReverse: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Feature detection for native reverse playback (Chrome 141+, Safari 16+)
|
|
||||||
function checkNativeReverseSupport(): boolean {
|
function checkNativeReverseSupport(): boolean {
|
||||||
if (typeof window === 'undefined') return false;
|
if (typeof window === 'undefined') return false;
|
||||||
try {
|
try {
|
||||||
@ -35,14 +35,13 @@ function checkNativeReverseSupport(): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Native playbackRate = -1 (Chrome 141+, Safari 16+)
|
|
||||||
async function startNativeReverse(
|
async function startNativeReverse(
|
||||||
video: HTMLVideoElement,
|
video: HTMLVideoElement,
|
||||||
duration: number,
|
duration: number,
|
||||||
onComplete: () => void
|
onComplete: () => void,
|
||||||
): Promise<void> {
|
didFinishRef: { current: boolean },
|
||||||
|
): Promise<boolean> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
// Seek to end
|
|
||||||
video.currentTime = duration;
|
video.currentTime = duration;
|
||||||
video.playbackRate = -1;
|
video.playbackRate = -1;
|
||||||
|
|
||||||
@ -52,188 +51,348 @@ async function startNativeReverse(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onTimeUpdate = () => {
|
const onTimeUpdate = () => {
|
||||||
|
if (didFinishRef.current) {
|
||||||
|
cleanup();
|
||||||
|
resolve(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (video.currentTime <= 0.05) {
|
if (video.currentTime <= 0.05) {
|
||||||
cleanup();
|
cleanup();
|
||||||
video.pause();
|
video.pause();
|
||||||
video.currentTime = 0;
|
video.currentTime = 0;
|
||||||
onComplete();
|
onComplete();
|
||||||
resolve();
|
resolve(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
video.addEventListener('timeupdate', onTimeUpdate);
|
video.addEventListener('timeupdate', onTimeUpdate);
|
||||||
|
|
||||||
video.play().catch(() => {
|
video.play().catch((err) => {
|
||||||
// Fallback if native reverse fails
|
logger.error('Native reverse play failed:', err);
|
||||||
cleanup();
|
cleanup();
|
||||||
resolve();
|
resolve(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// When video is already cached, seeking to end is instant
|
function getBufferedEnd(video: HTMLVideoElement): number {
|
||||||
async function startPreloadedReverse(
|
return video.buffered.length > 0
|
||||||
video: HTMLVideoElement,
|
? video.buffered.end(video.buffered.length - 1)
|
||||||
duration: number,
|
: 0;
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useReversePlayback(
|
export function useReversePlayback(
|
||||||
options: UseReversePlaybackOptions
|
options: UseReversePlaybackOptions,
|
||||||
): UseReversePlaybackResult {
|
): UseReversePlaybackResult {
|
||||||
const [isReversing, setIsReversing] = useState(false);
|
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 canUseNativeReverse = useMemo(() => checkNativeReverseSupport(), []);
|
||||||
|
|
||||||
const stopReverse = useCallback(() => {
|
const cleanup = useCallback(() => {
|
||||||
if (intervalRef.current) {
|
didFinishRef.current = true;
|
||||||
clearInterval(intervalRef.current);
|
if (animationFrameRef.current !== null) {
|
||||||
intervalRef.current = null;
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
animationFrameRef.current = null;
|
||||||
}
|
}
|
||||||
setIsReversing(false);
|
cleanupFnsRef.current.forEach((fn) => fn());
|
||||||
|
cleanupFnsRef.current = [];
|
||||||
// Reset playback rate if it was modified
|
|
||||||
const video = options.videoRef.current;
|
const video = options.videoRef.current;
|
||||||
if (video && video.playbackRate !== 1) {
|
if (video && video.playbackRate !== 1) {
|
||||||
video.playbackRate = 1;
|
video.playbackRate = 1;
|
||||||
}
|
}
|
||||||
}, [options.videoRef]);
|
}, [options.videoRef]);
|
||||||
|
|
||||||
|
const stopReverse = useCallback(() => {
|
||||||
|
cleanup();
|
||||||
|
setIsReversing(false);
|
||||||
|
setIsBuffering(false);
|
||||||
|
}, [cleanup]);
|
||||||
|
|
||||||
const startReverse = useCallback(async () => {
|
const startReverse = useCallback(async () => {
|
||||||
const video = options.videoRef.current;
|
const video = options.videoRef.current;
|
||||||
if (!video) return;
|
if (!video) return;
|
||||||
|
|
||||||
// Stop any existing reverse playback
|
|
||||||
stopReverse();
|
stopReverse();
|
||||||
|
didFinishRef.current = false;
|
||||||
setIsReversing(true);
|
setIsReversing(true);
|
||||||
|
|
||||||
const { duration, onComplete, preloadedUrls, videoUrl } = options;
|
const { onComplete, preloadedUrls, videoUrl, getCachedBlobUrl } = options;
|
||||||
|
|
||||||
// Strategy 1: Native playbackRate = -1 (smoothest)
|
const actualDuration = video.duration;
|
||||||
if (canUseNativeReverse) {
|
if (!Number.isFinite(actualDuration) || actualDuration <= 0) {
|
||||||
await startNativeReverse(video, duration, () => {
|
logger.error('Invalid video duration for reverse playback', {
|
||||||
setIsReversing(false);
|
videoDuration: video.duration,
|
||||||
onComplete();
|
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);
|
setIsReversing(false);
|
||||||
onComplete();
|
onComplete();
|
||||||
}, intervalRef);
|
return;
|
||||||
}, [canUseNativeReverse, options, stopReverse]);
|
}
|
||||||
|
|
||||||
|
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]);
|
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='icon' href='/favicon.svg' />
|
||||||
<link rel='manifest' href='/manifest.json' />
|
<link rel='manifest' href='/manifest.json' />
|
||||||
<meta name='theme-color' content='#3B82F6' />
|
<meta name='theme-color' content='#3B82F6' />
|
||||||
<meta name='apple-mobile-web-app-capable' content='yes' />
|
<meta name='mobile-web-app-capable' content='yes' />
|
||||||
<meta
|
<meta
|
||||||
name='apple-mobile-web-app-status-bar-style'
|
name='apple-mobile-web-app-status-bar-style'
|
||||||
content='default'
|
content='default'
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -201,7 +201,7 @@ export default function Login() {
|
|||||||
</BaseButtons>
|
</BaseButtons>
|
||||||
<br />
|
<br />
|
||||||
<p className={'text-center'}>
|
<p className={'text-center'}>
|
||||||
Don't have an account yet?{' '}
|
Don't have an account yet?{' '}
|
||||||
<Link className={`${textColor}`} href={'/register'}>
|
<Link className={`${textColor}`} href={'/register'}>
|
||||||
New Account
|
New Account
|
||||||
</Link>
|
</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 SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
|
||||||
import { getPageTitle } from '../../config';
|
import { getPageTitle } from '../../config';
|
||||||
import { logger } from '../../lib/logger';
|
import { logger } from '../../lib/logger';
|
||||||
|
import { parseJsonObject } from '../../lib/parseJson';
|
||||||
|
|
||||||
type TourPage = {
|
type TourPage = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -45,29 +46,6 @@ type ProjectElementItem = {
|
|||||||
name: string;
|
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) =>
|
const toElementLabel = (value: string) =>
|
||||||
value
|
value
|
||||||
.split('_')
|
.split('_')
|
||||||
|
|||||||
@ -25,6 +25,12 @@ import { getPageTitle } from '../../config';
|
|||||||
import { hasPermission } from '../../helpers/userPermissions';
|
import { hasPermission } from '../../helpers/userPermissions';
|
||||||
import { useAppSelector } from '../../stores/hooks';
|
import { useAppSelector } from '../../stores/hooks';
|
||||||
import { logger } from '../../lib/logger';
|
import { logger } from '../../lib/logger';
|
||||||
|
import { parseJsonObject } from '../../lib/parseJson';
|
||||||
|
import type {
|
||||||
|
BaseCanvasElement,
|
||||||
|
GalleryCard,
|
||||||
|
CarouselSlide,
|
||||||
|
} from '../../types/constructor';
|
||||||
|
|
||||||
type TourPage = {
|
type TourPage = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -34,57 +40,14 @@ type TourPage = {
|
|||||||
environment?: string;
|
environment?: string;
|
||||||
source_key?: string;
|
source_key?: string;
|
||||||
requires_auth?: boolean;
|
requires_auth?: boolean;
|
||||||
ui_schema_json?: Record<string, any> | string;
|
ui_schema_json?: Record<string, unknown> | string;
|
||||||
background_image_url?: string;
|
background_image_url?: string;
|
||||||
background_video_url?: string;
|
background_video_url?: string;
|
||||||
background_audio_url?: string;
|
background_audio_url?: string;
|
||||||
background_loop?: boolean;
|
background_loop?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type GalleryCard = {
|
type ConstructorElement = BaseCanvasElement & {
|
||||||
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;
|
|
||||||
navLabel?: string;
|
navLabel?: string;
|
||||||
navType?: 'forward' | 'back';
|
navType?: 'forward' | 'back';
|
||||||
navDisabled?: boolean;
|
navDisabled?: boolean;
|
||||||
@ -118,29 +81,6 @@ type ConstructorSchema = {
|
|||||||
elements?: ConstructorElement[];
|
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) => {
|
const toStringQuery = (value: string | string[] | undefined) => {
|
||||||
if (Array.isArray(value)) return String(value[0] || '').trim();
|
if (Array.isArray(value)) return String(value[0] || '').trim();
|
||||||
return String(value || '').trim();
|
return String(value || '').trim();
|
||||||
|
|||||||
@ -31,10 +31,6 @@ type ProjectData = {
|
|||||||
description?: string | null;
|
description?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type RuntimeContextData = {
|
|
||||||
rootDomain?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ProjectWorkspacePage = () => {
|
const ProjectWorkspacePage = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { projectsId } = router.query;
|
const { projectsId } = router.query;
|
||||||
@ -45,8 +41,6 @@ const ProjectWorkspacePage = () => {
|
|||||||
}, [projectsId]);
|
}, [projectsId]);
|
||||||
|
|
||||||
const [project, setProject] = useState<ProjectData | null>(null);
|
const [project, setProject] = useState<ProjectData | null>(null);
|
||||||
const [runtimeContext, setRuntimeContext] =
|
|
||||||
useState<RuntimeContextData | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isPublishing, setIsPublishing] = useState(false);
|
const [isPublishing, setIsPublishing] = useState(false);
|
||||||
const [isPublishModalActive, setIsPublishModalActive] = useState(false);
|
const [isPublishModalActive, setIsPublishModalActive] = useState(false);
|
||||||
@ -63,34 +57,6 @@ const ProjectWorkspacePage = () => {
|
|||||||
try {
|
try {
|
||||||
const response = await axios.get(`/projects/${projectId}`);
|
const response = await axios.get(`/projects/${projectId}`);
|
||||||
setProject(response?.data || null);
|
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) {
|
} catch (error: any) {
|
||||||
setErrorMessage(
|
setErrorMessage(
|
||||||
error?.response?.data?.message ||
|
error?.response?.data?.message ||
|
||||||
@ -166,38 +132,17 @@ const ProjectWorkspacePage = () => {
|
|||||||
|
|
||||||
const presentationLinks = useMemo(() => {
|
const presentationLinks = useMemo(() => {
|
||||||
const projectSlug = project?.slug?.trim();
|
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 (!projectSlug) {
|
||||||
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) {
|
|
||||||
return { production: '', stage: '' };
|
return { production: '', stage: '' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const protocol =
|
// Use path-based URLs that work without DNS configuration
|
||||||
typeof window !== 'undefined' && window.location.protocol === 'http:'
|
|
||||||
? 'http'
|
|
||||||
: 'https';
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
production: `${protocol}://${projectSlug}.${rootDomain}`,
|
production: `/p/${projectSlug}`,
|
||||||
stage: `${protocol}://stage.${projectSlug}.${rootDomain}`,
|
stage: `/p/${projectSlug}/stage`,
|
||||||
};
|
};
|
||||||
}, [project?.slug, runtimeContext?.rootDomain]);
|
}, [project?.slug]);
|
||||||
|
|
||||||
const openPresentation = (url: string, label: string) => {
|
const openPresentation = (url: string, label: string) => {
|
||||||
if (!url) {
|
if (!url) {
|
||||||
|
|||||||
@ -15,10 +15,20 @@ import CardBox from '../components/CardBox';
|
|||||||
import LayoutGuest from '../layouts/Guest';
|
import LayoutGuest from '../layouts/Guest';
|
||||||
import { getPageTitle } from '../config';
|
import { getPageTitle } from '../config';
|
||||||
import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
|
import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
|
||||||
import { useReversePlayback } from '../hooks/useReversePlayback';
|
import { useTransitionPlayback } from '../hooks/useTransitionPlayback';
|
||||||
|
import { usePageNavigation } from '../hooks/usePageNavigation';
|
||||||
import { OfflineToggle, OfflineStatusIndicator } from '../components/Offline';
|
import { OfflineToggle, OfflineStatusIndicator } from '../components/Offline';
|
||||||
import { logger } from '../lib/logger';
|
import { logger } from '../lib/logger';
|
||||||
import { resolveAssetPlaybackUrl } from '../lib/assetUrl';
|
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';
|
type RuntimeMode = 'admin' | 'stage' | 'production' | 'unknown';
|
||||||
|
|
||||||
@ -27,106 +37,8 @@ type RuntimeContext = {
|
|||||||
projectSlug: string | null;
|
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) =>
|
const getRows = (response: any) =>
|
||||||
Array.isArray(response?.data?.rows) ? response.data.rows : [];
|
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 RuntimePageView = () => {
|
||||||
const [context, setContext] = useState<RuntimeContext | null>(null);
|
const [context, setContext] = useState<RuntimeContext | null>(null);
|
||||||
@ -135,8 +47,6 @@ const RuntimePageView = () => {
|
|||||||
const [elements, setElements] = useState<RuntimeElement[]>([]);
|
const [elements, setElements] = useState<RuntimeElement[]>([]);
|
||||||
const [pageLinks, setPageLinks] = useState<RuntimePageLink[]>([]);
|
const [pageLinks, setPageLinks] = useState<RuntimePageLink[]>([]);
|
||||||
const [transitions, setTransitions] = useState<RuntimeTransition[]>([]);
|
const [transitions, setTransitions] = useState<RuntimeTransition[]>([]);
|
||||||
const [selectedPageId, setSelectedPageId] = useState<string | null>(null);
|
|
||||||
const [pageHistory, setPageHistory] = useState<string[]>([]);
|
|
||||||
const [overlayTransition, setOverlayTransition] =
|
const [overlayTransition, setOverlayTransition] =
|
||||||
useState<TransitionOverlayState | null>(null);
|
useState<TransitionOverlayState | null>(null);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@ -144,14 +54,30 @@ const RuntimePageView = () => {
|
|||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
|
||||||
const overlayVideoRef = useRef<HTMLVideoElement | null>(null);
|
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
|
// Initialize preload orchestrator for neighbor-based asset preloading
|
||||||
const preloadOrchestrator = usePreloadOrchestrator({
|
const preloadOrchestrator = usePreloadOrchestrator({
|
||||||
pages,
|
pages,
|
||||||
pageLinks,
|
pageLinks,
|
||||||
elements,
|
elements,
|
||||||
currentPageId: selectedPageId,
|
currentPageId,
|
||||||
pageHistory,
|
pageHistory,
|
||||||
enabled: !isLoading && !error,
|
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(() => {
|
const pageElements = useMemo(() => {
|
||||||
if (!currentPage) return [];
|
if (!currentPage) return [];
|
||||||
return elements
|
return elements
|
||||||
@ -351,15 +227,13 @@ const RuntimePageView = () => {
|
|||||||
linkDirection?: string;
|
linkDirection?: string;
|
||||||
transition?: RuntimeTransition | null;
|
transition?: RuntimeTransition | null;
|
||||||
}) => {
|
}) => {
|
||||||
const currentId = selectedPageId || defaultPage?.id;
|
const currentId = currentPageId || defaultPage?.id;
|
||||||
if (!targetPageId || !currentId || targetPageId === currentId) {
|
if (!targetPageId || !currentId || targetPageId === currentId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const previousPageId =
|
|
||||||
pageHistory.length > 1 ? pageHistory[pageHistory.length - 2] : null;
|
|
||||||
const isBack =
|
const isBack =
|
||||||
linkDirection === 'back' || previousPageId === targetPageId;
|
linkDirection === 'back' || isBackNavigation(targetPageId);
|
||||||
const transitionName =
|
const transitionName =
|
||||||
transition?.name || transition?.slug || 'Transition';
|
transition?.name || transition?.slug || 'Transition';
|
||||||
const canUseReverseVideo =
|
const canUseReverseVideo =
|
||||||
@ -382,209 +256,31 @@ const RuntimePageView = () => {
|
|||||||
: Number(transition.duration_sec),
|
: Number(transition.duration_sec),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[applyPageSelection, defaultPage?.id, pageHistory, selectedPageId],
|
[applyPageSelection, currentPageId, defaultPage?.id, isBackNavigation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const finishOverlayTransition = useCallback(() => {
|
const { isBuffering: isTransitionBuffering } = useTransitionPlayback({
|
||||||
if (reverseAnimationFrame.current !== null) {
|
videoRef: overlayVideoRef,
|
||||||
cancelAnimationFrame(reverseAnimationFrame.current);
|
transition: overlayTransition
|
||||||
reverseAnimationFrame.current = null;
|
? {
|
||||||
}
|
videoUrl: overlayTransition.videoUrl,
|
||||||
|
reverseMode: overlayTransition.isReverse ? 'reverse' : 'none',
|
||||||
setOverlayTransition((active) => {
|
durationSec: overlayTransition.durationSec,
|
||||||
if (!active) return null;
|
targetPageId: overlayTransition.targetPageId,
|
||||||
applyPageSelection(active.targetPageId, active.isReverse);
|
displayName: overlayTransition.transitionName,
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}, 1000 / fps);
|
: null,
|
||||||
|
onComplete: (targetPageId) => {
|
||||||
// Store interval ID for cleanup
|
if (targetPageId && overlayTransition) {
|
||||||
reverseAnimationFrame.current = intervalId as unknown as number;
|
applyPageSelection(targetPageId, overlayTransition.isReverse);
|
||||||
};
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
};
|
setOverlayTransition(null);
|
||||||
|
},
|
||||||
const onCanPlayThrough = () => {
|
preload: {
|
||||||
// For reverse playback, start immediately when enough data is buffered
|
preloadedUrls: preloadOrchestrator.preloadedUrls,
|
||||||
if (overlayTransition.isReverse && !reverseStarted) {
|
getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl,
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -860,7 +556,8 @@ const RuntimePageView = () => {
|
|||||||
<video
|
<video
|
||||||
ref={overlayVideoRef}
|
ref={overlayVideoRef}
|
||||||
src={overlayTransition.videoUrl}
|
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
|
muted
|
||||||
playsInline
|
playsInline
|
||||||
autoPlay={!overlayTransition.isReverse}
|
autoPlay={!overlayTransition.isReverse}
|
||||||
|
|||||||
@ -9,13 +9,12 @@ import SectionMain from '../components/SectionMain';
|
|||||||
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
|
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
|
||||||
import { getPageTitle } from '../config';
|
import { getPageTitle } from '../config';
|
||||||
import { logger } from '../lib/logger';
|
import { logger } from '../lib/logger';
|
||||||
|
import type { UiElementDefault } from '../types/constructor';
|
||||||
|
|
||||||
type UiElementType = {
|
type UiElementType = UiElementDefault & {
|
||||||
id: string;
|
|
||||||
element_type: string;
|
element_type: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
sort_order?: number;
|
sort_order?: number;
|
||||||
is_active?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const toHumanLabel = (value: string) =>
|
const toHumanLabel = (value: string) =>
|
||||||
|
|||||||
@ -23,61 +23,22 @@ import SectionMain from '../../components/SectionMain';
|
|||||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
|
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
|
||||||
import { getPageTitle } from '../../config';
|
import { getPageTitle } from '../../config';
|
||||||
import { logger } from '../../lib/logger';
|
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 = {
|
type UiElementType = UiElementDefault & {
|
||||||
id: string;
|
|
||||||
element_type: string;
|
element_type: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
sort_order?: number;
|
sort_order?: number;
|
||||||
is_active?: boolean;
|
|
||||||
default_settings_json?: Record<string, any> | string | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type ElementType =
|
type ElementType = CanvasElementType;
|
||||||
| '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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const clampPercent = (value: string) => {
|
const clampPercent = (value: string) => {
|
||||||
const parsed = Number(value);
|
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 './forms';
|
||||||
export * from './filters';
|
export * from './filters';
|
||||||
export * from './permissions';
|
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