tsmoothe transitions improvement
This commit is contained in:
parent
54aec6d861
commit
fa41bd6ee1
@ -21,7 +21,7 @@ import BaseButton from './BaseButton';
|
||||
import CardBox from './CardBox';
|
||||
import { OfflineToggle } from './Offline/OfflineToggle';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import { getPageTitle } from '../config';
|
||||
import { getPageTitle, baseURLApi } from '../config';
|
||||
import { PRELOAD_CONFIG } from '../config/preload.config';
|
||||
import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
|
||||
import { useTransitionPlayback } from '../hooks/useTransitionPlayback';
|
||||
@ -31,7 +31,6 @@ import {
|
||||
markPresignedUrlFailed,
|
||||
isRelativeStoragePath,
|
||||
} from '../lib/assetUrl';
|
||||
import { baseURLApi } from '../config';
|
||||
import { buildElementStyle } from '../lib/elementStyles';
|
||||
import type {
|
||||
RuntimeProject,
|
||||
@ -214,6 +213,9 @@ export default function RuntimePresentation({
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [isBackgroundReady, setIsBackgroundReady] = useState(true);
|
||||
const [pendingTransitionComplete, setPendingTransitionComplete] =
|
||||
useState(false);
|
||||
|
||||
const transitionVideoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
@ -289,7 +291,7 @@ export default function RuntimePresentation({
|
||||
});
|
||||
|
||||
// Integrate useTransitionPlayback hook for smooth transitions (matches Constructor pattern)
|
||||
const { isBuffering } = useTransitionPlayback({
|
||||
const { isBuffering, phase: transitionPhase } = useTransitionPlayback({
|
||||
videoRef: transitionVideoRef,
|
||||
transition: transitionPreview
|
||||
? {
|
||||
@ -300,17 +302,23 @@ export default function RuntimePresentation({
|
||||
}
|
||||
: null,
|
||||
onComplete: (targetPageId) => {
|
||||
const video = transitionVideoRef.current;
|
||||
if (targetPageId) {
|
||||
const targetPage = pages.find((p) => p.id === targetPageId);
|
||||
waitForPageImages(targetPage || null).then(() => {
|
||||
// Mark background as not ready - new image will need to load
|
||||
setIsBackgroundReady(false);
|
||||
setSelectedPageId(targetPageId);
|
||||
setPageHistory((prev) => [...prev, targetPageId]);
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
setTransitionPreview(null);
|
||||
});
|
||||
});
|
||||
// Signal that transition is complete and waiting for background
|
||||
setPendingTransitionComplete(true);
|
||||
});
|
||||
} else {
|
||||
// No target page - clean up and remove overlay
|
||||
video?.removeAttribute('src');
|
||||
video?.load();
|
||||
setTransitionPreview(null);
|
||||
setPendingTransitionComplete(false);
|
||||
}
|
||||
},
|
||||
features: {
|
||||
@ -359,6 +367,21 @@ export default function RuntimePresentation({
|
||||
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||
}, []);
|
||||
|
||||
// Remove transition overlay when background is ready
|
||||
useEffect(() => {
|
||||
if (pendingTransitionComplete && isBackgroundReady) {
|
||||
const video = transitionVideoRef.current;
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
video?.removeAttribute('src');
|
||||
video?.load();
|
||||
setTransitionPreview(null);
|
||||
setPendingTransitionComplete(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [pendingTransitionComplete, isBackgroundReady]);
|
||||
|
||||
// Load presentation data
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
@ -471,6 +494,17 @@ export default function RuntimePresentation({
|
||||
}
|
||||
}, [selectedPage]);
|
||||
|
||||
// Handle background ready state for pages without images or with videos
|
||||
useEffect(() => {
|
||||
// If no background image, or if there's a video (video takes over), mark as ready
|
||||
if (
|
||||
!selectedPage?.background_image_url ||
|
||||
selectedPage?.background_video_url
|
||||
) {
|
||||
setIsBackgroundReady(true);
|
||||
}
|
||||
}, [selectedPage?.background_image_url, selectedPage?.background_video_url]);
|
||||
|
||||
const navigateToPage = useCallback(
|
||||
async (
|
||||
targetPageId: string,
|
||||
@ -488,8 +522,10 @@ export default function RuntimePresentation({
|
||||
isReverse: isBack,
|
||||
});
|
||||
} else {
|
||||
// Direct navigation - wait for images first
|
||||
// Direct navigation - wait for images first, then switch
|
||||
await waitForPageImages(targetPage);
|
||||
// Mark background as loading (Image onLoad will set it back to true)
|
||||
setIsBackgroundReady(false);
|
||||
setSelectedPageId(targetPageId);
|
||||
setPageHistory((prev) => [...prev, targetPageId]);
|
||||
}
|
||||
@ -756,6 +792,24 @@ export default function RuntimePresentation({
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
>
|
||||
{/* Background image element - ensures proper loading for waitForPageImages() */}
|
||||
{backgroundImageUrl && !backgroundVideoUrl && (
|
||||
<div className='absolute inset-0 pointer-events-none'>
|
||||
<Image
|
||||
key={backgroundImageUrl}
|
||||
src={backgroundImageUrl}
|
||||
alt=''
|
||||
fill
|
||||
sizes='100vw'
|
||||
className='object-cover'
|
||||
priority
|
||||
unoptimized
|
||||
onLoad={() => setIsBackgroundReady(true)}
|
||||
onError={() => setIsBackgroundReady(true)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Background video */}
|
||||
{backgroundVideoUrl && (
|
||||
<video
|
||||
@ -828,12 +882,15 @@ export default function RuntimePresentation({
|
||||
</div>
|
||||
|
||||
{/* Transition overlay - uses useTransitionPlayback hook for smooth transitions */}
|
||||
{/* Opacity is 0 during 'preparing' phase to show old page while video loads */}
|
||||
{transitionPreview && (
|
||||
<div className='fixed inset-0 z-50 overflow-hidden pointer-events-none'>
|
||||
<video
|
||||
ref={transitionVideoRef}
|
||||
className='absolute inset-0 h-full w-full object-cover transition-opacity duration-300 ease-linear'
|
||||
style={{ opacity: isBuffering ? 0 : 1 }}
|
||||
style={{
|
||||
opacity: transitionPhase === 'preparing' || isBuffering ? 0 : 1,
|
||||
}}
|
||||
muted
|
||||
playsInline
|
||||
preload='auto'
|
||||
|
||||
@ -515,10 +515,16 @@ export function usePreloadOrchestrator(
|
||||
const storagePaths: string[] = [];
|
||||
const currentPage = pages.find((p) => p.id === currentPageId);
|
||||
|
||||
if (currentPage?.background_image_url && isRelativeStoragePath(currentPage.background_image_url)) {
|
||||
if (
|
||||
currentPage?.background_image_url &&
|
||||
isRelativeStoragePath(currentPage.background_image_url)
|
||||
) {
|
||||
storagePaths.push(currentPage.background_image_url);
|
||||
}
|
||||
if (currentPage?.background_video_url && isRelativeStoragePath(currentPage.background_video_url)) {
|
||||
if (
|
||||
currentPage?.background_video_url &&
|
||||
isRelativeStoragePath(currentPage.background_video_url)
|
||||
) {
|
||||
storagePaths.push(currentPage.background_video_url);
|
||||
}
|
||||
|
||||
@ -532,7 +538,10 @@ export function usePreloadOrchestrator(
|
||||
const neighbors = neighborGraph.getNeighbors(currentPageId, 1);
|
||||
neighbors.forEach(({ pageId }) => {
|
||||
const page = pages.find((p) => p.id === pageId);
|
||||
if (page?.background_image_url && isRelativeStoragePath(page.background_image_url)) {
|
||||
if (
|
||||
page?.background_image_url &&
|
||||
isRelativeStoragePath(page.background_image_url)
|
||||
) {
|
||||
storagePaths.push(page.background_image_url);
|
||||
}
|
||||
});
|
||||
@ -548,7 +557,9 @@ export function usePreloadOrchestrator(
|
||||
addToQueue({
|
||||
id: `bg-img-${currentPageId}`,
|
||||
url: resolvedUrl,
|
||||
storageKey: isRelativeStoragePath(storageKey) ? storageKey : undefined,
|
||||
storageKey: isRelativeStoragePath(storageKey)
|
||||
? storageKey
|
||||
: undefined,
|
||||
priority: PRELOAD_CONFIG.priority.currentPage + 200,
|
||||
assetType: 'image',
|
||||
pageId: currentPageId,
|
||||
@ -562,7 +573,9 @@ export function usePreloadOrchestrator(
|
||||
addToQueue({
|
||||
id: `bg-vid-${currentPageId}`,
|
||||
url: resolvedUrl,
|
||||
storageKey: isRelativeStoragePath(storageKey) ? storageKey : undefined,
|
||||
storageKey: isRelativeStoragePath(storageKey)
|
||||
? storageKey
|
||||
: undefined,
|
||||
priority: PRELOAD_CONFIG.priority.currentPage + 150,
|
||||
assetType: 'video',
|
||||
pageId: currentPageId,
|
||||
@ -578,7 +591,9 @@ export function usePreloadOrchestrator(
|
||||
addToQueue({
|
||||
id: generateJobId(),
|
||||
url: resolvedUrl,
|
||||
storageKey: isRelativeStoragePath(storageKey) ? storageKey : undefined,
|
||||
storageKey: isRelativeStoragePath(storageKey)
|
||||
? storageKey
|
||||
: undefined,
|
||||
priority: asset.priority,
|
||||
assetType: asset.assetType,
|
||||
pageId: asset.pageId,
|
||||
@ -598,7 +613,9 @@ export function usePreloadOrchestrator(
|
||||
addToQueue({
|
||||
id: `bg-img-${pageId}`,
|
||||
url: resolvedUrl,
|
||||
storageKey: isRelativeStoragePath(storageKey) ? storageKey : undefined,
|
||||
storageKey: isRelativeStoragePath(storageKey)
|
||||
? storageKey
|
||||
: undefined,
|
||||
priority: PRELOAD_CONFIG.priority.neighborBase,
|
||||
assetType: 'image',
|
||||
pageId,
|
||||
@ -620,9 +637,12 @@ export function usePreloadOrchestrator(
|
||||
addAssetsToQueue();
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('[PRELOAD] Failed to fetch presigned URLs, falling back to proxy', {
|
||||
error: error?.message,
|
||||
});
|
||||
logger.error(
|
||||
'[PRELOAD] Failed to fetch presigned URLs, falling back to proxy',
|
||||
{
|
||||
error: error?.message,
|
||||
},
|
||||
);
|
||||
// Fallback: add to queue without presigned URLs (will use backend proxy)
|
||||
addAssetsToQueue();
|
||||
});
|
||||
|
||||
@ -113,7 +113,10 @@ function isPresignedUrl(url: string): boolean {
|
||||
* Convert a presigned URL back to proxy URL
|
||||
* Extracts the storage key from the S3 path and builds a proxy URL
|
||||
*/
|
||||
function getProxyUrlFallback(presignedUrl: string, originalStorageKey?: string): string | null {
|
||||
function getProxyUrlFallback(
|
||||
presignedUrl: string,
|
||||
originalStorageKey?: string,
|
||||
): string | null {
|
||||
// If we have the original storage key, use it directly
|
||||
if (originalStorageKey && isRelativeStoragePath(originalStorageKey)) {
|
||||
const normalizedPath = originalStorageKey.replace(/^\/+/, '');
|
||||
@ -615,7 +618,11 @@ export function useTransitionPlayback(
|
||||
|
||||
// Check if this is a presigned URL failure (likely CORS)
|
||||
const currentUrl = currentPlayableUrlRef.current;
|
||||
if (currentUrl && isPresignedUrl(currentUrl) && !didTryFallbackRef.current) {
|
||||
if (
|
||||
currentUrl &&
|
||||
isPresignedUrl(currentUrl) &&
|
||||
!didTryFallbackRef.current
|
||||
) {
|
||||
logger.info('Presigned URL failed, trying proxy fallback', {
|
||||
url: currentUrl.slice(0, 80),
|
||||
});
|
||||
@ -628,7 +635,10 @@ export function useTransitionPlayback(
|
||||
}
|
||||
|
||||
// Get proxy fallback URL
|
||||
const fallbackUrl = getProxyUrlFallback(currentUrl, currentTransition.videoUrl);
|
||||
const fallbackUrl = getProxyUrlFallback(
|
||||
currentUrl,
|
||||
currentTransition.videoUrl,
|
||||
);
|
||||
if (fallbackUrl) {
|
||||
didTryFallbackRef.current = true;
|
||||
video.pause();
|
||||
@ -684,7 +694,17 @@ export function useTransitionPlayback(
|
||||
clearTimers();
|
||||
stopReverseRef.current?.();
|
||||
};
|
||||
}, [sourceUrl, videoRef, playbackStartMs, durationBufferMs, hardTimeoutMs, clearTimers, revokeBlobUrl, finishPlayback, handleError]);
|
||||
}, [
|
||||
sourceUrl,
|
||||
videoRef,
|
||||
playbackStartMs,
|
||||
durationBufferMs,
|
||||
hardTimeoutMs,
|
||||
clearTimers,
|
||||
revokeBlobUrl,
|
||||
finishPlayback,
|
||||
handleError,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!transition) {
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
|
||||
import axios, { AxiosError } from 'axios';
|
||||
import { baseURLApi } from '../config';
|
||||
import { logger } from './logger';
|
||||
|
||||
/**
|
||||
* Check if a URL is a presigned S3 URL
|
||||
@ -27,12 +28,12 @@ export const setupPresignedUrlInterceptor = (): void => {
|
||||
|
||||
// Check if this is a presigned S3 URL failure (likely CORS)
|
||||
if (isPresignedS3Url(url) && !presignedUrlsDisabled) {
|
||||
console.info('[assetUrl] Presigned URL request failed, disabling presigned URLs');
|
||||
logger.info('Presigned URL request failed, disabling presigned URLs');
|
||||
disablePresignedUrls();
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@ -44,7 +45,7 @@ export const disablePresignedUrls = (): void => {
|
||||
if (!presignedUrlsDisabled) {
|
||||
presignedUrlsDisabled = true;
|
||||
presignedUrlCache.clear();
|
||||
console.info('[assetUrl] Presigned URLs disabled - all requests will use proxy');
|
||||
logger.info('Presigned URLs disabled - all requests will use proxy');
|
||||
}
|
||||
};
|
||||
|
||||
@ -127,7 +128,9 @@ const processBatch = async (): Promise<void> => {
|
||||
* Queue a URL for batch presigning and return a promise that resolves when the batch completes.
|
||||
* Used by the preloader to efficiently fetch presigned URLs for multiple assets.
|
||||
*/
|
||||
export const queuePresignedUrl = (storageKey: string): Promise<string | null> => {
|
||||
export const queuePresignedUrl = (
|
||||
storageKey: string,
|
||||
): Promise<string | null> => {
|
||||
// Check cache first
|
||||
const cached = presignedUrlCache.get(storageKey);
|
||||
if (cached && cached.expiresAt > Date.now() + CACHE_BUFFER_MS) {
|
||||
@ -158,7 +161,9 @@ export const queuePresignedUrl = (storageKey: string): Promise<string | null> =>
|
||||
* More efficient than calling queuePresignedUrl multiple times.
|
||||
* Returns empty object if presigned URLs are disabled.
|
||||
*/
|
||||
export const queuePresignedUrls = (storageKeys: string[]): Promise<Record<string, string>> => {
|
||||
export const queuePresignedUrls = (
|
||||
storageKeys: string[],
|
||||
): Promise<Record<string, string>> => {
|
||||
// Skip if presigned URLs are disabled
|
||||
if (presignedUrlsDisabled) {
|
||||
return Promise.resolve({});
|
||||
@ -259,7 +264,7 @@ export const arePresignedUrlsDisabled = (): boolean => {
|
||||
export const markPresignedUrlsVerified = (): void => {
|
||||
if (!presignedUrlsDisabled && !presignedUrlsVerified) {
|
||||
presignedUrlsVerified = true;
|
||||
console.info('[assetUrl] Presigned URLs verified - enabling direct S3 access');
|
||||
logger.info('Presigned URLs verified - enabling direct S3 access');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -64,9 +64,7 @@ export const decodeImages = async (
|
||||
* @example
|
||||
* const imageUrls = extractPageImageUrls(page);
|
||||
*/
|
||||
export const extractPageImageUrls = (
|
||||
page: PageWithImages | null,
|
||||
): string[] => {
|
||||
export const extractPageImageUrls = (page: PageWithImages | null): string[] => {
|
||||
if (!page) return [];
|
||||
|
||||
const imageUrls: string[] = [];
|
||||
|
||||
@ -69,7 +69,10 @@ axios.interceptors.response.use(
|
||||
|
||||
// Detect presigned S3 URL failures (CORS not configured)
|
||||
// Network errors (status 0) or CORS errors typically indicate S3 CORS issues
|
||||
if (isPresignedS3Url(requestUrl) && (!status || status === 0 || error.message?.includes('Network Error'))) {
|
||||
if (
|
||||
isPresignedS3Url(requestUrl) &&
|
||||
(!status || status === 0 || error.message?.includes('Network Error'))
|
||||
) {
|
||||
logger.info('[axios] Presigned URL failed, disabling presigned URLs', {
|
||||
url: requestUrl.slice(0, 80),
|
||||
});
|
||||
|
||||
@ -427,9 +427,13 @@ const mergeElementWithDefaults = (
|
||||
const elementValue = elementRecord[prop];
|
||||
const defaultValue = defaultsRecord[prop];
|
||||
const elementIsEmpty =
|
||||
elementValue === '' || elementValue === undefined || elementValue === null;
|
||||
elementValue === '' ||
|
||||
elementValue === undefined ||
|
||||
elementValue === null;
|
||||
const defaultHasValue =
|
||||
defaultValue !== undefined && defaultValue !== null && defaultValue !== '';
|
||||
defaultValue !== undefined &&
|
||||
defaultValue !== null &&
|
||||
defaultValue !== '';
|
||||
if (elementIsEmpty && defaultHasValue) {
|
||||
mergedRecord[prop] = defaultValue;
|
||||
}
|
||||
@ -1087,10 +1091,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
(item: Record<string, unknown>) => {
|
||||
const extracted: Record<string, unknown> = {};
|
||||
nestedUrlFields.forEach((urlField) => {
|
||||
if (
|
||||
item[urlField] !== undefined &&
|
||||
item[urlField] !== ''
|
||||
) {
|
||||
if (item[urlField] !== undefined && item[urlField] !== '') {
|
||||
extracted[urlField] = item[urlField];
|
||||
}
|
||||
});
|
||||
|
||||
@ -232,8 +232,7 @@ const RuntimePageView = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const isBack =
|
||||
linkDirection === 'back' || isBackNavigation(targetPageId);
|
||||
const isBack = linkDirection === 'back' || isBackNavigation(targetPageId);
|
||||
const transitionName =
|
||||
transition?.name || transition?.slug || 'Transition';
|
||||
const canUseReverseVideo =
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user