| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175 |
- 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
- firmware_version: string | null
- table_type: string | null
- }
- 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
- // Every handler checks `ws === socket` so a socket orphaned by a table
- // switch (including one still CONNECTING when the switch happened) can
- // never update the store on behalf of the new connection
- socket.onopen = () => {
- if (isStopped || ws !== socket) {
- socket.close()
- return
- }
- useStatusStore.setState({ isBackendConnected: true, connectionAttempts: 0 })
- window.dispatchEvent(new CustomEvent('backend-connected'))
- }
- socket.onmessage = (event) => {
- if (isStopped || ws !== socket) 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 || ws !== socket) return
- ws = null
- const attempts = useStatusStore.getState().connectionAttempts + 1
- useStatusStore.setState({
- isBackendConnected: false,
- connectionAttempts: attempts,
- })
- // Exponential backoff with jitter, capped at 30s — a flat interval makes
- // every open tab hammer the backend in lockstep while it's down
- const delay =
- Math.min(30_000, 3_000 * 2 ** Math.min(attempts - 1, 4)) + Math.random() * 1_000
- reconnectTimeout = setTimeout(connectWebSocket, delay)
- }
- socket.onerror = () => {
- if (isStopped || ws !== socket) 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. Null the ref first so the old socket's
- // handlers see ws !== socket and stand down; close() is valid (and
- // necessary) even on a socket still in CONNECTING state.
- if (ws) {
- const oldSocket = ws
- ws = null
- oldSocket.close()
- }
- 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)
- // close() is safe in any readyState, including CONNECTING
- ws?.close()
- ws = null
- }
|