Skip to content

Memory Viewer Modal

Metadata

  • System type: library

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.