855 lines
31 KiB
Markdown
855 lines
31 KiB
Markdown
# Navigation & Smooth Transitions Analysis
|
|
|
|
## Executive Summary
|
|
|
|
Deep analysis of how navigation works in the Tour Builder Platform - from Constructor editing to Runtime Presentations, across Online and Offline modes. This document traces each navigation thread step-by-step to verify that smooth transitions are robust.
|
|
|
|
**Architecture Update:** The page navigation system was refactored from 6+ fragmented hooks into a unified state machine (`usePageNavigationState`). This consolidation:
|
|
- Prevents race conditions via atomic `useReducer` transitions
|
|
- Uses explicit phases instead of boolean flag combinations
|
|
- Computes derived state (`isLoading`, `showSpinner`, etc.) from a single phase value
|
|
- Provides a `PageNavigationContext` for child component access
|
|
|
|
---
|
|
|
|
## 1. NAVIGATION SYSTEM ARCHITECTURE
|
|
|
|
### 1.1 Core Hooks (Shared Between Constructor & RuntimePresentation)
|
|
|
|
| Hook | File | Purpose |
|
|
|------|------|---------|
|
|
| `usePageNavigation` | `hooks/usePageNavigation.ts` | Page navigation state with history tracking, browser-like back behavior |
|
|
| `usePageNavigationState` | `hooks/usePageNavigationState.ts` | **Unified state machine** - URL resolution, switching, fade effects, cleanup (replaces 6 hooks) |
|
|
| `useTransitionPlayback` | `hooks/useTransitionPlayback.ts` | Transition video playback, last frame preservation, pre-generated reverse support |
|
|
| `usePreloadOrchestrator` | `hooks/usePreloadOrchestrator.ts` | Asset preloading with blob URL cache |
|
|
| `useNetworkAware` | `hooks/useNetworkAware.ts` | Network condition monitoring |
|
|
|
|
**Architecture Refactor Note:** The following hooks were consolidated into `usePageNavigationState`:
|
|
- `usePageSwitch` - Page switching with blob URL resolution
|
|
- `useBackgroundState` - Background ready tracking
|
|
- `useBackgroundTransition` - CSS animation-based crossfade
|
|
- `useTransitionCleanup` - Video cleanup coordination
|
|
- `useBackgroundUrls` - URL resolution for display
|
|
- `pageLoadingUtils` - Loading state computation
|
|
|
|
### 1.2 Component Integration
|
|
|
|
```
|
|
RuntimePresentation.tsx / constructor.tsx
|
|
|
|
|
+----------------------------------------------------------------+
|
|
| usePageNavigation |
|
|
| - Unified page history management (MAX_HISTORY_LENGTH = 50) |
|
|
| - Browser-like back: history pops when isBack=true |
|
|
| - Provides getNavigationContext() for resolveNavigationTarget()|
|
|
| - applyPageSelection(targetPageId, isBack) handles history |
|
|
+----------------------------------------------------------------+
|
|
|
|
|
+----------------------------------------------------------------+
|
|
| usePreloadOrchestrator |
|
|
| - Preloads assets for current + neighbor pages |
|
|
| - Provides getReadyBlobUrl() for O(1) instant lookup |
|
|
| - Provides getCachedBlobUrl() for Cache API lookup |
|
|
+----------------------------------------------------------------+
|
|
|
|
|
+----------------------------------------------------------------+
|
|
| usePageNavigationState (UNIFIED STATE MACHINE) |
|
|
| - navigateToPage() initiates navigation with URL resolution |
|
|
| - Tracks phase: idle → preparing → transitioning/loading_bg |
|
|
| → transition_done → fading_in → idle |
|
|
| - Maintains currentImageUrl, previousImageUrl for overlay |
|
|
| - Provides derived state: isLoading, showSpinner, showElements |
|
|
| - onBackgroundReady() callback for CanvasBackground |
|
|
| - onTransitionEnded() callback for video transition completion |
|
|
| - Atomic state via useReducer (prevents race conditions) |
|
|
+----------------------------------------------------------------+
|
|
|
|
|
+----------------------------------------------------------------+
|
|
| useTransitionPlayback |
|
|
| - Plays transition videos with presigned URL fallback |
|
|
| - Preserves last frame until onComplete callback |
|
|
| - Uses pre-generated reversed videos for back navigation |
|
|
| - Passes isBack to onComplete for proper history management |
|
|
+----------------------------------------------------------------+
|
|
```
|
|
|
|
**Note:** `usePageNavigationState` consolidated 6 hooks into a single state machine:
|
|
- URL resolution, switching, overlay management (from `usePageSwitch`)
|
|
- Background ready tracking (from `useBackgroundState`)
|
|
- Fade-out coordination (from `useBackgroundTransition`)
|
|
- Video cleanup (from `useTransitionCleanup`)
|
|
- URL resolution for display (from `useBackgroundUrls`)
|
|
- Loading state computation (from `pageLoadingUtils`)
|
|
|
|
---
|
|
|
|
## 2. NAVIGATION WITHOUT TRANSITION VIDEO
|
|
|
|
### 2.1 Flow Overview
|
|
|
|
```
|
|
User clicks navigation element (no transitionVideoUrl)
|
|
|
|
|
handleElementClick / navigateToPage
|
|
|
|
|
Direct navigation - no transition video
|
|
```
|
|
|
|
### 2.2 Step-by-Step Thread Analysis
|
|
|
|
**Thread 1: Navigation Trigger**
|
|
```
|
|
handleElementClick(element) [RuntimePresentation:309-340]
|
|
|
|
|
resolveNavigationTarget(element, pages) [lib/navigationHelpers.ts]
|
|
+-- Extract targetPageSlug from element
|
|
+-- Find target page in pages array
|
|
+-- Return { pageId, transitionVideoUrl, isBack }
|
|
|
|
|
navigateToPage(targetPageId, undefined, false) [No transition video]
|
|
```
|
|
|
|
**Thread 2: Page Switch Initiation**
|
|
```
|
|
navigateToPage(targetPageId, undefined, isBack) [RuntimePresentation:285-315]
|
|
|
|
|
No transitionVideoUrl -> Direct navigation path
|
|
|
|
|
setIsBackgroundReady(false) [Mark: waiting for new bg]
|
|
lastInitializedPageIdRef.current = targetPageId
|
|
|
|
|
await pageSwitch.switchToPage(targetPage, () => {
|
|
applyPageSelection(targetPageId, isBack); [usePageNavigation hook]
|
|
// - If isBack=true AND target matches previous: history pops
|
|
// - Otherwise: history appends (trimmed to MAX_HISTORY_LENGTH=50)
|
|
});
|
|
```
|
|
|
|
**Thread 3: URL Resolution & Overlay Setup**
|
|
```
|
|
switchToPage(targetPage, onSwitched) [usePageSwitch:372-419]
|
|
|
|
|
Save current as previous for overlay:
|
|
setPreviousBgImageUrl(currentBgImageUrlRef.current)
|
|
|
|
|
setIsSwitching(true)
|
|
setIsNewBgReady(false)
|
|
|
|
|
Resolve URLs in parallel (prefer preloaded blob URLs):
|
|
[imageUrl, videoUrl, audioUrl] = await Promise.all([
|
|
resolveToDisplayUrl(background_image_url),
|
|
resolveMediaUrl(background_video_url),
|
|
resolveMediaUrl(background_audio_url),
|
|
])
|
|
```
|
|
|
|
**Thread 4: Blob URL Resolution Priority**
|
|
```
|
|
resolveToDisplayUrl(storagePath) [usePageSwitch:237-306]
|
|
|
|
|
1. getReadyBlobUrl(storagePath) [O(1) Map lookup - same session]
|
|
-> If found: return immediately (instant)
|
|
|
|
|
2. getCachedBlobUrl(storagePath) [Cache API lookup ~5ms]
|
|
-> If found: create blob URL, return
|
|
|
|
|
3. resolveAssetPlaybackUrl(storagePath) [Resolve to playback URL]
|
|
|
|
|
4. getReadyBlobUrl(resolvedUrl) [Fallback lookup]
|
|
-> If found: return
|
|
|
|
|
5. getCachedBlobUrl(resolvedUrl) [Fallback Cache API]
|
|
-> If found: return
|
|
|
|
|
6. loadImageWithFallback(originalUrl, storageKey) [Network fetch]
|
|
+-- Try presigned URL
|
|
+-- On CORS failure: markPresignedUrlFailed()
|
|
+-- Retry with proxy URL: /api/file/download
|
|
```
|
|
|
|
**Thread 5: Background Display & Overlay**
|
|
```
|
|
After URL resolution:
|
|
setCurrentBgImageUrl(imageUrl)
|
|
setCurrentBgVideoUrl(videoUrl)
|
|
setCurrentBgAudioUrl(audioUrl)
|
|
onSwitched() [Notify caller]
|
|
|
|
|
For blob URLs (local data):
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => {
|
|
setIsNewBgReady(true) [Mark ready after 2 frames]
|
|
})
|
|
})
|
|
|
|
|
For remote images:
|
|
Wait for Image onLoad callback
|
|
-> markBackgroundReady()
|
|
```
|
|
|
|
**Thread 6: Render - Previous Background Overlay**
|
|
```
|
|
RuntimePresentation render [lines 517-528]
|
|
|
|
|
{pageSwitch.previousBgImageUrl &&
|
|
pageSwitch.isSwitching &&
|
|
!pageSwitch.isNewBgReady && (
|
|
<div className="absolute inset-0 z-10"
|
|
style={{ backgroundImage: `url("${previousBgImageUrl}")` }}
|
|
/>
|
|
)}
|
|
|
|
|
Previous page background stays visible until new one is ready!
|
|
```
|
|
|
|
**Thread 7: Overlay Clearing**
|
|
```
|
|
useBackgroundTransition effect [lines 144-158]
|
|
|
|
|
When: isSwitching && isNewBgReady && previousBgImageUrl
|
|
|
|
|
pageSwitch.clearPreviousBackground()
|
|
+-- setPreviousBgImageUrl('')
|
|
+-- setIsSwitching(false)
|
|
+-- revokeBlobUrl(prevUrl) [Memory cleanup]
|
|
```
|
|
|
|
### 2.3 Summary: Navigation WITHOUT Transition Video
|
|
|
|
| Phase | What's Visible | State |
|
|
|-------|----------------|-------|
|
|
| 1. Click | Current page | `isSwitching: false` |
|
|
| 2. Switch starts | Previous bg (z-0) fading out, New content (z-1) fading in | `isSwitching: true, isFadingIn: true` |
|
|
| 3. Crossfade | Both visible with CSS animation | `animate-crossfade-out` / `animate-crossfade-in` |
|
|
| 4. Animation ends | New page fully visible | `onAnimationEnd` fires, `isFadingIn: false` |
|
|
| 5. Cleanup | New page only | `clearPreviousBackground()` called |
|
|
|
|
### 2.4 CSS Animation-Based Crossfade
|
|
|
|
The crossfade effect uses CSS animations instead of JS-controlled transitions:
|
|
|
|
**CSS Variables (main.css) - Single Source of Truth:**
|
|
```css
|
|
:root {
|
|
--crossfade-duration: 700ms;
|
|
--crossfade-easing: cubic-bezier(0.4, 0, 0.2, 1);
|
|
}
|
|
```
|
|
|
|
**CSS Classes (main.css):**
|
|
```css
|
|
.animate-crossfade-in {
|
|
animation: page-crossfade-in var(--crossfade-duration, 700ms) var(--crossfade-easing) forwards;
|
|
}
|
|
|
|
.animate-crossfade-out {
|
|
animation: page-crossfade-out var(--crossfade-duration, 700ms) var(--crossfade-easing) forwards;
|
|
}
|
|
```
|
|
|
|
**Easing Characteristics:**
|
|
- `cubic-bezier(0.4, 0, 0.2, 1)` - Material Design standard
|
|
- Slow start prevents abrupt appearance
|
|
- Smooth acceleration and soft landing
|
|
|
|
**Why CSS Animations (not transitions):**
|
|
- CSS animations always play when the class is added
|
|
- Immune to React's render batching that can skip transition states
|
|
- `onAnimationEnd` event provides reliable completion detection
|
|
- Duration controlled via CSS variable (single source of truth)
|
|
- JS can read duration via `getCrossfadeDuration()` utility
|
|
|
|
**Hook Usage:**
|
|
```typescript
|
|
const { isFadingIn, onFadeInAnimationEnd } = useBackgroundTransition({
|
|
pageSwitch,
|
|
fadeIn: { hasActiveTransition: false },
|
|
});
|
|
|
|
// In JSX:
|
|
<div
|
|
className={`absolute inset-0 z-1 ${isFadingIn ? 'animate-crossfade-in' : ''}`}
|
|
onAnimationEnd={onFadeInAnimationEnd}
|
|
>
|
|
{/* New page content */}
|
|
</div>
|
|
|
|
// Previous background:
|
|
{previousBgImageUrl && isFadingIn && (
|
|
<div className={`absolute inset-0 z-0 ${isFadingIn ? 'animate-crossfade-out' : ''}`}>
|
|
...
|
|
</div>
|
|
)}
|
|
```
|
|
|
|
**ROBUSTNESS CHECK: Previous page renders while next page background and UI elements not ready**
|
|
|
|
---
|
|
|
|
## 3. NAVIGATION WITH TRANSITION VIDEO
|
|
|
|
### 3.1 Flow Overview
|
|
|
|
```
|
|
User clicks navigation element (has transitionVideoUrl)
|
|
|
|
|
handleElementClick / navigateToPage
|
|
|
|
|
Start transition video -> Play -> Keep last frame -> Switch page -> Fade out
|
|
```
|
|
|
|
### 3.2 Step-by-Step Thread Analysis
|
|
|
|
**Thread 1: Transition Initiation**
|
|
```
|
|
navigateToPage(targetPageId, transitionVideoUrl, isBack) [RuntimePresentation:272-307]
|
|
|
|
|
Has transitionVideoUrl -> Transition path
|
|
|
|
|
resetFadeOut() [Clear previous fade state]
|
|
setPendingTransitionComplete(false)
|
|
|
|
|
setTransitionPreview({
|
|
targetPageId,
|
|
videoUrl: resolveAssetPlaybackUrl(transitionVideoUrl),
|
|
storageKey: transitionVideoUrl, [Raw path for cache lookup]
|
|
isReverse: isBack,
|
|
})
|
|
```
|
|
|
|
**Thread 2: Transition Video Source Resolution**
|
|
```
|
|
useTransitionPlayback effect triggers [lines 355-771]
|
|
|
|
|
resolvePlayableSource() [lines 431-540]
|
|
|
|
|
1. getReadyBlobUrl(storageKey) [O(1) lookup by storage path]
|
|
-> If found: use cached blob URL
|
|
|
|
|
2. getCachedBlobUrl(storageKey) [Cache API by storage path]
|
|
-> If found: create blob URL, cache it
|
|
|
|
|
3. Check lastLoadedBlobUrlRef (reuse same session)
|
|
|
|
|
4. getReadyBlobUrl(sourceUrl) [Lookup by resolved URL]
|
|
|
|
|
5. getCachedBlobUrl(sourceUrl) [Cache API by resolved URL]
|
|
|
|
|
6. Network fetch as blob [Fallback - full download]
|
|
-> axios.get(requestUrl, { responseType: 'blob' })
|
|
-> URL.createObjectURL(blob)
|
|
```
|
|
|
|
**Thread 3: Video Playback**
|
|
```
|
|
loadAndPlay() [lines 542-598]
|
|
|
|
|
video.src = playableSourceUrl
|
|
video.currentTime = 0
|
|
video.load()
|
|
attemptPlay()
|
|
|
|
|
onPlaying event fires [lines 633-675]
|
|
|
|
|
setPhase('playing')
|
|
|
|
|
scheduleFinishByDuration(durationSec) [lines 405-421]
|
|
+-- finishBeforeEndMs = 50 [Finish 50ms BEFORE end]
|
|
+-- finishMs = durationSec * 1000 - 50
|
|
+-- setTimeout(() => finishPlayback('duration-timer'), finishMs)
|
|
```
|
|
|
|
**Thread 4: Last Frame Preservation (CRITICAL)**
|
|
```
|
|
finishPlayback(reason) [lines 244-293]
|
|
|
|
|
didFinishRef.current = true
|
|
clearTimers()
|
|
|
|
|
video.pause()
|
|
|
|
|
*** LAST FRAME PRESERVATION ***
|
|
if (video.duration && Number.isFinite(video.duration) &&
|
|
video.currentTime >= video.duration - 0.1) {
|
|
video.currentTime = Math.max(0, video.duration - 0.05)
|
|
}
|
|
|
|
|
Explanation:
|
|
- Some browsers show BLACK after 'ended' event
|
|
- Seeking to duration - 0.05 keeps last visible frame
|
|
- This ensures smooth visual continuity
|
|
|
|
|
setPhase('finishing')
|
|
|
|
|
Optional: waitForImages() for target page pre-decode
|
|
|
|
|
setPhase('completed')
|
|
onCompleteRef.current(targetPageId)
|
|
```
|
|
|
|
**Thread 5: Page Switch After Transition**
|
|
```
|
|
onComplete callback (targetPageId, isBack) [RuntimePresentation:146-166]
|
|
|
|
|
if (targetPageId) {
|
|
const targetPage = pages.find(p => p.id === targetPageId)
|
|
lastInitializedPageIdRef.current = targetPageId
|
|
|
|
|
await pageSwitch.switchToPage(targetPage, () => {
|
|
applyPageSelection(targetPageId, isBack ?? false) [usePageNavigation]
|
|
// Proper history management: pops on back, appends on forward
|
|
})
|
|
|
|
|
setIsBackgroundReady(false)
|
|
setPendingTransitionComplete(true) [Signal: waiting for bg ready]
|
|
}
|
|
```
|
|
|
|
**Thread 6: Background Image Load Detection**
|
|
```
|
|
Background image element in render [lines 476-514]
|
|
|
|
|
<img onLoad={() => {
|
|
setIsBackgroundReady(true)
|
|
pageSwitch.markBackgroundReady()
|
|
}} />
|
|
|
|
|
Or for video backgrounds:
|
|
useEffect auto-marks ready when no image or has video
|
|
```
|
|
|
|
**Thread 7: Video Transition Overlay Removal (Instant, With rAF Delay)**
|
|
```
|
|
TransitionPreviewOverlay.tsx
|
|
|
|
|
When: isFadingOut=true passed from parent
|
|
|
|
|
useEffect triggers:
|
|
if (isFadingOut) {
|
|
requestAnimationFrame(() => {
|
|
setShouldHide(true) [After one paint frame]
|
|
})
|
|
}
|
|
|
|
|
Container opacity:
|
|
- 0 during initial buffering (before first frame)
|
|
- 0 when shouldHide=true (after rAF delay - bg is painted)
|
|
- 1 otherwise (video playing)
|
|
|
|
|
transition: 'none' when hiding [NO CSS transition - instant hide]
|
|
```
|
|
|
|
**Thread 8: Render - Transition Overlay Visibility**
|
|
```
|
|
TransitionPreviewOverlay component [TransitionPreviewOverlay.tsx]
|
|
|
|
|
Container opacity controlled by:
|
|
- isBuffering && !isVideoReady → 0 (initial buffering)
|
|
- shouldHide → 0 (after rAF delay, new bg painted)
|
|
- otherwise → opacity prop or 1
|
|
|
|
|
transition: useTransition ? '150ms' : 'none'
|
|
- 150ms ONLY for initial buffering fade-in
|
|
- 'none' when hiding (instant hide since last video frame = new bg)
|
|
|
|
|
{transitionPreview && (
|
|
<TransitionPreviewOverlay
|
|
isBuffering={transitionPhase === 'preparing' || isBuffering}
|
|
isFadingOut={pendingTransitionComplete && isBackgroundReady}
|
|
/>
|
|
)}
|
|
|
|
|
Container hidden while: preparing, buffering (old page visible through)
|
|
Container visible when: video ready to play (instant appearance)
|
|
Overlay removed: instantly after rAF (ensures bg is painted first)
|
|
```
|
|
|
|
**Key Design Decision - Instant Hide with rAF Delay:**
|
|
- Video itself IS the transition effect
|
|
- First frame = old page background
|
|
- Last frame = new page background
|
|
- Wait one `requestAnimationFrame` after `isFadingOut=true`
|
|
- This ensures new background is painted before hiding overlay
|
|
- Overlay hides **instantly** (no CSS transition) - seamless since last frame = new bg
|
|
- No visual discontinuity or flash
|
|
|
|
### 3.3 Summary: Navigation WITH Transition Video
|
|
|
|
| Phase | What's Visible | State |
|
|
|-------|----------------|-------|
|
|
| 1. Click | Current page | `transitionPhase: 'idle'` |
|
|
| 2. Preparing | Current page (container hidden) | `transitionPhase: 'preparing', isBuffering: true` |
|
|
| 3. Playing | Transition video | `transitionPhase: 'playing'` |
|
|
| 4. Finishing | **Last frame of video** | `transitionPhase: 'finishing'` |
|
|
| 5. Completed | **Last frame of video** | `pendingTransitionComplete: true` |
|
|
| 6. Bg loading | **Last frame of video** (z-50) | New bg loading underneath |
|
|
| 7. Bg ready | **Instant switch** | `setTransitionPreview(null)` (no fade!) |
|
|
| 8. Done | New page only | Overlay removed |
|
|
|
|
**Key Changes from Previous Implementation:**
|
|
- Container hidden during buffering (no black flash)
|
|
- No fade-out animation for video transitions
|
|
- Instant overlay removal when background ready
|
|
- Video itself is the transition (first frame = old, last frame = new)
|
|
|
|
**ROBUSTNESS CHECK: Last video frame renders while next page background and UI elements not ready**
|
|
|
|
### 3.4 PreviousBackgroundOverlay (Simplified)
|
|
|
|
The `PreviousBackgroundOverlay` component was simplified to remove all fade logic:
|
|
|
|
```tsx
|
|
// PreviousBackgroundOverlay.tsx - Simplified implementation
|
|
const PreviousBackgroundOverlay = ({
|
|
imageUrl,
|
|
isSwitching = false,
|
|
isNewBgReady = false,
|
|
className = '',
|
|
}) => {
|
|
// Simple render logic: show while switching AND new bg not ready
|
|
const shouldRender = isSwitching && !isNewBgReady && !!imageUrl;
|
|
|
|
if (!shouldRender) return null;
|
|
|
|
return (
|
|
<div
|
|
className={`pointer-events-none absolute inset-0 z-2 ${className}`}
|
|
style={{
|
|
backgroundImage: `url("${imageUrl}")`,
|
|
backgroundSize: 'contain',
|
|
backgroundPosition: 'center',
|
|
backgroundRepeat: 'no-repeat',
|
|
}}
|
|
/>
|
|
);
|
|
};
|
|
```
|
|
|
|
**Changes from previous implementation:**
|
|
- Removed all CSS transition/fade logic
|
|
- Removed timeout fallbacks
|
|
- Removed transition event handlers
|
|
- Simplified to pure show/hide based on props
|
|
- Hides **instantly** when `isNewBgReady=true`
|
|
|
|
### 3.5 scheduleAfterPaint Helper
|
|
|
|
The `CanvasBackground.tsx` uses a `scheduleAfterPaint` helper for video first-frame detection:
|
|
|
|
```typescript
|
|
/**
|
|
* Schedule a callback to run after the next browser paint.
|
|
* Uses double rAF pattern: first rAF schedules for next frame,
|
|
* second rAF ensures the frame has actually been committed.
|
|
*/
|
|
const scheduleAfterPaint = (callback: () => void): void => {
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(callback);
|
|
});
|
|
};
|
|
```
|
|
|
|
**Usage in video first-frame detection:**
|
|
```typescript
|
|
// Using requestVideoFrameCallback for accurate first-frame detection
|
|
if ('requestVideoFrameCallback' in video) {
|
|
video.requestVideoFrameCallback(() => {
|
|
clearTimeout(timeout);
|
|
scheduleAfterPaint(() => {
|
|
reportVideoReady(); // Called after frame is painted
|
|
});
|
|
});
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 4. ONLINE VS OFFLINE MODE
|
|
|
|
### 4.1 Network Awareness
|
|
|
|
```
|
|
useNetworkAware hook [hooks/useNetworkAware.ts]
|
|
|
|
|
Monitors:
|
|
- navigator.onLine
|
|
- connection.effectiveType (slow-2g, 2g, 3g, 4g)
|
|
- connection.downlink (Mbps)
|
|
- connection.rtt (ms)
|
|
- connection.saveData (boolean)
|
|
|
|
|
Returns:
|
|
- networkInfo.isOnline
|
|
- recommendedConcurrency (1-3 based on connection)
|
|
- shouldPreloadAggressively
|
|
- suggestOfflineMode
|
|
```
|
|
|
|
### 4.2 Preload Orchestrator - Online Mode
|
|
|
|
```
|
|
processQueue() [usePreloadOrchestrator:355-485]
|
|
|
|
|
Guard: if (!networkInfo.isOnline) return [Skip if offline]
|
|
|
|
|
While queue has items && activeDownloads < recommendedConcurrency:
|
|
+-- Check preloadedUrls.has(url) -> skip if already loaded
|
|
+-- Check StorageManager.hasAsset(url) -> skip if cached
|
|
| +-- If cached: createReadyBlobUrl() from cache
|
|
|
|
|
preloadWithProgress(url, jobId, assetId)
|
|
+-- fetch(url) with streaming progress
|
|
+-- Store in Cache API
|
|
+-- createReadyBlobUrl()
|
|
```
|
|
|
|
### 4.3 Preload Orchestrator - Offline Mode
|
|
|
|
```
|
|
processQueue() [usePreloadOrchestrator:355-485]
|
|
|
|
|
Guard: if (!networkInfo.isOnline) return [Don't process queue when offline]
|
|
|
|
|
BUT: isUrlCached check still works!
|
|
+-- StorageManager.hasAsset(url)
|
|
| +-- Check IndexedDB: OfflineDbManager.hasAssetByUrl(url)
|
|
| +-- Check Cache API: caches.open().match(url)
|
|
|
|
|
Assets already downloaded are still accessible!
|
|
```
|
|
|
|
### 4.4 Navigation Flow - Same for Both Modes
|
|
|
|
The navigation flow is **identical** for online and offline modes:
|
|
|
|
1. **URL Resolution** - `resolveToDisplayUrl()` / `resolvePlayableSource()`
|
|
- Tries `getReadyBlobUrl()` first (in-memory Map - O(1))
|
|
- Tries `getCachedBlobUrl()` second (Cache API + IndexedDB)
|
|
- Falls back to network only if not cached
|
|
|
|
2. **Blob URL Priority** - Cache is always checked first
|
|
```
|
|
Online: blob URL (cache) > presigned URL > proxy URL
|
|
Offline: blob URL (cache) > FAIL if not cached
|
|
```
|
|
|
|
3. **Transition Video** - Same playback logic
|
|
- If cached: plays from blob URL (instant)
|
|
- If not cached: depends on network availability
|
|
|
|
### 4.5 Offline Storage Hierarchy
|
|
|
|
```
|
|
Asset lookup order (StorageManager)
|
|
|
|
|
1. IndexedDB (files >= 5MB)
|
|
+-- OfflineDbManager.getAssetByUrl(url)
|
|
|
|
|
2. Cache API (files < 5MB)
|
|
+-- caches.open('tour-builder-assets-v1').match(url)
|
|
|
|
|
3. Network (online only)
|
|
+-- fetch(presignedUrl || proxyUrl)
|
|
```
|
|
|
|
### 4.6 Summary: Online vs Offline Differences
|
|
|
|
| Aspect | Online Mode | Offline Mode |
|
|
|--------|-------------|--------------|
|
|
| Preload queue | Active processing | Paused (no new downloads) |
|
|
| Asset resolution | Cache -> Network | Cache only |
|
|
| Transition video | Cache -> Network | Cache only |
|
|
| Background image | Cache -> Network | Cache only |
|
|
| Navigation flow | Same | Same |
|
|
| Overlay behavior | Same | Same |
|
|
| Last frame handling | Same | Same |
|
|
|
|
**ROBUSTNESS CHECK: Same navigation flow works in both online and offline modes**
|
|
|
|
---
|
|
|
|
## 5. CONSTRUCTOR VS RUNTIME PRESENTATION
|
|
|
|
### 5.1 Shared Components
|
|
|
|
Both use the **same hooks**:
|
|
- `usePageNavigationState` - Unified state machine for URL resolution, overlay management, fade effects
|
|
- `useTransitionPlayback` - Video playback, last frame preservation
|
|
- `usePreloadOrchestrator` - Asset preloading
|
|
- `usePageNavigation` - History tracking
|
|
|
|
### 5.2 Key Differences
|
|
|
|
| Aspect | Constructor | RuntimePresentation |
|
|
|--------|-------------|---------------------|
|
|
| State management | `usePageNavigationState` (unified) | `usePageNavigationState` (unified) |
|
|
| Crossfade animation | Yes (project transition settings) | Yes (project transition settings) |
|
|
| Crossfade easing | From `transitionSettings.fadeEasing` | From `transitionSettings.fadeEasing` |
|
|
| Transition video fade-out | **No (instant removal)** | **No (instant removal)** |
|
|
| Video overlay hiding | Container hidden while buffering | Container hidden while buffering |
|
|
| Background transition config | Via `usePageNavigationState` | Via `usePageNavigationState` |
|
|
| Overlay component | `TransitionPreviewOverlay` | `TransitionPreviewOverlay` |
|
|
| Post-transition cleanup | Via `onTransitionEnded` + `onBackgroundReady` | Via `onTransitionEnded` + `onBackgroundReady` |
|
|
| Animation end detection | CSS `onAnimationEnd` event | CSS `onAnimationEnd` event |
|
|
| Edit mode support | Direct background updates (`setBackgroundDirectly`) | N/A |
|
|
|
|
### 5.3 Constructor Transition Flow
|
|
|
|
```
|
|
onComplete callback (targetPageId, isBack) [constructor.tsx]
|
|
|
|
|
if (targetPageId) {
|
|
await navState.navigateToPage(targetPage, {
|
|
hasTransition: false,
|
|
isBack: isBack ?? false,
|
|
onSwitched: () => applyPageSelection(targetPage.id, isBack ?? false)
|
|
})
|
|
// navigateToPage handles all state transitions atomically
|
|
// usePageNavigation handles history (pops on back, appends on forward)
|
|
clearSelection()
|
|
setSelectedMenuItem('none')
|
|
}
|
|
|
|
|
navState.onTransitionEnded() is called when transition video completes
|
|
|
|
|
Video cleanup happens via effect watching pendingTransitionComplete + isBackgroundReady
|
|
```
|
|
|
|
Both constructor and runtime presentation use the same `usePageNavigationState` hook for unified state management. The `usePageNavigation` hook handles browser-like history behavior.
|
|
|
|
---
|
|
|
|
## 6. ROBUSTNESS VERIFICATION
|
|
|
|
### 6.1 Scenario: Navigation WITHOUT Transition (Direct)
|
|
|
|
| Step | What Happens | Verified |
|
|
|------|--------------|----------|
|
|
| 1 | User clicks navigation element | YES |
|
|
| 2 | Previous bg saved to `previousBgImageUrl` | YES |
|
|
| 3 | `isSwitching: true, isNewBgReady: false` | YES |
|
|
| 4 | URL resolution (blob -> cache -> network) | YES |
|
|
| 5 | Previous bg overlay renders (z-10) | YES |
|
|
| 6 | New bg loads underneath (z-1) | YES |
|
|
| 7 | Image onLoad -> `markBackgroundReady()` | YES |
|
|
| 8 | `clearPreviousBackground()` removes overlay | YES |
|
|
|
|
### 6.2 Scenario: Navigation WITH Transition Video
|
|
|
|
| Step | What Happens | Verified |
|
|
|------|--------------|----------|
|
|
| 1 | User clicks navigation element | YES |
|
|
| 2 | `setTransitionPreview()` triggers hook | YES |
|
|
| 3 | Video source resolved (blob -> cache -> network) | YES |
|
|
| 4 | Video plays (opacity: 1) | YES |
|
|
| 5 | Timer fires 50ms before video ends | YES |
|
|
| 6 | `finishPlayback()` seeks to duration - 0.05 | YES |
|
|
| 7 | Last frame stays visible | YES |
|
|
| 8 | `onComplete()` triggers page switch | YES |
|
|
| 9 | New bg loads underneath overlay | YES |
|
|
| 10 | Image onLoad -> `isBackgroundReady: true` | YES |
|
|
| 11 | Overlay fades out (opacity: 0 transition) | YES |
|
|
| 12 | Cleanup: remove video src, clear state | YES |
|
|
|
|
### 6.3 Scenario: Reverse Navigation (Back)
|
|
|
|
| Step | What Happens | Verified |
|
|
|------|--------------|----------|
|
|
| 1 | User clicks back navigation | YES |
|
|
| 2 | `isBack: true` set, uses `reverseVideoUrl` | YES |
|
|
| 3 | Pre-generated reversed video loaded | YES |
|
|
| 4 | Forward playback of reversed video | YES |
|
|
| 5 | Video ends normally | YES |
|
|
| 6 | `onComplete()` -> `finishPlayback()` | YES |
|
|
| 7 | Same cleanup flow as forward | YES |
|
|
|
|
**Note:** Reversed videos are pre-generated server-side using FFmpeg when pages are saved. This eliminates client-side frame-stepping and ensures professional audio/video synchronization.
|
|
|
|
### 6.4 Scenario: Offline Mode
|
|
|
|
| Step | What Happens | Verified |
|
|
|------|--------------|----------|
|
|
| 1 | `networkInfo.isOnline: false` | YES |
|
|
| 2 | Preload queue paused | YES |
|
|
| 3 | Navigation clicked | YES |
|
|
| 4 | URL resolved from cache (IndexedDB/Cache API) | YES |
|
|
| 5 | If cached: works identically to online | YES |
|
|
| 6 | If not cached: fails gracefully | YES |
|
|
|
|
---
|
|
|
|
## 7. CONCLUSION
|
|
|
|
The navigation and smooth transitions system is **robust and comprehensive**:
|
|
|
|
### 7.1 Key Strengths
|
|
|
|
1. **Unified State Machine** - `usePageNavigationState` consolidates 6 hooks into atomic state transitions
|
|
2. **Race Condition Prevention** - `useReducer` ensures state updates are atomic (no batching issues)
|
|
3. **Explicit Phase Tracking** - Single `phase` value (idle, preparing, transitioning, etc.) instead of multiple boolean flags
|
|
4. **Derived State via useMemo** - `isLoading`, `showSpinner`, `showElements` computed from phase
|
|
5. **Unified History Management** - `usePageNavigation` hook handles history in both components
|
|
6. **Browser-Like Back Navigation** - History pops when navigating back (via `applyPageSelection(id, isBack=true)`)
|
|
7. **History Limit** - `MAX_HISTORY_LENGTH=50` prevents unbounded growth in long sessions
|
|
8. **Blob URL Priority** - Always checks cache before network
|
|
9. **CSS Animation Crossfade** - Uses CSS animations (not transitions) for reliable visual effects
|
|
10. **Smooth Easing** - Project-configurable transition easing
|
|
11. **Event-Based Completion** - `onAnimationEnd` event replaces timer-based duration tracking
|
|
12. **Previous Background Overlay** - Keeps previous page visible during direct navigation
|
|
13. **Last Frame Preservation** - Seeks to duration - 0.05 to avoid black frame
|
|
14. **Timer-Based Finish** - Finishes 50ms before video ends to catch last frame
|
|
15. **No Video Fade** - Video transitions end instantly without fade-out animation
|
|
16. **Container Hidden While Buffering** - Prevents black flash before video ready
|
|
17. **Offline Resilience** - Same flow works when offline (if assets cached)
|
|
18. **Context Provider** - `PageNavigationContext` allows child components to access navigation state
|
|
|
|
### 7.2 No Gaps Identified
|
|
|
|
- **State consistency**: Single `useReducer` prevents race conditions from async batching
|
|
- **Invalid states impossible**: State machine phases prevent flag combinations like `isLoading && showElements`
|
|
- **Pages without transition**: Previous page renders while next loads (crossfade animation)
|
|
- **Pages with transition**: Last frame renders while next loads (instant switch when ready)
|
|
- **Video overlay**: Container hidden while buffering (no black flash)
|
|
- **Online mode**: Full preloading and network fallback
|
|
- **Offline mode**: Graceful cache-first behavior
|
|
- **Constructor**: Same `usePageNavigationState` hook as RuntimePresentation
|
|
- **Edit mode**: Direct background updates via `setBackgroundDirectly()` bypass navigation flow
|
|
|
|
---
|
|
|
|
## 8. CRITICAL CODE LOCATIONS
|
|
|
|
| Feature | File | Lines |
|
|
|---------|------|-------|
|
|
| Page navigation with history | `usePageNavigation.ts` | 62-195 |
|
|
| History limit (MAX=50) | `usePageNavigation.ts` | 8 |
|
|
| Browser-like history pop | `usePageNavigation.ts` | 120-127 |
|
|
| Navigation context | `usePageNavigation.ts` | 164-170 |
|
|
| **Unified state machine** | `usePageNavigationState.ts` | Full file (~520 LOC) |
|
|
| State machine reducer | `usePageNavigationState.ts` | 80-180 |
|
|
| Navigation phases | `usePageNavigationState.ts` | 30-45 |
|
|
| URL resolution | `usePageNavigationState.ts` | 200-280 |
|
|
| Derived state (isLoading, etc.) | `usePageNavigationState.ts` | 350-400 |
|
|
| onBackgroundReady callback | `usePageNavigationState.ts` | 420-450 |
|
|
| Transition video playback | `useTransitionPlayback.ts` | 355-771 |
|
|
| Last frame preservation | `useTransitionPlayback.ts` | 251-262 |
|
|
| Timer-based finish | `useTransitionPlayback.ts` | 405-421 |
|
|
| isBack in onComplete | `useTransitionPlayback.ts` | 290-296 |
|
|
| CSS variables (duration, easing) | `css/main.css` | 15-20 |
|
|
| CSS animations | `css/main.css` | 42-120 |
|
|
| getCrossfadeDuration utility | `lib/browserUtils.ts` | 14-32 |
|
|
| TransitionPreviewOverlay | `Constructor/TransitionPreviewOverlay.tsx` | 1-75 |
|
|
| PageNavigationContext provider | `context/PageNavigationContext.tsx` | Full file (~120 LOC) |
|
|
| Network awareness | `useNetworkAware.ts` | 68-163 |
|
|
| Server-side reversal | `backend/src/services/videoProcessing.ts` | N/A |
|
|
| Preload queue guard | `usePreloadOrchestrator.ts` | 355-358 |
|
|
| Navigation helpers | `lib/navigationHelpers.ts` | 1-120 |
|
|
|
|
**Deleted Files (consolidated into `usePageNavigationState.ts`):**
|
|
- `usePageSwitch.ts` (~475 LOC)
|
|
- `useBackgroundState.ts` (~156 LOC)
|
|
- `useBackgroundTransition.ts` (~164 LOC)
|
|
- `useTransitionCleanup.ts` (~108 LOC)
|
|
- `useBackgroundUrls.ts` (~104 LOC)
|
|
- `lib/pageLoadingUtils.ts` (~76 LOC)
|