Memory Viewer Modal
System Intent
- What this is: A full-screen modal component that displays a paginated horizontal list of memory items (visual or audio). Audio memories render an
AudioPlayer sub-component stacked above a TranscriptDisplay sub-component; visual memories render a VideoPlayer sub-component that polls the video API every 2 s (up to 5 min) until processing completes. Both players provide custom play/pause, scrubbing, and mute controls. Only the memory at the active (visible) index is rendered as a player — inactive items show a thumbnail or placeholder. The modal uses Pressable to toggle the video controls overlay visibility on tap. For audio items the layout is responsive: on screens narrower than 600 px the audio player and transcript are stacked vertically (player on top, transcript below); on screens 600 px and wider the container uses flex proportions of 2 (audio) to 3 (transcript).
Mermaid Diagram
flowchart TD
Open[MemoryViewerModal visible=true] --> FlatList[FlatList horizontal pagingEnabled]
FlatList -->|item.type = audio and activeIndex| AudioBlock[audioWithTranscriptContainer]
AudioBlock --> AudioPlayer[AudioPlayer component]
AudioBlock --> TranscriptDisplay[TranscriptDisplay component]
FlatList -->|item.type = visual and activeIndex| VideoPlayer[VideoPlayer component]
FlatList -->|inactive or thumbnail only| Thumbnail[Image / placeholder]
VideoPlayer -->|getOrFetchMemoryVideo| API[/memories/video endpoint]
API -->|processing_status = pending| Pending[show spinner overlay]
Pending -->|poll every 2s up to 5 min| API
API -->|processing_status = complete| Play[set videoUrl, render VideoView]
API -->|processing_status = failed or timeout| Err[show error state]
Tap[User Tap on VideoView] --> Pressable[Pressable onPress]
Pressable --> handleVideoTap[handleVideoTap]
handleVideoTap --> Toggle[setControlsVisible toggle]
Toggle -->|showing -> hidden| ClearTimer[clearTimeout controlsTimerRef]
Toggle -->|hidden -> showing| ShowBriefly[showControlsBriefly — auto-hide after 3s]
VideoPlayer -->|playToEnd| ShowControls[setControlsVisible true]
Flows
Flow: memoryViewerRender
- Core files:
main/app/components/memory/memory-viewer-modal.tsx - Test files: none currently
Types
MemoryViewerModalProps {
visible: boolean
memories: MemoryFeedItem[]
initialIndex: number
onClose: () => void
}
MemoryFeedItem {
id: string
type: "audio" | "visual" | "text"
processing_status: "complete" | "pending" | "failed"
thumbnail?: string
...
}
VideoPlayerProps {
memoryId: string
width: number
height: number
initialThumbnail?: string | null
}
Paths
| path | input | output | path-type | notes |
memoryViewerRender.audio | item.type = "audio", index = activeIndex | AudioPlayer + TranscriptDisplay rendered inside audioWithTranscriptContainer | happy path | only active index renders player; responsive layout based on window width |
memoryViewerRender.audioMobile | item.type = "audio", width < 600, index = activeIndex | AudioPlayer full-width on top; TranscriptDisplay full-width below (flex 1) | happy path | stacked column |
memoryViewerRender.audioDesktop | item.type = "audio", width ≥ 600, index = activeIndex | AudioPlayer flex 2 on top; TranscriptDisplay flex 3 below | happy path | still column layout with proportional sizing |
memoryViewerRender.visual | item.type = "visual", index = activeIndex | VideoPlayer rendered (polls until complete) | happy path | VideoPlayer handles pending/failed internally |
memoryViewerRender.visualThumbnail | item.type = "visual", thumbnail present, non-active | Image + ProcessingOverlay | happy path | |
memoryViewerRender.placeholder | no thumbnail | icon + "Image unavailable" text + ProcessingOverlay | happy path | |
memoryViewerRender.hidden | visible = false or memories empty | null (no render) | happy path | |
Flow: videoProcessingPoll
- Core files:
main/app/components/memory/memory-viewer-modal.tsx, main/app/lib/video/video-prefetch-cache.ts, main/app/lib/api/memory/video.ts - Test files: none currently
Types
GetMemoryVideoResponse {
presigned_url: string — empty string ("") when processing is not yet complete
duration_seconds: number
frame_count?: number
processing_status?: "complete" | "pending" | "failed"
}
Paths
| path | input | output | path-type | notes |
videoProcessingPoll.complete | processing_status = "complete" from API | sets videoUrl, renders VideoView | happy path | cache entry kept; no further polling |
videoProcessingPoll.pending | processing_status = "pending" from API | shows spinner overlay, schedules next poll in 2 s | happy path | cache evicts pending response so retry hits server |
videoProcessingPoll.failed | processing_status = "failed" from API | shows error state "Video processing failed on the server" | error | polling stops |
videoProcessingPoll.timeout | 5 min elapsed with processing_status still "pending" | shows error state "Video processing timed out" | error | MAX_POLL_DURATION_MS = 300 000 |
videoProcessingPoll.networkError | fetch throws during poll | logs warning, schedules next poll in 2 s | error | isMountedRef guard prevents state update after unmount |
videoProcessingPoll.unmounted | component unmounts while poll pending | clears pollingTimerRef, discards in-flight result | happy path | isMountedRef.current = false stops all state updates |
Pseudocode
// Initial fetch (useEffect on memoryId)
getOrFetchMemoryVideo(memoryId)
.then(res => {
if not isMountedRef.current: return
if res.processing_status == "pending":
pollStartTimeRef.current = Date.now()
setProcessingStatus("pending") // triggers polling useEffect
else if res.processing_status == "failed":
setError(...)
else:
setVideoUrl(res.presigned_url)
setProcessingStatus("complete")
})
// video-prefetch-cache: evict non-complete responses so the next call re-fetches
if not result.presigned_url or result.processing_status in ("pending", "failed"):
videoCache.delete(memoryId)
// Polling useEffect (fires when processingStatus == "pending")
scheduleNextPoll():
if Date.now() - pollStartTimeRef.current > 300_000:
setProcessingStatus("failed")
setError("Video processing timed out")
return
pollingTimerRef.current = setTimeout(() => {
getOrFetchMemoryVideo(memoryId)
.then(res => {
if not isMountedRef.current: return
if res.processing_status == "pending": scheduleNextPoll(); return
if res.processing_status == "failed": setError(...); return
setVideoUrl(res.presigned_url)
setProcessingStatus("complete")
})
.catch(err => {
if not isMountedRef.current: return
scheduleNextPoll() // keep retrying on network error
})
}, 2000)
// Cleanup on unmount
isMountedRef.current = false
clearTimeout(pollingTimerRef.current)
Flow: videoControlsToggle
- Core files:
main/app/components/memory/memory-viewer-modal.tsx - Test files: none currently
Types
VideoPlayerProps {
memoryId: string
width: number
height: number
}
Paths
| path | input | output | path-type | notes |
videoControlsToggle.tapToHide | tap while controls visible | controls hidden, timer cleared | happy path | via Pressable overlay |
videoControlsToggle.tapToShow | tap while controls hidden | controls shown, auto-hide after 3s | happy path | |
videoControlsToggle.playToEnd | video reaches end (playToEnd event) | controls shown permanently | happy path | |
Pseudocode
// Pressable covers the VideoView (instead of TapGestureHandler)
onPress => handleVideoTap()
setControlsVisible(prev =>
if prev: clearTimeout(timer); return false
else: set new 3s timer; return true
)
showControlsBriefly():
setControlsVisible(true)
clearTimeout(prev timer)
setTimeout(() => setControlsVisible(false), 3000)
Note: Pressable is used instead of TapGestureHandler (legacy RNGH v1 API). TapGestureHandler does not reliably receive taps over a native VideoView because the OS-level video surface consumes touch events before they reach the JS gesture recognizer. Pressable uses the React Native responder system and works reliably over native views. handleVideoTap derives toggle state from the functional updater (prev => !prev) to avoid stale closure reads.
Logs
| Source | Location |
| React Native | Metro / device console |
Deployment
- Mechanism:
local only - Deploy command:
cd main/app && npx expo start
- Notes: Expo managed workflow, React Native app.