瀏覽代碼

Consolidate WebSocket connections into Zustand stores

Replace 3 duplicate /ws/status connections (Layout, NowPlayingBar,
TableControlPage) with a single shared Zustand store. Convert
always-on /ws/cache-progress to connect-on-demand with auto-disconnect
when idle. Reduces server load and removes ~350 lines of duplicated
WebSocket boilerplate.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
tuanchris 5 月之前
父節點
當前提交
4d79972adb

+ 3 - 113
frontend/src/components/NowPlayingBar.tsx

@@ -11,6 +11,8 @@ import {
   DialogTitle,
 } from '@/components/ui/dialog'
 import { apiClient } from '@/lib/apiClient'
+import { useStatusStore } from '@/stores/useStatusStore'
+import type { StatusData } from '@/stores/useStatusStore'
 import {
   DndContext,
   closestCenter,
@@ -30,40 +32,6 @@ import { CSS } from '@dnd-kit/utilities'
 
 type Coordinate = [number, number]
 
-interface PlaybackStatus {
-  current_file: string | null
-  is_paused: boolean
-  manual_pause: boolean
-  scheduled_pause: boolean
-  is_running: boolean
-  progress: {
-    current: number
-    total: number
-    remaining_time: number | null
-    elapsed_time: number
-    percentage: number
-    last_completed_time?: {
-      actual_time_seconds: number
-      actual_time_formatted: string
-      timestamp: string
-    }
-  } | null
-  playlist: {
-    current_index: number
-    total_files: number
-    mode: string
-    next_file: string | null
-    files: string[]
-    name: string | null
-  } | null
-  speed: number
-  pause_time_remaining: number
-  original_pause_time: number | null
-  connection_status: boolean
-  current_theta: number
-  current_rho: number
-}
-
 function formatTime(seconds: number): string {
   if (!seconds || seconds < 0) return '--:--'
   const mins = Math.floor(seconds / 60)
@@ -212,9 +180,8 @@ interface NowPlayingBarProps {
 }
 
 export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVisible, openExpanded = false, onClose }: NowPlayingBarProps) {
-  const [status, setStatus] = useState<PlaybackStatus | null>(null)
+  const status: StatusData | null = useStatusStore((s) => s.status)
   const [previewUrl, setPreviewUrl] = useState<string | null>(null)
-  const wsRef = useRef<WebSocket | null>(null)
 
   // Expanded state for slide-up view
   const [isExpanded, setIsExpanded] = useState(false)
@@ -330,83 +297,6 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
   const lastProgressTimeRef = useRef<number>(0)
   const smoothProgressRef = useRef<number>(0)
 
-  // Connect to status WebSocket (reconnects when table changes)
-  useEffect(() => {
-    let reconnectTimeout: ReturnType<typeof setTimeout> | null = null
-    let shouldReconnect = true
-
-    const connectWebSocket = () => {
-      if (!shouldReconnect) return
-
-      // Don't interrupt an existing connection that's still connecting
-      if (wsRef.current) {
-        if (wsRef.current.readyState === WebSocket.CONNECTING) {
-          return // Already connecting, wait for it
-        }
-        if (wsRef.current.readyState === WebSocket.OPEN) {
-          wsRef.current.close()
-        }
-        wsRef.current = null
-      }
-
-      const wsUrl = apiClient.getWebSocketUrl('/ws/status')
-      const ws = new WebSocket(wsUrl)
-      // Assign to ref IMMEDIATELY so concurrent calls see it's connecting
-      wsRef.current = ws
-
-      ws.onopen = () => {
-        if (!shouldReconnect) {
-          // Component unmounted while connecting - close the WebSocket now
-          ws.close()
-        }
-      }
-
-      ws.onmessage = (event) => {
-        if (!shouldReconnect) return
-        try {
-          const message = JSON.parse(event.data)
-          if (message.type === 'status_update' && message.data) {
-            setStatus(message.data)
-          }
-        } catch {
-          // Ignore parse errors
-        }
-      }
-
-      ws.onclose = () => {
-        if (!shouldReconnect) return
-        reconnectTimeout = setTimeout(connectWebSocket, 3000)
-      }
-    }
-
-    connectWebSocket()
-
-    // Reconnect when base URL changes (table switch)
-    const unsubscribe = apiClient.onBaseUrlChange(() => {
-      if (reconnectTimeout) {
-        clearTimeout(reconnectTimeout)
-        reconnectTimeout = null
-      }
-      // connectWebSocket handles closing existing connection safely
-      connectWebSocket()
-    })
-
-    return () => {
-      shouldReconnect = false
-      unsubscribe()
-      if (reconnectTimeout) {
-        clearTimeout(reconnectTimeout)
-      }
-      if (wsRef.current) {
-        // Only close if already OPEN - CONNECTING WebSockets will close in onopen
-        if (wsRef.current.readyState === WebSocket.OPEN) {
-          wsRef.current.close()
-        }
-        wsRef.current = null
-      }
-    }
-  }, [])
-
   // Fetch preview images for current and next patterns
   const [nextPreviewUrl, setNextPreviewUrl] = useState<string | null>(null)
   const lastFetchedFilesRef = useRef<string>('')

+ 72 - 263
frontend/src/components/layout/Layout.tsx

@@ -10,6 +10,8 @@ import { TableSelector } from '@/components/TableSelector'
 import { useTable } from '@/contexts/TableContext'
 import { apiClient } from '@/lib/apiClient'
 import ShinyText from '@/components/ShinyText'
+import { useStatusStore } from '@/stores/useStatusStore'
+import { useCacheProgressStore } from '@/stores/useCacheProgressStore'
 
 const navItems = [
   { path: '/', label: 'Browse', icon: 'grid_view', title: 'Browse Patterns' },
@@ -54,20 +56,25 @@ export function Layout() {
   const tableName = activeTableData?.name || activeTable?.name
   const displayName = hasMultipleTables && tableName ? tableName : appName
 
-  // Connection status
-  const [isConnected, setIsConnected] = useState(false)
-  const [isBackendConnected, setIsBackendConnected] = useState(false)
-  const [isHoming, setIsHoming] = useState(false)
+  // Connection & status from shared store
+  const isBackendConnected = useStatusStore((s) => s.isBackendConnected)
+  const connectionAttempts = useStatusStore((s) => s.connectionAttempts)
+  const isConnected = useStatusStore((s) => s.status?.connection_status ?? false)
+  const isHoming = useStatusStore((s) => s.status?.is_homing ?? false)
+  const sensorHomingFailed = useStatusStore((s) => s.status?.sensor_homing_failed ?? false)
+  const statusCurrentFile = useStatusStore((s) => s.status?.current_file ?? null)
+  const statusIsRunning = useStatusStore((s) => s.status?.is_running ?? false)
+  const statusIsPaused = useStatusStore((s) => s.status?.is_paused ?? false)
+  const statusPauseTimeRemaining = useStatusStore((s) => s.status?.pause_time_remaining ?? 0)
+
+  // Homing overlay state (local UI state)
   const [homingDismissed, setHomingDismissed] = useState(false)
   const [homingJustCompleted, setHomingJustCompleted] = useState(false)
   const [homingCountdown, setHomingCountdown] = useState(0)
   const [keepHomingLogsOpen, setKeepHomingLogsOpen] = useState(false)
   const wasHomingRef = useRef(false)
-  const [connectionAttempts, setConnectionAttempts] = useState(0)
-  const wsRef = useRef<WebSocket | null>(null)
 
-  // Sensor homing failure state
-  const [sensorHomingFailed, setSensorHomingFailed] = useState(false)
+  // Sensor homing recovery (local UI state)
   const [isRecoveringHoming, setIsRecoveringHoming] = useState(false)
 
   // Update availability
@@ -186,11 +193,28 @@ export function Layout() {
     }
   }, [])
 
+  // Homing transition detection — watches store values
+  useEffect(() => {
+    const newIsHoming = isHoming
+    // Detect transition from not homing to homing — reset dismissal
+    if (!wasHomingRef.current && newIsHoming) {
+      setHomingDismissed(false)
+    }
+    // Detect transition from homing to not homing
+    if (wasHomingRef.current && !newIsHoming) {
+      if (!sensorHomingFailed) {
+        setHomingJustCompleted(true)
+        setHomingCountdown(5)
+        setHomingDismissed(false)
+      }
+    }
+    wasHomingRef.current = newIsHoming
+  }, [isHoming, sensorHomingFailed])
+
   // Now Playing bar state
   const [isNowPlayingOpen, setIsNowPlayingOpen] = useState(false)
   const [openNowPlayingExpanded, setOpenNowPlayingExpanded] = useState(false)
-  const [currentPlayingFile, setCurrentPlayingFile] = useState<string | null>(null) // Track current file for header button
-  const wasPlayingRef = useRef<boolean | null>(null) // Track previous playing state (null = first message)
+  const currentPlayingFile = statusCurrentFile
 
   // Draggable Now Playing button state
   type SnapPosition = 'left' | 'center' | 'right'
@@ -210,7 +234,20 @@ export function Layout() {
   // Derive isCurrentlyPlaying from currentPlayingFile
   const isCurrentlyPlaying = Boolean(currentPlayingFile)
 
-  // Listen for playback-started event (dispatched when user starts a pattern)
+  // Auto-close NowPlayingBar when playback stops (watches store values)
+  const wasNpPlayingRef = useRef<boolean | null>(null)
+  const npIsPlaying = Boolean(currentPlayingFile) || statusIsRunning || statusIsPaused || statusPauseTimeRemaining > 0
+  useEffect(() => {
+    // Skip first render
+    if (wasNpPlayingRef.current !== null) {
+      if (!npIsPlaying && wasNpPlayingRef.current) {
+        setIsNowPlayingOpen(false)
+      }
+    }
+    wasNpPlayingRef.current = npIsPlaying
+  }, [npIsPlaying])
+
+  // Listen for playback-started event (dispatched by the status store on transitions)
   useEffect(() => {
     const handlePlaybackStarted = () => {
       setIsNowPlayingOpen(true)
@@ -231,158 +268,6 @@ export function Layout() {
   const logsContainerRef = useRef<HTMLDivElement>(null)
   const logsLoadedCountRef = useRef(0) // Track how many logs we've loaded (for offset)
 
-  // Check device connection status via WebSocket
-  // This effect runs once on mount and manages its own reconnection logic
-  useEffect(() => {
-    let reconnectTimeout: ReturnType<typeof setTimeout> | null = null
-    let isMounted = true
-
-    const connectWebSocket = () => {
-      if (!isMounted) return
-
-      // Only close existing connection if it's open (not still connecting)
-      // This prevents "WebSocket closed before connection established" errors
-      if (wsRef.current) {
-        if (wsRef.current.readyState === WebSocket.OPEN) {
-          wsRef.current.close()
-          wsRef.current = null
-        } else if (wsRef.current.readyState === WebSocket.CONNECTING) {
-          // Already connecting, don't interrupt
-          return
-        }
-      }
-
-      const ws = new WebSocket(apiClient.getWebSocketUrl('/ws/status'))
-      // Assign to ref IMMEDIATELY so concurrent calls see it's connecting
-      wsRef.current = ws
-
-      ws.onopen = () => {
-        if (!isMounted) {
-          // Component unmounted while connecting - close the WebSocket now
-          ws.close()
-          return
-        }
-        setIsBackendConnected(true)
-        setConnectionAttempts(0)
-        // Dispatch event so pages can refetch data
-        window.dispatchEvent(new CustomEvent('backend-connected'))
-      }
-
-      ws.onmessage = (event) => {
-        if (!isMounted) return
-        try {
-          const data = JSON.parse(event.data)
-          // Handle status updates
-          if (data.type === 'status_update' && data.data) {
-            // Update device connection status from the status message
-            if (data.data.connection_status !== undefined) {
-              setIsConnected(data.data.connection_status)
-            }
-            // Update homing status and detect completion
-            if (data.data.is_homing !== undefined) {
-              const newIsHoming = data.data.is_homing
-              // Detect transition from not homing to homing - reset dismissal
-              if (!wasHomingRef.current && newIsHoming) {
-                setHomingDismissed(false)
-              }
-              // Detect transition from homing to not homing
-              if (wasHomingRef.current && !newIsHoming) {
-                // Homing just completed - show completion state with countdown
-                // But not if sensor homing failed (that shows a different dialog)
-                if (!data.data.sensor_homing_failed) {
-                  setHomingJustCompleted(true)
-                  setHomingCountdown(5)
-                  setHomingDismissed(false)
-                }
-              }
-              wasHomingRef.current = newIsHoming
-              setIsHoming(newIsHoming)
-            }
-            // Update sensor homing failure status
-            if (data.data.sensor_homing_failed !== undefined) {
-              setSensorHomingFailed(data.data.sensor_homing_failed)
-            }
-            // Auto-open/close Now Playing bar based on playback state
-            // Track current file - this is the most reliable indicator of playback
-            const currentFile = data.data.current_file || null
-            setCurrentPlayingFile(currentFile)
-
-            // Include pause_time_remaining to keep bar visible during countdown between patterns
-            const isPlaying = Boolean(currentFile) || Boolean(data.data.is_running) || Boolean(data.data.is_paused) || (data.data.pause_time_remaining ?? 0) > 0
-            // Skip auto-open on first message (page refresh) - only react to state changes
-            if (wasPlayingRef.current !== null) {
-              if (isPlaying && !wasPlayingRef.current) {
-                // Playback just started - open the Now Playing bar in expanded mode
-                setIsNowPlayingOpen(true)
-                setOpenNowPlayingExpanded(true)
-                // Close the logs drawer if open
-                setIsLogsOpen(false)
-                // Reset the expanded flag after a short delay
-                setTimeout(() => setOpenNowPlayingExpanded(false), 500)
-                // Dispatch event so pages can close their sidebars/panels
-                window.dispatchEvent(new CustomEvent('playback-started'))
-              } else if (!isPlaying && wasPlayingRef.current) {
-                // Playback just stopped - close the Now Playing bar
-                setIsNowPlayingOpen(false)
-              }
-            }
-            wasPlayingRef.current = isPlaying
-          }
-        } catch {
-          // Ignore parse errors
-        }
-      }
-
-      ws.onclose = () => {
-        if (!isMounted) return
-        wsRef.current = null
-        setIsBackendConnected(false)
-        setConnectionAttempts((prev) => prev + 1)
-        // Reconnect after 3 seconds (don't change device status on WS disconnect)
-        reconnectTimeout = setTimeout(connectWebSocket, 3000)
-      }
-
-      ws.onerror = () => {
-        if (!isMounted) return
-        setIsBackendConnected(false)
-      }
-    }
-
-    // Reset playing state on mount
-    wasPlayingRef.current = null
-
-    // Connect on mount
-    connectWebSocket()
-
-    // Subscribe to base URL changes (when user switches tables)
-    // This triggers reconnection to the new backend
-    const unsubscribe = apiClient.onBaseUrlChange(() => {
-      if (isMounted) {
-        wasPlayingRef.current = null // Reset playing state for new table
-        setCurrentPlayingFile(null) // Reset playback state for new table
-        setIsConnected(false) // Reset connection status until new table reports
-        setIsBackendConnected(false) // Show connecting state
-        setSensorHomingFailed(false) // Reset sensor homing failure state for new table
-        connectWebSocket()
-      }
-    })
-
-    return () => {
-      isMounted = false
-      unsubscribe()
-      if (reconnectTimeout) {
-        clearTimeout(reconnectTimeout)
-      }
-      if (wsRef.current) {
-        // Only close if already OPEN - CONNECTING WebSockets will close in onopen
-        if (wsRef.current.readyState === WebSocket.OPEN) {
-          wsRef.current.close()
-        }
-        wsRef.current = null
-      }
-    }
-  }, []) // Empty deps - runs once on mount, reconnects via apiClient listener
-
   // Connect to logs WebSocket when drawer opens
   useEffect(() => {
     if (!isLogsOpen) {
@@ -690,7 +575,7 @@ export function Layout() {
 
       if (response.success) {
         toast.success(response.message || 'Homing completed successfully')
-        setSensorHomingFailed(false)
+        // sensorHomingFailed will auto-clear via next status WebSocket update
       } else if (response.sensor_homing_failed) {
         // Sensor homing failed again
         toast.error(response.message || 'Sensor homing failed again')
@@ -728,16 +613,16 @@ export function Layout() {
   const [connectionLogs, setConnectionLogs] = useState<Array<{ timestamp: string; level: string; message: string }>>([])
   const blockingLogsRef = useRef<HTMLDivElement>(null)
 
-  // Cache progress state
-  const [cacheProgress, setCacheProgress] = useState<{
-    is_running: boolean
-    stage: string
-    processed_files: number
-    total_files: number
-    current_file: string
-    error?: string
-  } | null>(null)
-  const cacheWsRef = useRef<WebSocket | null>(null)
+  // Cache progress from shared store
+  const cacheProgress = useCacheProgressStore((s) => s.cacheProgress)
+
+  // Connect/disconnect cache progress WebSocket based on backend connectivity
+  useEffect(() => {
+    if (isBackendConnected) {
+      useCacheProgressStore.getState().connect()
+    }
+    return () => useCacheProgressStore.getState().disconnect()
+  }, [isBackendConnected])
 
   // Cache All Previews prompt state
   const [showCacheAllPrompt, setShowCacheAllPrompt] = useState(false)
@@ -881,99 +766,23 @@ export function Layout() {
     }
   }, [isBackendConnected, isHoming, homingJustCompleted])
 
-  // Cache progress WebSocket connection - always connected to monitor cache generation
+  // Cache completion detection — show cache-all prompt when generation finishes
+  const prevCacheProgressRef = useRef(cacheProgress)
   useEffect(() => {
-    if (!isBackendConnected) return
-
-    let reconnectTimeout: ReturnType<typeof setTimeout> | null = null
-    let shouldConnect = true
-
-    const connectCacheWebSocket = () => {
-      if (!shouldConnect) return
-      // Don't interrupt an existing connection that's still connecting
-      if (cacheWsRef.current) {
-        if (cacheWsRef.current.readyState === WebSocket.CONNECTING) {
-          return // Already connecting, wait for it
-        }
-        if (cacheWsRef.current.readyState === WebSocket.OPEN) {
-          return // Already connected
-        }
-        // CLOSING or CLOSED state - clear the ref
-        cacheWsRef.current = null
-      }
-
-      const ws = new WebSocket(apiClient.getWebSocketUrl('/ws/cache-progress'))
-      // Assign to ref IMMEDIATELY so concurrent calls see it's connecting
-      cacheWsRef.current = ws
-
-      ws.onopen = () => {
-        if (!shouldConnect) {
-          // Effect cleanup ran while connecting - close now
-          ws.close()
-        }
-      }
-
-      ws.onmessage = (event) => {
-        try {
-          const message = JSON.parse(event.data)
-          if (message.type === 'cache_progress') {
-            const data = message.data
-            if (data.is_running) {
-              // Cache generation is running - show splash screen
-              setCacheProgress(data)
-            } else if (data.stage === 'complete') {
-              // Cache generation just completed
-              if (cacheProgress?.is_running) {
-                // Was running before, now complete - show cache all prompt
-                const promptShown = localStorage.getItem('cacheAllPromptShown')
-                if (!promptShown) {
-                  setTimeout(() => {
-                    setCacheAllProgress(null) // Reset to clean state
-                    setShowCacheAllPrompt(true)
-                  }, 500)
-                }
-              }
-              setCacheProgress(null)
-            } else {
-              // Not running and not complete (idle state)
-              setCacheProgress(null)
-            }
-          }
-        } catch {
-          // Ignore parse errors
-        }
-      }
+    const prev = prevCacheProgressRef.current
+    prevCacheProgressRef.current = cacheProgress
 
-      ws.onclose = () => {
-        if (!shouldConnect) return
-        cacheWsRef.current = null
-        // Reconnect after 3 seconds
-        if (shouldConnect && isBackendConnected) {
-          reconnectTimeout = setTimeout(connectCacheWebSocket, 3000)
-        }
-      }
-
-      ws.onerror = () => {
-        // Will trigger onclose
-      }
-    }
-
-    connectCacheWebSocket()
-
-    return () => {
-      shouldConnect = false
-      if (reconnectTimeout) {
-        clearTimeout(reconnectTimeout)
-      }
-      if (cacheWsRef.current) {
-        // Only close if already OPEN - CONNECTING WebSockets will close in onopen
-        if (cacheWsRef.current.readyState === WebSocket.OPEN) {
-          cacheWsRef.current.close()
-        }
-        cacheWsRef.current = null
+    // Detect transition: was running → now complete
+    if (prev?.is_running && cacheProgress?.stage === 'complete') {
+      const promptShown = localStorage.getItem('cacheAllPromptShown')
+      if (!promptShown) {
+        setTimeout(() => {
+          setCacheAllProgress(null)
+          setShowCacheAllPrompt(true)
+        }, 500)
       }
     }
-  }, [isBackendConnected]) // Only reconnect based on backend connection, not cache state
+  }, [cacheProgress])
 
   // Calculate cache progress percentage
   const cachePercentage = cacheProgress?.total_files

+ 12 - 66
frontend/src/pages/TableControlPage.tsx

@@ -35,13 +35,24 @@ import {
   SelectValue,
 } from '@/components/ui/select'
 import { apiClient } from '@/lib/apiClient'
+import { useStatusStore } from '@/stores/useStatusStore'
 
 export function TableControlPage() {
   const [speedInput, setSpeedInput] = useState('')
   const [currentSpeed, setCurrentSpeed] = useState<number | null>(null)
   const [currentTheta, setCurrentTheta] = useState(0)
   const [isLoading, setIsLoading] = useState<string | null>(null)
-  const [isPatternRunning, setIsPatternRunning] = useState(false)
+
+  // Subscribe to shared status WebSocket via store
+  const speed = useStatusStore((s) => s.status?.speed ?? null)
+  const isPatternRunning = useStatusStore((s) =>
+    (s.status?.is_running || s.status?.is_paused) ?? false
+  )
+
+  // Sync speed from store into local state (for the badge display)
+  useEffect(() => {
+    if (speed !== null) setCurrentSpeed(speed)
+  }, [speed])
 
   // Serial terminal state
   const [serialPorts, setSerialPorts] = useState<string[]>([])
@@ -53,71 +64,6 @@ export function TableControlPage() {
   const serialOutputRef = useRef<HTMLDivElement>(null)
   const serialInputRef = useRef<HTMLInputElement>(null)
 
-  // Connect to status WebSocket to get current speed and playback status
-  useEffect(() => {
-    let ws: WebSocket | null = null
-    let shouldReconnect = true
-
-    const connect = () => {
-      if (!shouldReconnect) return
-
-      // Don't interrupt an existing connection that's still connecting
-      if (ws) {
-        if (ws.readyState === WebSocket.CONNECTING) {
-          return // Already connecting, wait for it
-        }
-        if (ws.readyState === WebSocket.OPEN) {
-          ws.close()
-        }
-        ws = null
-      }
-
-      ws = new WebSocket(apiClient.getWebSocketUrl('/ws/status'))
-
-      ws.onopen = () => {
-        if (!shouldReconnect) {
-          // Component unmounted while connecting - close the WebSocket now
-          ws?.close()
-        }
-      }
-
-      ws.onmessage = (event) => {
-        if (!shouldReconnect) return
-        try {
-          const message = JSON.parse(event.data)
-          if (message.type === 'status_update' && message.data) {
-            if (message.data.speed !== null && message.data.speed !== undefined) {
-              setCurrentSpeed(message.data.speed)
-            }
-            // Track if a pattern is running or paused
-            setIsPatternRunning(message.data.is_running || message.data.is_paused)
-          }
-        } catch (error) {
-          console.error('Failed to parse status:', error)
-        }
-      }
-    }
-
-    connect()
-
-    // Reconnect when table changes
-    const unsubscribe = apiClient.onBaseUrlChange(() => {
-      connect()
-    })
-
-    return () => {
-      shouldReconnect = false
-      unsubscribe()
-      if (ws) {
-        // Only close if already OPEN - CONNECTING WebSockets will close in onopen
-        if (ws.readyState === WebSocket.OPEN) {
-          ws.close()
-        }
-        ws = null
-      }
-    }
-  }, [])
-
   const handleAction = async (
     action: string,
     endpoint: string,

+ 85 - 0
frontend/src/stores/useCacheProgressStore.ts

@@ -0,0 +1,85 @@
+import { create } from 'zustand'
+import { apiClient } from '@/lib/apiClient'
+
+export interface CacheProgressData {
+  is_running: boolean
+  stage: string
+  processed_files: number
+  total_files: number
+  current_file: string
+  error?: string
+}
+
+interface CacheProgressStore {
+  cacheProgress: CacheProgressData | null
+  isConnected: boolean
+  connect: () => void
+  disconnect: () => void
+}
+
+let ws: WebSocket | null = null
+
+export const useCacheProgressStore = create<CacheProgressStore>()((set, get) => ({
+  cacheProgress: null,
+  isConnected: false,
+
+  connect: () => {
+    // Already connected or connecting
+    if (ws) {
+      if (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN) return
+      ws = null
+    }
+
+    const socket = new WebSocket(apiClient.getWebSocketUrl('/ws/cache-progress'))
+    ws = socket
+
+    socket.onopen = () => {
+      // If disconnect() was called while connecting, close now
+      if (ws !== socket) {
+        socket.close()
+        return
+      }
+      set({ isConnected: true })
+    }
+
+    socket.onmessage = (event) => {
+      try {
+        const message = JSON.parse(event.data)
+        if (message.type === 'cache_progress') {
+          const data: CacheProgressData = message.data
+          if (data.is_running) {
+            set({ cacheProgress: data })
+          } else if (data.stage === 'complete') {
+            set({ cacheProgress: { ...data } })
+            // Auto-disconnect after completion arrives
+            setTimeout(() => get().disconnect(), 500)
+          } else {
+            // Idle — nothing is happening, disconnect
+            set({ cacheProgress: null })
+            get().disconnect()
+          }
+        }
+      } catch {
+        // Ignore parse errors
+      }
+    }
+
+    socket.onclose = () => {
+      if (ws === socket) ws = null
+      set({ isConnected: false })
+      // No auto-reconnect — Layout will call connect() again if needed
+    }
+
+    socket.onerror = () => {
+      // Will trigger onclose
+    }
+  },
+
+  disconnect: () => {
+    if (ws) {
+      if (ws.readyState === WebSocket.OPEN) ws.close()
+      ws = null
+    }
+    set({ isConnected: false })
+  },
+}))

+ 161 - 0
frontend/src/stores/useStatusStore.ts

@@ -0,0 +1,161 @@
+import { create } from 'zustand'
+import { apiClient } from '@/lib/apiClient'
+
+export interface StatusData {
+  current_file: string | null
+  is_paused: boolean
+  manual_pause: boolean
+  scheduled_pause: boolean
+  is_running: boolean
+  is_homing: boolean
+  is_clearing: boolean
+  sensor_homing_failed: boolean
+  progress: {
+    current: number
+    total: number
+    remaining_time: number | null
+    elapsed_time: number
+    percentage: number
+    last_completed_time?: {
+      actual_time_seconds: number
+      actual_time_formatted: string
+      timestamp: string
+    }
+  } | null
+  playlist: {
+    current_index: number
+    total_files: number
+    mode: string
+    next_file: string | null
+    files: string[]
+    name: string | null
+  } | null
+  speed: number
+  pause_time_remaining: number
+  original_pause_time: number | null
+  connection_status: boolean
+  current_theta: number
+  current_rho: number
+}
+
+interface StatusStore {
+  isBackendConnected: boolean
+  connectionAttempts: number
+  status: StatusData | null
+}
+
+export const useStatusStore = create<StatusStore>()(() => ({
+  isBackendConnected: false,
+  connectionAttempts: 0,
+  status: null,
+}))
+
+// --- Module-level WebSocket lifecycle (singleton, outside React) ---
+
+let ws: WebSocket | null = null
+let reconnectTimeout: ReturnType<typeof setTimeout> | null = null
+let isStopped = false
+
+function connectWebSocket() {
+  if (isStopped) return
+
+  // Don't interrupt an existing connection that's still connecting
+  if (ws) {
+    if (ws.readyState === WebSocket.CONNECTING) return
+    if (ws.readyState === WebSocket.OPEN) ws.close()
+    ws = null
+  }
+
+  const socket = new WebSocket(apiClient.getWebSocketUrl('/ws/status'))
+  ws = socket
+
+  socket.onopen = () => {
+    if (isStopped) {
+      socket.close()
+      return
+    }
+    useStatusStore.setState({ isBackendConnected: true, connectionAttempts: 0 })
+    window.dispatchEvent(new CustomEvent('backend-connected'))
+  }
+
+  socket.onmessage = (event) => {
+    if (isStopped) return
+    try {
+      const data = JSON.parse(event.data)
+      if (data.type === 'status_update' && data.data) {
+        useStatusStore.setState({ status: data.data })
+      }
+    } catch {
+      // Ignore parse errors
+    }
+  }
+
+  socket.onclose = () => {
+    if (isStopped) return
+    ws = null
+    useStatusStore.setState((prev) => ({
+      isBackendConnected: false,
+      connectionAttempts: prev.connectionAttempts + 1,
+    }))
+    reconnectTimeout = setTimeout(connectWebSocket, 3000)
+  }
+
+  socket.onerror = () => {
+    if (isStopped) return
+    useStatusStore.setState({ isBackendConnected: false })
+  }
+}
+
+// Reconnect on table switch
+apiClient.onBaseUrlChange(() => {
+  useStatusStore.setState({ status: null, isBackendConnected: false })
+  if (reconnectTimeout) {
+    clearTimeout(reconnectTimeout)
+    reconnectTimeout = null
+  }
+  // Close existing and reconnect
+  if (ws) {
+    if (ws.readyState === WebSocket.OPEN) ws.close()
+    ws = null
+  }
+  connectWebSocket()
+})
+
+// Start connection immediately at module load
+connectWebSocket()
+
+// --- Playback transition detection ---
+
+let wasPlaying: boolean | null = null
+
+useStatusStore.subscribe((state) => {
+  const s = state.status
+  if (!s) return
+
+  const isPlaying =
+    Boolean(s.current_file) ||
+    Boolean(s.is_running) ||
+    Boolean(s.is_paused) ||
+    (s.pause_time_remaining ?? 0) > 0
+
+  // Skip first message (page refresh) - only react to transitions
+  if (wasPlaying !== null) {
+    if (isPlaying && !wasPlaying) {
+      window.dispatchEvent(new CustomEvent('playback-started'))
+    }
+  }
+  wasPlaying = isPlaying
+})
+
+// Reset wasPlaying on table switch so we don't fire false transitions
+apiClient.onBaseUrlChange(() => {
+  wasPlaying = null
+})
+
+// For HMR / cleanup in tests
+export function _stopStatusWebSocket() {
+  isStopped = true
+  if (reconnectTimeout) clearTimeout(reconnectTimeout)
+  if (ws && ws.readyState === WebSocket.OPEN) ws.close()
+  ws = null
+}