- three destinations for info panel thumbnails : the panel image preview, new page, external page (URL). Two destinations for other elements (other page, external URL) - toggle for info panel media opening (fullscreen or in the panel) - ability to replace background with info panel media (image, video, 360 panorama) - ability to make 360 panorama as page background - global mute button -
887 lines
24 KiB
TypeScript
887 lines
24 KiB
TypeScript
/**
|
|
* usePageNavigationState Hook
|
|
*
|
|
* Unified state machine for page navigation, replacing 6+ fragmented hooks.
|
|
* Uses useReducer for atomic state transitions, preventing race conditions.
|
|
*
|
|
* Consolidates:
|
|
* - usePageSwitch: URL resolution and switching
|
|
* - useBackgroundState: Background ready tracking
|
|
* - useBackgroundTransition: Fade-from-black effects
|
|
* - useTransitionCleanup: Video cleanup coordination
|
|
* - useBackgroundUrls: URL resolution for display
|
|
* - pageLoadingUtils: Loading state computation
|
|
*
|
|
* State Machine Phases:
|
|
* - idle: No navigation in progress, elements visible
|
|
* - preparing: Navigation triggered, saving previous URLs, resolving new URLs
|
|
* - transitioning: Video transition playing
|
|
* - transition_done: Video finished, waiting for background to load
|
|
* - loading_bg: Direct navigation (no video), waiting for background to load
|
|
* - fading_in: Black overlay fading out to reveal new page
|
|
*/
|
|
|
|
import { useReducer, useCallback, useRef, useEffect, useMemo } from 'react';
|
|
import {
|
|
resolveAssetPlaybackUrl,
|
|
markPresignedUrlFailed,
|
|
isRelativeStoragePath,
|
|
isPresignedUrl,
|
|
buildProxyUrl,
|
|
} from '../lib/assetUrl';
|
|
import {
|
|
scheduleAfterPaint,
|
|
scheduleAfterPaintSafari,
|
|
isSafari,
|
|
getCrossfadeDuration,
|
|
} from '../lib/browserUtils';
|
|
import { logger } from '../lib/logger';
|
|
import type { ResolvedTransitionSettings } from '../types/transition';
|
|
|
|
// ============================================================================
|
|
// Types
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Navigation phases as a finite state machine
|
|
*/
|
|
export type NavigationPhase =
|
|
| 'idle' // No navigation in progress
|
|
| 'preparing' // Resolving URLs, saving previous state
|
|
| 'transitioning' // Video transition playing
|
|
| 'transition_done' // Video finished, waiting for background
|
|
| 'loading_bg' // Direct navigation, waiting for background
|
|
| 'fading_in'; // Black overlay fading out
|
|
|
|
/**
|
|
* Minimal page interface for navigation
|
|
*/
|
|
export interface NavigablePage {
|
|
id: string;
|
|
background_image_url?: string;
|
|
background_video_url?: string;
|
|
background_embed_url?: string;
|
|
background_audio_url?: string;
|
|
}
|
|
|
|
/**
|
|
* Preload cache provider interface
|
|
*/
|
|
export interface PreloadCacheProvider {
|
|
getReadyBlobUrl?: (url: string) => string | null;
|
|
getCachedBlobUrl?: (url: string) => Promise<string | null>;
|
|
preloadedUrls?: Set<string>;
|
|
}
|
|
|
|
/**
|
|
* Internal state structure
|
|
*/
|
|
interface NavigationState {
|
|
phase: NavigationPhase;
|
|
|
|
// Current page URLs (resolved for display)
|
|
currentImageUrl: string;
|
|
currentVideoUrl: string;
|
|
currentEmbedUrl: string;
|
|
currentAudioUrl: string;
|
|
|
|
// Previous page URLs (for overlay during transition)
|
|
previousImageUrl: string;
|
|
previousVideoUrl: string;
|
|
|
|
// Target page ID (during navigation)
|
|
targetPageId: string | null;
|
|
|
|
// Whether current navigation is a back navigation
|
|
isBackNavigation: boolean;
|
|
|
|
// Safari black flash prevention
|
|
lastKnownBgUrl: string;
|
|
|
|
// Video buffering state
|
|
isVideoBuffering: boolean;
|
|
}
|
|
|
|
/**
|
|
* Actions for the reducer
|
|
*/
|
|
type NavigationAction =
|
|
| {
|
|
type: 'START_NAVIGATION';
|
|
payload: {
|
|
hasTransition: boolean;
|
|
targetPageId: string | null;
|
|
isBack: boolean;
|
|
};
|
|
}
|
|
| {
|
|
type: 'START_TRANSITION';
|
|
payload: {
|
|
targetPageId: string | null;
|
|
isBack: boolean;
|
|
};
|
|
}
|
|
| {
|
|
type: 'URLS_RESOLVED';
|
|
payload: {
|
|
imageUrl: string;
|
|
videoUrl: string;
|
|
embedUrl: string;
|
|
audioUrl: string;
|
|
};
|
|
}
|
|
| { type: 'TRANSITION_STARTED' }
|
|
| { type: 'TRANSITION_ENDED' }
|
|
| { type: 'BACKGROUND_READY' }
|
|
| { type: 'FADE_STARTED' }
|
|
| { type: 'FADE_COMPLETED' }
|
|
| {
|
|
type: 'SET_BACKGROUND_DIRECTLY';
|
|
payload: {
|
|
imageUrl: string;
|
|
videoUrl: string;
|
|
embedUrl: string;
|
|
audioUrl: string;
|
|
};
|
|
}
|
|
| { type: 'RESET_TO_IDLE' }
|
|
| { type: 'SET_VIDEO_BUFFERING'; payload: boolean }
|
|
| { type: 'UPDATE_LAST_KNOWN_BG'; payload: string }
|
|
| { type: 'CLEAR_PREVIOUS_BACKGROUND' };
|
|
|
|
// ============================================================================
|
|
// Reducer
|
|
// ============================================================================
|
|
|
|
const initialState: NavigationState = {
|
|
phase: 'idle',
|
|
currentImageUrl: '',
|
|
currentVideoUrl: '',
|
|
currentEmbedUrl: '',
|
|
currentAudioUrl: '',
|
|
previousImageUrl: '',
|
|
previousVideoUrl: '',
|
|
targetPageId: null,
|
|
isBackNavigation: false,
|
|
lastKnownBgUrl: '',
|
|
isVideoBuffering: false,
|
|
};
|
|
|
|
function navigationReducer(
|
|
state: NavigationState,
|
|
action: NavigationAction,
|
|
): NavigationState {
|
|
// DevTools logging in development
|
|
if (process.env.NODE_ENV === 'development') {
|
|
logger.info('[NavigationState] Action:', {
|
|
type: action.type,
|
|
currentPhase: state.phase,
|
|
payload: 'payload' in action ? action.payload : undefined,
|
|
});
|
|
}
|
|
|
|
switch (action.type) {
|
|
case 'START_NAVIGATION':
|
|
// ATOMIC: Save previous URLs + set phase in one update
|
|
return {
|
|
...state,
|
|
phase: action.payload.hasTransition ? 'transitioning' : 'loading_bg',
|
|
previousImageUrl: state.currentImageUrl,
|
|
previousVideoUrl: state.currentVideoUrl,
|
|
targetPageId: action.payload.targetPageId,
|
|
isBackNavigation: action.payload.isBack,
|
|
};
|
|
|
|
case 'START_TRANSITION':
|
|
// Video transition started (before video plays)
|
|
// Sets phase to 'transitioning' and saves previous URLs atomically
|
|
return {
|
|
...state,
|
|
phase: 'transitioning',
|
|
previousImageUrl: state.currentImageUrl,
|
|
previousVideoUrl: state.currentVideoUrl,
|
|
targetPageId: action.payload.targetPageId,
|
|
isBackNavigation: action.payload.isBack,
|
|
};
|
|
|
|
case 'URLS_RESOLVED':
|
|
// URLs resolved, update current URLs
|
|
return {
|
|
...state,
|
|
currentImageUrl: action.payload.imageUrl,
|
|
currentVideoUrl: action.payload.videoUrl,
|
|
currentEmbedUrl: action.payload.embedUrl,
|
|
currentAudioUrl: action.payload.audioUrl,
|
|
};
|
|
|
|
case 'TRANSITION_STARTED':
|
|
// Video transition has started playing
|
|
if (state.phase !== 'transitioning') return state;
|
|
return state; // Phase already correct, no change needed
|
|
|
|
case 'TRANSITION_ENDED':
|
|
// Video transition ended, wait for background
|
|
if (state.phase !== 'transitioning') return state;
|
|
return {
|
|
...state,
|
|
phase: 'transition_done',
|
|
};
|
|
|
|
case 'BACKGROUND_READY':
|
|
// Background loaded, start fade-in (only from certain phases)
|
|
if (
|
|
state.phase !== 'transition_done' &&
|
|
state.phase !== 'loading_bg' &&
|
|
state.phase !== 'preparing'
|
|
) {
|
|
return state;
|
|
}
|
|
return {
|
|
...state,
|
|
phase: 'fading_in',
|
|
};
|
|
|
|
case 'FADE_STARTED':
|
|
// Fade animation started
|
|
if (state.phase !== 'fading_in') return state;
|
|
return state;
|
|
|
|
case 'FADE_COMPLETED':
|
|
// Fade animation completed, return to idle
|
|
return {
|
|
...state,
|
|
phase: 'idle',
|
|
previousImageUrl: '',
|
|
previousVideoUrl: '',
|
|
targetPageId: null,
|
|
isBackNavigation: false,
|
|
};
|
|
|
|
case 'SET_BACKGROUND_DIRECTLY':
|
|
// Direct background update (edit mode) - bypasses navigation flow
|
|
return {
|
|
...state,
|
|
phase: 'idle',
|
|
currentImageUrl: action.payload.imageUrl,
|
|
currentVideoUrl: action.payload.videoUrl,
|
|
currentEmbedUrl: action.payload.embedUrl,
|
|
currentAudioUrl: action.payload.audioUrl,
|
|
previousImageUrl: '',
|
|
previousVideoUrl: '',
|
|
};
|
|
|
|
case 'RESET_TO_IDLE':
|
|
// Force reset to idle state
|
|
return {
|
|
...state,
|
|
phase: 'idle',
|
|
previousImageUrl: '',
|
|
previousVideoUrl: '',
|
|
targetPageId: null,
|
|
isBackNavigation: false,
|
|
};
|
|
|
|
case 'SET_VIDEO_BUFFERING':
|
|
return {
|
|
...state,
|
|
isVideoBuffering: action.payload,
|
|
};
|
|
|
|
case 'UPDATE_LAST_KNOWN_BG':
|
|
// Update Safari black flash prevention snapshot
|
|
return {
|
|
...state,
|
|
lastKnownBgUrl: action.payload,
|
|
};
|
|
|
|
case 'CLEAR_PREVIOUS_BACKGROUND':
|
|
return {
|
|
...state,
|
|
previousImageUrl: '',
|
|
previousVideoUrl: '',
|
|
};
|
|
|
|
default:
|
|
return state;
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Hook Options & Result
|
|
// ============================================================================
|
|
|
|
export interface UsePageNavigationStateOptions {
|
|
/** Preload cache provider for blob URL resolution */
|
|
preloadCache?: PreloadCacheProvider;
|
|
/** Fade duration in milliseconds (default: 700) */
|
|
fadeDurationMs?: number;
|
|
/** Transition settings for dynamic duration/easing */
|
|
transitionSettings?: ResolvedTransitionSettings | null;
|
|
}
|
|
|
|
export interface UsePageNavigationStateResult {
|
|
// Current state
|
|
phase: NavigationPhase;
|
|
state: NavigationState;
|
|
|
|
// Current page URLs (for display)
|
|
currentImageUrl: string;
|
|
currentVideoUrl: string;
|
|
currentEmbedUrl: string;
|
|
currentAudioUrl: string;
|
|
|
|
// Previous page URLs (for overlay)
|
|
previousImageUrl: string;
|
|
previousVideoUrl: string;
|
|
|
|
// Safari black flash prevention
|
|
lastKnownBgUrl: string;
|
|
|
|
// Derived states (computed from phase)
|
|
isLoading: boolean;
|
|
showSpinner: boolean;
|
|
showElements: boolean;
|
|
showPreviousOverlay: boolean;
|
|
showTransitionVideo: boolean;
|
|
isFadingIn: boolean;
|
|
isSwitching: boolean;
|
|
isNewBgReady: boolean;
|
|
isBackgroundReady: boolean;
|
|
pendingTransitionComplete: boolean;
|
|
|
|
// Video buffering state
|
|
isVideoBuffering: boolean;
|
|
|
|
// Transition style for CSS
|
|
transitionStyle: React.CSSProperties;
|
|
|
|
// Actions
|
|
/** Start navigation to a new page */
|
|
navigateToPage: (
|
|
targetPage: NavigablePage | null,
|
|
options?: {
|
|
hasTransition?: boolean;
|
|
isBack?: boolean;
|
|
onSwitched?: () => void;
|
|
},
|
|
) => Promise<void>;
|
|
|
|
/** Signal that background media is ready (call from CanvasBackground.onLoad) */
|
|
onBackgroundReady: () => void;
|
|
|
|
/** Signal that transition video has ended */
|
|
onTransitionEnded: () => void;
|
|
|
|
/** Reset background ready state (call before navigation) */
|
|
resetBackgroundReady: () => void;
|
|
|
|
/** Clear previous background overlay */
|
|
clearPreviousBackground: () => void;
|
|
|
|
/** Direct background update for edit mode (bypasses navigation flow) */
|
|
setBackgroundDirectly: (
|
|
imageUrl: string,
|
|
videoUrl: string,
|
|
embedUrl: string,
|
|
audioUrl: string,
|
|
) => void;
|
|
|
|
/** Reset to idle state */
|
|
resetToIdle: () => void;
|
|
|
|
/** Video buffering state callback */
|
|
onVideoBufferStateChange: (isBuffering: boolean) => void;
|
|
|
|
/** Mark new background as ready (for compatibility with usePageSwitch) */
|
|
markBackgroundReady: () => void;
|
|
|
|
/** Start a video transition (sets phase to 'transitioning') */
|
|
startTransition: (targetPageId: string | null, isBack?: boolean) => void;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Helper Functions
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Decode an image from URL to ensure it's ready for display.
|
|
* Safari-specific: waits extra frame after decode to ensure pixels are painted.
|
|
*/
|
|
const decodeImage = (url: string): Promise<void> => {
|
|
return new Promise((resolve) => {
|
|
if (!url) {
|
|
resolve();
|
|
return;
|
|
}
|
|
|
|
const img = new window.Image();
|
|
const safariMode = isSafari();
|
|
|
|
const onReady = () => {
|
|
if (safariMode) {
|
|
scheduleAfterPaintSafari(() => resolve());
|
|
} else {
|
|
scheduleAfterPaint(() => resolve());
|
|
}
|
|
};
|
|
|
|
img.onload = () => {
|
|
if (typeof img.decode === 'function') {
|
|
img.decode().then(onReady).catch(onReady);
|
|
} else {
|
|
onReady();
|
|
}
|
|
};
|
|
|
|
img.onerror = () => onReady();
|
|
img.src = url;
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Load and decode an image with presigned URL fallback.
|
|
*/
|
|
const loadImageWithFallback = (
|
|
url: string,
|
|
storageKey?: string,
|
|
): Promise<string> => {
|
|
return new Promise((resolve) => {
|
|
const img = new window.Image();
|
|
const safariMode = isSafari();
|
|
|
|
const onImageReady = (srcUrl: string) => {
|
|
if (safariMode) {
|
|
scheduleAfterPaintSafari(() => resolve(srcUrl));
|
|
} else {
|
|
resolve(srcUrl);
|
|
}
|
|
};
|
|
|
|
const tryLoad = (srcUrl: string, isRetry = false) => {
|
|
img.src = srcUrl;
|
|
|
|
img.onload = () => {
|
|
if (typeof img.decode === 'function') {
|
|
img
|
|
.decode()
|
|
.then(() => onImageReady(srcUrl))
|
|
.catch(() => onImageReady(srcUrl));
|
|
} else {
|
|
onImageReady(srcUrl);
|
|
}
|
|
};
|
|
|
|
img.onerror = () => {
|
|
if (!isRetry && isPresignedUrl(srcUrl) && storageKey) {
|
|
logger.info('Image presigned URL failed, retrying with proxy', {
|
|
storageKey: storageKey.slice(-50),
|
|
});
|
|
markPresignedUrlFailed(storageKey);
|
|
const proxyUrl = buildProxyUrl(storageKey);
|
|
tryLoad(proxyUrl, true);
|
|
} else {
|
|
onImageReady(srcUrl);
|
|
}
|
|
};
|
|
};
|
|
|
|
tryLoad(url);
|
|
});
|
|
};
|
|
|
|
// ============================================================================
|
|
// Main Hook
|
|
// ============================================================================
|
|
|
|
export function usePageNavigationState(
|
|
options: UsePageNavigationStateOptions = {},
|
|
): UsePageNavigationStateResult {
|
|
const { preloadCache, transitionSettings } = options;
|
|
const fadeDurationMs =
|
|
options.fadeDurationMs ?? transitionSettings?.durationMs ?? 700;
|
|
|
|
const [state, dispatch] = useReducer(navigationReducer, initialState);
|
|
|
|
// Refs for stable callbacks
|
|
const preloadCacheRef = useRef(preloadCache);
|
|
preloadCacheRef.current = preloadCache;
|
|
const transitionSettingsRef = useRef(transitionSettings);
|
|
transitionSettingsRef.current = transitionSettings;
|
|
|
|
// Track created blob URLs for cleanup
|
|
const createdBlobUrlsRef = useRef<Set<string>>(new Set());
|
|
|
|
// Fade timer ref
|
|
const fadeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
// ============================================================================
|
|
// URL Resolution
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Resolve a storage path to a displayable URL.
|
|
*/
|
|
const resolveToDisplayUrl = useCallback(
|
|
async (storagePath: string | undefined): Promise<string> => {
|
|
if (!storagePath) return '';
|
|
|
|
const cache = preloadCacheRef.current;
|
|
|
|
// 1. Try in-memory blob URL lookup (instant)
|
|
if (cache?.getReadyBlobUrl) {
|
|
const readyUrl = cache.getReadyBlobUrl(storagePath);
|
|
if (readyUrl) {
|
|
logger.info('Using ready blob URL (storage key)', {
|
|
storagePath: storagePath.slice(-50),
|
|
});
|
|
return readyUrl;
|
|
}
|
|
}
|
|
|
|
// 2. Try persistent cache by storage path
|
|
if (cache?.getCachedBlobUrl) {
|
|
try {
|
|
const blobUrl = await cache.getCachedBlobUrl(storagePath);
|
|
if (blobUrl) {
|
|
createdBlobUrlsRef.current.add(blobUrl);
|
|
return blobUrl;
|
|
}
|
|
} catch {
|
|
// Fall through
|
|
}
|
|
}
|
|
|
|
// 3. Resolve to playback URL
|
|
const originalUrl = resolveAssetPlaybackUrl(storagePath);
|
|
if (!originalUrl) return '';
|
|
|
|
// Try blob URL lookup by resolved URL
|
|
if (cache?.getReadyBlobUrl) {
|
|
const readyUrl = cache.getReadyBlobUrl(originalUrl);
|
|
if (readyUrl) {
|
|
return readyUrl;
|
|
}
|
|
}
|
|
|
|
// Try cached blob URL by resolved URL
|
|
if (cache?.getCachedBlobUrl) {
|
|
try {
|
|
const blobUrl = await cache.getCachedBlobUrl(originalUrl);
|
|
if (blobUrl) {
|
|
createdBlobUrlsRef.current.add(blobUrl);
|
|
return blobUrl;
|
|
}
|
|
} catch {
|
|
// Fall through
|
|
}
|
|
}
|
|
|
|
// Load with presigned URL fallback
|
|
const storageKey = isRelativeStoragePath(storagePath)
|
|
? storagePath
|
|
: undefined;
|
|
return loadImageWithFallback(originalUrl, storageKey);
|
|
},
|
|
[],
|
|
);
|
|
|
|
/**
|
|
* Resolve video/audio URL.
|
|
*/
|
|
const resolveMediaUrl = useCallback(
|
|
async (storagePath: string | undefined): Promise<string> => {
|
|
if (!storagePath) return '';
|
|
|
|
const cache = preloadCacheRef.current;
|
|
|
|
// 1. Try in-memory blob URL lookup
|
|
if (cache?.getReadyBlobUrl) {
|
|
const readyUrl = cache.getReadyBlobUrl(storagePath);
|
|
if (readyUrl) return readyUrl;
|
|
}
|
|
|
|
// 2. Try persistent cache
|
|
if (cache?.getCachedBlobUrl) {
|
|
try {
|
|
const blobUrl = await cache.getCachedBlobUrl(storagePath);
|
|
if (blobUrl) {
|
|
createdBlobUrlsRef.current.add(blobUrl);
|
|
return blobUrl;
|
|
}
|
|
} catch {
|
|
// Fall through
|
|
}
|
|
}
|
|
|
|
// 3. Resolve URL
|
|
const originalUrl = resolveAssetPlaybackUrl(storagePath);
|
|
if (!originalUrl) return '';
|
|
|
|
if (cache?.getReadyBlobUrl) {
|
|
const readyUrl = cache.getReadyBlobUrl(originalUrl);
|
|
if (readyUrl) return readyUrl;
|
|
}
|
|
|
|
if (cache?.getCachedBlobUrl) {
|
|
try {
|
|
const blobUrl = await cache.getCachedBlobUrl(originalUrl);
|
|
if (blobUrl) {
|
|
createdBlobUrlsRef.current.add(blobUrl);
|
|
return blobUrl;
|
|
}
|
|
} catch {
|
|
// Fall through
|
|
}
|
|
}
|
|
|
|
return originalUrl;
|
|
},
|
|
[],
|
|
);
|
|
|
|
// ============================================================================
|
|
// Actions
|
|
// ============================================================================
|
|
|
|
const navigateToPage = useCallback(
|
|
async (
|
|
targetPage: NavigablePage | null,
|
|
options: {
|
|
hasTransition?: boolean;
|
|
isBack?: boolean;
|
|
onSwitched?: () => void;
|
|
} = {},
|
|
) => {
|
|
const { hasTransition = false, isBack = false, onSwitched } = options;
|
|
|
|
if (!targetPage) {
|
|
dispatch({ type: 'RESET_TO_IDLE' });
|
|
onSwitched?.();
|
|
return;
|
|
}
|
|
|
|
// Start navigation atomically
|
|
dispatch({
|
|
type: 'START_NAVIGATION',
|
|
payload: {
|
|
hasTransition,
|
|
targetPageId: targetPage.id,
|
|
isBack,
|
|
},
|
|
});
|
|
|
|
// Resolve URLs (may be async)
|
|
const [imageUrl, videoUrl, embedUrl, audioUrl] = await Promise.all([
|
|
resolveToDisplayUrl(targetPage.background_image_url),
|
|
resolveMediaUrl(targetPage.background_video_url),
|
|
resolveMediaUrl(targetPage.background_embed_url),
|
|
resolveMediaUrl(targetPage.background_audio_url),
|
|
]);
|
|
|
|
// Update current URLs
|
|
dispatch({
|
|
type: 'URLS_RESOLVED',
|
|
payload: { imageUrl, videoUrl, embedUrl, audioUrl },
|
|
});
|
|
|
|
// Notify caller
|
|
onSwitched?.();
|
|
|
|
// For blob URLs, decode image before marking ready
|
|
if (
|
|
!hasTransition &&
|
|
(embedUrl || imageUrl.startsWith('blob:') || !imageUrl)
|
|
) {
|
|
decodeImage(imageUrl).then(() => {
|
|
dispatch({ type: 'BACKGROUND_READY' });
|
|
});
|
|
}
|
|
},
|
|
[resolveToDisplayUrl, resolveMediaUrl],
|
|
);
|
|
|
|
const onBackgroundReady = useCallback(() => {
|
|
dispatch({ type: 'BACKGROUND_READY' });
|
|
}, []);
|
|
|
|
const onTransitionEnded = useCallback(() => {
|
|
dispatch({ type: 'TRANSITION_ENDED' });
|
|
}, []);
|
|
|
|
const resetBackgroundReady = useCallback(() => {
|
|
// This is called before navigation to reset state
|
|
// The actual reset happens in START_NAVIGATION
|
|
}, []);
|
|
|
|
const clearPreviousBackground = useCallback(() => {
|
|
dispatch({ type: 'CLEAR_PREVIOUS_BACKGROUND' });
|
|
}, []);
|
|
|
|
const setBackgroundDirectly = useCallback(
|
|
(
|
|
imageUrl: string,
|
|
videoUrl: string,
|
|
embedUrl: string,
|
|
audioUrl: string,
|
|
) => {
|
|
dispatch({
|
|
type: 'SET_BACKGROUND_DIRECTLY',
|
|
payload: { imageUrl, videoUrl, embedUrl, audioUrl },
|
|
});
|
|
},
|
|
[],
|
|
);
|
|
|
|
const resetToIdle = useCallback(() => {
|
|
dispatch({ type: 'RESET_TO_IDLE' });
|
|
}, []);
|
|
|
|
const onVideoBufferStateChange = useCallback((isBuffering: boolean) => {
|
|
dispatch({ type: 'SET_VIDEO_BUFFERING', payload: isBuffering });
|
|
}, []);
|
|
|
|
const markBackgroundReady = useCallback(() => {
|
|
dispatch({ type: 'BACKGROUND_READY' });
|
|
}, []);
|
|
|
|
const startTransition = useCallback(
|
|
(targetPageId: string | null, isBack = false) => {
|
|
dispatch({
|
|
type: 'START_TRANSITION',
|
|
payload: { targetPageId, isBack },
|
|
});
|
|
},
|
|
[],
|
|
);
|
|
|
|
// ============================================================================
|
|
// Effects
|
|
// ============================================================================
|
|
|
|
// Update lastKnownBgUrl for Safari black flash prevention
|
|
useEffect(() => {
|
|
if (state.currentImageUrl) {
|
|
dispatch({
|
|
type: 'UPDATE_LAST_KNOWN_BG',
|
|
payload: state.currentImageUrl,
|
|
});
|
|
}
|
|
}, [state.currentImageUrl]);
|
|
|
|
// Fade completion timer
|
|
useEffect(() => {
|
|
if (state.phase === 'fading_in') {
|
|
const duration = getCrossfadeDuration(
|
|
transitionSettingsRef.current?.durationMs,
|
|
);
|
|
const bufferMs = isSafari() ? 100 : 50;
|
|
|
|
fadeTimerRef.current = setTimeout(() => {
|
|
fadeTimerRef.current = null;
|
|
dispatch({ type: 'FADE_COMPLETED' });
|
|
}, duration + bufferMs);
|
|
}
|
|
|
|
return () => {
|
|
if (fadeTimerRef.current) {
|
|
clearTimeout(fadeTimerRef.current);
|
|
fadeTimerRef.current = null;
|
|
}
|
|
};
|
|
}, [state.phase]);
|
|
|
|
// ============================================================================
|
|
// Derived State
|
|
// ============================================================================
|
|
|
|
const derived = useMemo(
|
|
() => ({
|
|
isLoading: state.phase === 'preparing' || state.phase === 'loading_bg',
|
|
// Show spinner when:
|
|
// - Preparing navigation (resolving URLs)
|
|
// - Loading background (no video transition)
|
|
// - Transition video ended, waiting for background
|
|
// - Video transition active but buffering (video not playing yet)
|
|
showSpinner:
|
|
state.phase === 'preparing' ||
|
|
state.phase === 'loading_bg' ||
|
|
state.phase === 'transition_done' ||
|
|
(state.phase === 'transitioning' && state.isVideoBuffering),
|
|
showElements: state.phase === 'idle' || state.phase === 'fading_in',
|
|
showPreviousOverlay:
|
|
state.phase === 'loading_bg' || state.phase === 'transition_done',
|
|
// Keep transition video overlay visible through entire video transition flow:
|
|
// transitioning → transition_done → loading_bg → fading_in
|
|
// The overlay is only rendered when transitionPreview is set, so this won't
|
|
// affect direct navigation (no video transition).
|
|
showTransitionVideo:
|
|
state.phase === 'transitioning' ||
|
|
state.phase === 'transition_done' ||
|
|
state.phase === 'loading_bg' ||
|
|
state.phase === 'fading_in',
|
|
isFadingIn: state.phase === 'fading_in',
|
|
// Compatibility flags for existing components
|
|
isSwitching: state.phase !== 'idle' && state.phase !== 'fading_in',
|
|
isNewBgReady: state.phase === 'fading_in' || state.phase === 'idle',
|
|
isBackgroundReady: state.phase === 'idle' || state.phase === 'fading_in',
|
|
pendingTransitionComplete: state.phase === 'transition_done',
|
|
}),
|
|
[state.phase, state.isVideoBuffering],
|
|
);
|
|
|
|
// Transition style
|
|
const transitionStyle: React.CSSProperties = useMemo(
|
|
() =>
|
|
({
|
|
'--transition-duration': `${transitionSettings?.durationMs ?? 700}ms`,
|
|
'--transition-easing': transitionSettings?.easing ?? 'ease-in-out',
|
|
'--overlay-color': transitionSettings?.overlayColor ?? '#000000',
|
|
}) as React.CSSProperties,
|
|
[
|
|
transitionSettings?.durationMs,
|
|
transitionSettings?.easing,
|
|
transitionSettings?.overlayColor,
|
|
],
|
|
);
|
|
|
|
return {
|
|
// Current state
|
|
phase: state.phase,
|
|
state,
|
|
|
|
// Current page URLs
|
|
currentImageUrl: state.currentImageUrl,
|
|
currentVideoUrl: state.currentVideoUrl,
|
|
currentEmbedUrl: state.currentEmbedUrl,
|
|
currentAudioUrl: state.currentAudioUrl,
|
|
|
|
// Previous page URLs
|
|
previousImageUrl: state.previousImageUrl,
|
|
previousVideoUrl: state.previousVideoUrl,
|
|
|
|
// Safari black flash prevention
|
|
lastKnownBgUrl: state.lastKnownBgUrl,
|
|
|
|
// Derived states
|
|
...derived,
|
|
|
|
// Video buffering
|
|
isVideoBuffering: state.isVideoBuffering,
|
|
|
|
// Transition style
|
|
transitionStyle,
|
|
|
|
// Actions
|
|
navigateToPage,
|
|
onBackgroundReady,
|
|
onTransitionEnded,
|
|
resetBackgroundReady,
|
|
clearPreviousBackground,
|
|
setBackgroundDirectly,
|
|
resetToIdle,
|
|
onVideoBufferStateChange,
|
|
markBackgroundReady,
|
|
startTransition,
|
|
};
|
|
}
|