39948-vm/frontend/src/hooks/usePageNavigationState.ts
Dmitri 6413c7bdf0 implemented:
- 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
-
2026-06-15 07:50:45 +02:00

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,
};
}