Procházet zdrojové kódy

Fix remote table control and improve multi-table stability

- Convert 56+ direct fetch() calls to apiClient across all pages
  - BrowsePage, LEDPage, SettingsPage, PlaylistsPage, TableControlPage
  - previewCache, Layout, NowPlayingBar
- Add apiClient.delete() body support for DELETE requests with payload
- Fix table selection persistence across page refreshes
  - Use ref to track restored selection before async state updates
- Reload page on table switch for clean WebSocket/cache state
- Fix WebSocket reconnection bugs in Layout and NowPlayingBar
  - Add shouldReconnect flag to prevent stale onclose handlers
  - Clear pending reconnect timeouts properly
- Optimize NowPlayingBar preview fetching
  - Skip fetch when bar is hidden
  - Track last fetched files to prevent duplicate requests
- Fix DW LED default Rainbow speed to 60 for smoother animation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
tuanchris před 3 týdny
rodič
revize
8d2bcf78e7

+ 49 - 29
frontend/src/components/NowPlayingBar.tsx

@@ -3,6 +3,7 @@ import { toast } from 'sonner'
 import { Button } from '@/components/ui/button'
 import { Progress } from '@/components/ui/progress'
 import { Input } from '@/components/ui/input'
+import { apiClient } from '@/lib/apiClient'
 
 type Coordinate = [number, number]
 
@@ -156,13 +157,19 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
   const lastProgressTimeRef = useRef<number>(0)
   const smoothProgressRef = useRef<number>(0)
 
-  // Connect to status WebSocket
+  // Connect to status WebSocket (reconnects when table changes)
   useEffect(() => {
+    let reconnectTimeout: ReturnType<typeof setTimeout> | null = null
+    let shouldReconnect = true
+
     const connectWebSocket = () => {
-      const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
-      const ws = new WebSocket(`${protocol}//${window.location.host}/ws/status`)
+      if (!shouldReconnect) return
+
+      const wsUrl = apiClient.getWebSocketUrl('/ws/status')
+      const ws = new WebSocket(wsUrl)
 
       ws.onmessage = (event) => {
+        if (!shouldReconnect) return
         try {
           const message = JSON.parse(event.data)
           if (message.type === 'status_update' && message.data) {
@@ -174,7 +181,8 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
       }
 
       ws.onclose = () => {
-        setTimeout(connectWebSocket, 3000)
+        if (!shouldReconnect) return
+        reconnectTimeout = setTimeout(connectWebSocket, 3000)
       }
 
       wsRef.current = ws
@@ -182,7 +190,28 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
 
     connectWebSocket()
 
+    // Reconnect when base URL changes (table switch)
+    const unsubscribe = apiClient.onBaseUrlChange(() => {
+      // Disable reconnect for old connection
+      shouldReconnect = false
+      if (reconnectTimeout) {
+        clearTimeout(reconnectTimeout)
+        reconnectTimeout = null
+      }
+      if (wsRef.current) {
+        wsRef.current.close()
+      }
+      // Re-enable and connect to new URL
+      shouldReconnect = true
+      connectWebSocket()
+    })
+
     return () => {
+      shouldReconnect = false
+      unsubscribe()
+      if (reconnectTimeout) {
+        clearTimeout(reconnectTimeout)
+      }
       if (wsRef.current) {
         wsRef.current.close()
       }
@@ -191,21 +220,25 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
 
   // Fetch preview images for current and next patterns
   const [nextPreviewUrl, setNextPreviewUrl] = useState<string | null>(null)
+  const lastFetchedFilesRef = useRef<string>('')
 
   useEffect(() => {
+    // Don't fetch if not visible
+    if (!isVisible) return
+
     const currentFile = status?.current_file
     const nextFile = status?.playlist?.next_file
 
     // Build list of files to fetch
     const filesToFetch = [currentFile, nextFile].filter(Boolean) as string[]
+    const fetchKey = filesToFetch.join('|')
+
+    // Skip if we already fetched these exact files
+    if (fetchKey === lastFetchedFilesRef.current) return
+    lastFetchedFilesRef.current = fetchKey
 
     if (filesToFetch.length > 0) {
-      fetch('/preview_thr_batch', {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ file_names: filesToFetch }),
-      })
-        .then((r) => r.json())
+      apiClient.post<Record<string, { image_data?: string }>>('/preview_thr_batch', { file_names: filesToFetch })
         .then((data) => {
           if (currentFile && data[currentFile]?.image_data) {
             setPreviewUrl(data[currentFile].image_data)
@@ -226,7 +259,7 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
       setPreviewUrl(null)
       setNextPreviewUrl(null)
     }
-  }, [status?.current_file, status?.playlist?.next_file])
+  }, [isVisible, status?.current_file, status?.playlist?.next_file])
 
   // Canvas drawing functions for real-time preview
   const polarToCartesian = useCallback((theta: number, rho: number, size: number) => {
@@ -384,12 +417,7 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
     lastFileRef.current = currentFile
     lastDrawnIndexRef.current = -1
 
-    fetch('/get_theta_rho_coordinates', {
-      method: 'POST',
-      headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({ file_name: currentFile }),
-    })
-      .then((r) => r.json())
+    apiClient.post<{ coordinates?: Coordinate[] }>('/get_theta_rho_coordinates', { file_name: currentFile })
       .then((data) => {
         if (data.coordinates && Array.isArray(data.coordinates)) {
           setCoordinates(data.coordinates)
@@ -480,8 +508,7 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
   const handlePause = async () => {
     try {
       const endpoint = status?.is_paused ? '/resume_execution' : '/pause_execution'
-      const response = await fetch(endpoint, { method: 'POST' })
-      if (!response.ok) throw new Error()
+      await apiClient.post(endpoint)
       toast.success(status?.is_paused ? 'Resumed' : 'Paused')
     } catch {
       toast.error('Failed to toggle pause')
@@ -490,8 +517,7 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
 
   const handleStop = async () => {
     try {
-      const response = await fetch('/stop_execution', { method: 'POST' })
-      if (!response.ok) throw new Error()
+      await apiClient.post('/stop_execution')
       toast.success('Stopped')
     } catch {
       toast.error('Failed to stop')
@@ -500,8 +526,7 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
 
   const handleSkip = async () => {
     try {
-      const response = await fetch('/skip_pattern', { method: 'POST' })
-      if (!response.ok) throw new Error()
+      await apiClient.post('/skip_pattern')
       toast.success('Skipping to next pattern')
     } catch {
       toast.error('Failed to skip')
@@ -517,12 +542,7 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
       return
     }
     try {
-      const response = await fetch('/set_speed', {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ speed }),
-      })
-      if (!response.ok) throw new Error()
+      await apiClient.post('/set_speed', { speed })
       setSpeedInput('')
       toast.success(`Speed set to ${speed} mm/s`)
     } catch {

+ 20 - 3
frontend/src/components/layout/Layout.tsx

@@ -156,10 +156,16 @@ export function Layout() {
 
   // Check device connection status via WebSocket
   useEffect(() => {
+    let reconnectTimeout: ReturnType<typeof setTimeout> | null = null
+    let isMounted = true
+
     const connectWebSocket = () => {
+      if (!isMounted) return
+
       const ws = new WebSocket(apiClient.getWebSocketUrl('/ws/status'))
 
       ws.onopen = () => {
+        if (!isMounted) return
         setIsBackendConnected(true)
         setConnectionAttempts(0)
         // Dispatch event so pages can refetch data
@@ -167,6 +173,7 @@ export function Layout() {
       }
 
       ws.onmessage = (event) => {
+        if (!isMounted) return
         try {
           const data = JSON.parse(event.data)
           // Handle status updates
@@ -214,22 +221,31 @@ export function Layout() {
       }
 
       ws.onclose = () => {
+        if (!isMounted) return
         setIsBackendConnected(false)
         setConnectionAttempts((prev) => prev + 1)
         // Reconnect after 3 seconds (don't change device status on WS disconnect)
-        setTimeout(connectWebSocket, 3000)
+        reconnectTimeout = setTimeout(connectWebSocket, 3000)
       }
 
       ws.onerror = () => {
+        if (!isMounted) return
         setIsBackendConnected(false)
       }
 
       wsRef.current = ws
     }
 
+    // Reset playing state when table changes to avoid false transitions
+    wasPlayingRef.current = null
+
     connectWebSocket()
 
     return () => {
+      isMounted = false
+      if (reconnectTimeout) {
+        clearTimeout(reconnectTimeout)
+      }
       if (wsRef.current) {
         wsRef.current.close()
       }
@@ -345,7 +361,8 @@ export function Layout() {
         logsWsRef.current = null
       }
     }
-  }, [isLogsOpen])
+    // Also reconnect when active table changes
+  }, [isLogsOpen, activeTable?.id])
 
   const handleOpenLogs = () => {
     setIsLogsOpen(true)
@@ -553,7 +570,7 @@ export function Layout() {
       const interval = setInterval(() => {
         addLog('INFO', `Retrying connection to WebSocket /ws/status...`)
 
-        fetch('/api/settings', { method: 'GET' })
+        apiClient.get('/api/settings')
           .then(() => {
             addLog('INFO', 'HTTP endpoint responding, waiting for WebSocket...')
           })

+ 56 - 67
frontend/src/contexts/TableContext.tsx

@@ -52,6 +52,7 @@ export function TableProvider({ children }: { children: React.ReactNode }) {
   const [isDiscovering, setIsDiscovering] = useState(false)
   const [lastDiscovery, setLastDiscovery] = useState<Date | null>(null)
   const initializedRef = useRef(false)
+  const restoredActiveIdRef = useRef<string | null>(null) // Track restored selection
 
   // Load saved tables from localStorage on mount
   useEffect(() => {
@@ -70,18 +71,21 @@ export function TableProvider({ children }: { children: React.ReactNode }) {
         if (activeId && data.tables) {
           const active = data.tables.find(t => t.id === activeId)
           if (active) {
+            restoredActiveIdRef.current = activeId // Mark that we restored a selection
             setActiveTableState(active)
-            apiClient.setBaseUrl(active.url === `http://localhost:${window.location.port || 8080}` ? '' : active.url)
+            // Only set non-empty base URL for remote tables
+            if (!active.isCurrent && active.url !== window.location.origin) {
+              apiClient.setBaseUrl(active.url)
+            }
           }
         }
       }
 
-      // Auto-discover on first load if no tables saved
-      if (!stored) {
-        discoverTables()
-      }
+      // Always refresh to ensure current table is available and up-to-date
+      discoverTables()
     } catch (e) {
       console.error('Failed to load saved tables:', e)
+      discoverTables()
     }
   }, [])
 
@@ -105,101 +109,86 @@ export function TableProvider({ children }: { children: React.ReactNode }) {
     }
   }, [tables, activeTable])
 
-  // Set active table and update API client
+  // Set active table - saves to localStorage and reloads page for clean state
   const setActiveTable = useCallback((table: Table) => {
-    setActiveTableState(table)
+    // Save to localStorage before reload
+    try {
+      const currentTables = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}')
+      const data: StoredTableData = {
+        tables: currentTables.tables || tables,
+        activeTableId: table.id,
+      }
+      localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
+      localStorage.setItem(ACTIVE_TABLE_KEY, table.id)
+    } catch (e) {
+      console.error('Failed to save table selection:', e)
+    }
 
     // Update API client base URL
-    // If the table is the current one (serving this frontend), use relative URLs
     if (table.isCurrent || table.url === window.location.origin) {
       apiClient.setBaseUrl('')
     } else {
       apiClient.setBaseUrl(table.url)
     }
-  }, [])
 
-  // Discover tables via mDNS
+    // Reload page for clean state (WebSockets, caches, etc.)
+    window.location.reload()
+  }, [tables])
+
+  // Refresh tables - ensures current table is always available
   const discoverTables = useCallback(async () => {
     setIsDiscovering(true)
 
     try {
-      // Call the discovery endpoint on the current backend
-      const response = await fetch('/api/discover-tables?timeout=3')
-      if (!response.ok) {
-        throw new Error('Discovery failed')
+      // Always fetch the current table's info
+      const infoResponse = await fetch('/api/table-info')
+      if (!infoResponse.ok) {
+        throw new Error('Failed to fetch table info')
       }
 
-      const data = await response.json()
-      const discoveredTables: Table[] = (data.tables || []).map((t: {
-        id: string
-        name: string
-        url: string
-        host?: string
-        port?: number
-        version?: string
-        is_current?: boolean
-      }) => ({
-        id: t.id,
-        name: t.name,
-        url: t.url,
-        host: t.host,
-        port: t.port,
-        version: t.version,
+      const info = await infoResponse.json()
+      const currentTable: Table = {
+        id: info.id,
+        name: info.name,
+        url: window.location.origin,
+        version: info.version,
         isOnline: true,
-        isCurrent: t.is_current || false,
-      }))
+        isCurrent: true,
+      }
 
-      // Merge with existing tables (keep manual additions)
+      // Merge with existing tables
       setTables(prev => {
-        const merged = [...discoveredTables]
+        // Start with current table
+        const merged: Table[] = [currentTable]
 
-        // Add any manually added tables that weren't discovered
+        // Add any other tables (manual additions), mark them for status check
         prev.forEach(existing => {
-          if (!merged.find(t => t.id === existing.id)) {
-            merged.push({ ...existing, isOnline: false })
+          if (existing.id !== currentTable.id && !existing.isCurrent) {
+            merged.push({ ...existing, isOnline: existing.isOnline ?? false })
           }
         })
 
         return merged
       })
 
-      // If no active table, select the current one
-      if (!activeTable) {
-        const currentTable = discoveredTables.find(t => t.isCurrent)
-        if (currentTable) {
-          setActiveTable(currentTable)
-        }
+      // If no active table AND no restored selection, select the current one
+      // Use ref to check restored selection because activeTable state may not be updated yet
+      if (!activeTable && !restoredActiveIdRef.current) {
+        setActiveTable(currentTable)
+      } else if (activeTable?.isCurrent) {
+        // Update active table name if it changed on the backend
+        setActiveTableState(prev => prev ? { ...prev, name: currentTable.name } : null)
       }
+      // Clear the restored ref after first discovery
+      restoredActiveIdRef.current = null
 
       setLastDiscovery(new Date())
     } catch (e) {
-      console.error('Table discovery failed:', e)
-
-      // If discovery fails and we have no tables, add the current backend
-      if (tables.length === 0) {
-        try {
-          const infoResponse = await fetch('/api/table-info')
-          if (infoResponse.ok) {
-            const info = await infoResponse.json()
-            const currentTable: Table = {
-              id: info.id,
-              name: info.name,
-              url: window.location.origin,
-              version: info.version,
-              isOnline: true,
-              isCurrent: true,
-            }
-            setTables([currentTable])
-            setActiveTable(currentTable)
-          }
-        } catch {
-          // Ignore
-        }
-      }
+      console.error('Table refresh failed:', e)
     } finally {
       setIsDiscovering(false)
     }
-  }, [activeTable, tables.length, setActiveTable])
+  }, [activeTable, setActiveTable])
 
   // Add a table manually by URL
   const addTable = useCallback(async (url: string, name?: string): Promise<Table | null> => {

+ 2 - 2
frontend/src/lib/apiClient.ts

@@ -117,8 +117,8 @@ class ApiClient {
   /**
    * DELETE request
    */
-  async delete<T = unknown>(endpoint: string, signal?: AbortSignal): Promise<T> {
-    return this.request<T>(endpoint, { method: 'DELETE', signal })
+  async delete<T = unknown>(endpoint: string, body?: unknown, signal?: AbortSignal): Promise<T> {
+    return this.request<T>(endpoint, { method: 'DELETE', body, signal })
   }
 
   /**

+ 8 - 15
frontend/src/lib/previewCache.ts

@@ -1,4 +1,6 @@
 // IndexedDB cache for preview images - matches original implementation
+import { apiClient } from './apiClient'
+
 const PREVIEW_CACHE_DB_NAME = 'dune_weaver_previews'
 const PREVIEW_CACHE_DB_VERSION = 1
 const PREVIEW_CACHE_STORE_NAME = 'previews'
@@ -328,8 +330,7 @@ export async function cacheAllPreviews(
     await initPreviewCacheDB()
 
     // Fetch all patterns
-    const response = await fetch('/list_theta_rho_files_with_metadata')
-    const patterns: { path: string }[] = await response.json()
+    const patterns: { path: string }[] = await apiClient.get('/list_theta_rho_files_with_metadata')
     const allPaths = patterns.map((p) => p.path)
 
     // Check which patterns are already cached
@@ -351,19 +352,11 @@ export async function cacheAllPreviews(
       const batchPatterns = uncachedPatterns.slice(batchStart, batchEnd)
 
       try {
-        const batchResponse = await fetch('/preview_thr_batch', {
-          method: 'POST',
-          headers: { 'Content-Type': 'application/json' },
-          body: JSON.stringify({ file_names: batchPatterns }),
-        })
-
-        if (batchResponse.ok) {
-          const results = await batchResponse.json()
-
-          for (const [path, data] of Object.entries(results)) {
-            if (data && !(data as { error?: string }).error) {
-              await savePreviewToCache(path, data as PreviewData)
-            }
+        const results = await apiClient.post<Record<string, PreviewData>>('/preview_thr_batch', { file_names: batchPatterns })
+
+        for (const [path, data] of Object.entries(results)) {
+          if (data && !data.error) {
+            await savePreviewToCache(path, data)
           }
         }
       } catch {

+ 31 - 89
frontend/src/pages/BrowsePage.tsx

@@ -7,6 +7,7 @@ import {
   cacheAllPreviews,
 } from '@/lib/previewCache'
 import { fuzzyMatch } from '@/lib/utils'
+import { apiClient } from '@/lib/apiClient'
 import { useOnBackendConnected } from '@/hooks/useBackendConnection'
 import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
@@ -135,11 +136,8 @@ export function BrowsePage() {
   // Load favorites from "Favorites" playlist
   const loadFavorites = async () => {
     try {
-      const response = await fetch('/get_playlist?name=Favorites')
-      if (response.ok) {
-        const playlist = await response.json()
-        setFavorites(new Set(playlist.files || []))
-      }
+      const playlist = await apiClient.get<{ files?: string[] }>('/get_playlist?name=Favorites')
+      setFavorites(new Set(playlist.files || []))
     } catch {
       // Favorites playlist doesn't exist yet - that's OK
     }
@@ -156,33 +154,19 @@ export function BrowsePage() {
       if (isFavorite) {
         // Remove from favorites
         newFavorites.delete(path)
-        const response = await fetch('/modify_playlist', {
-          method: 'POST',
-          headers: { 'Content-Type': 'application/json' },
-          body: JSON.stringify({ playlist_name: 'Favorites', files: Array.from(newFavorites) }),
-        })
-        if (response.ok) {
-          setFavorites(newFavorites)
-          toast.success('Removed from favorites')
-        }
+        await apiClient.post('/modify_playlist', { playlist_name: 'Favorites', files: Array.from(newFavorites) })
+        setFavorites(newFavorites)
+        toast.success('Removed from favorites')
       } else {
         // Add to favorites - first check if playlist exists
         newFavorites.add(path)
-        const checkResponse = await fetch('/get_playlist?name=Favorites')
-        if (checkResponse.ok) {
+        try {
+          await apiClient.get('/get_playlist?name=Favorites')
           // Playlist exists, add to it
-          await fetch('/add_to_playlist', {
-            method: 'POST',
-            headers: { 'Content-Type': 'application/json' },
-            body: JSON.stringify({ playlist_name: 'Favorites', pattern: path }),
-          })
-        } else {
+          await apiClient.post('/add_to_playlist', { playlist_name: 'Favorites', pattern: path })
+        } catch {
           // Create playlist with this pattern
-          await fetch('/create_playlist', {
-            method: 'POST',
-            headers: { 'Content-Type': 'application/json' },
-            body: JSON.stringify({ playlist_name: 'Favorites', files: [path] }),
-          })
+          await apiClient.post('/create_playlist', { playlist_name: 'Favorites', files: [path] })
         }
         setFavorites(newFavorites)
         toast.success('Added to favorites')
@@ -195,8 +179,7 @@ export function BrowsePage() {
   const fetchPatterns = async () => {
     setIsLoading(true)
     try {
-      const response = await fetch('/list_theta_rho_files_with_metadata')
-      const data = await response.json()
+      const data = await apiClient.get<PatternMetadata[]>('/list_theta_rho_files_with_metadata')
       setPatterns(data)
 
       if (data.length > 0) {
@@ -262,12 +245,7 @@ export function BrowsePage() {
           const batch = uncachedPaths.slice(i, i + BATCH_SIZE)
 
           try {
-            const response = await fetch('/preview_thr_batch', {
-              method: 'POST',
-              headers: { 'Content-Type': 'application/json' },
-              body: JSON.stringify({ file_names: batch }),
-            })
-            const data = await response.json()
+            const data = await apiClient.post<Record<string, PreviewData>>('/preview_thr_batch', { file_names: batch })
 
             // Save fetched previews to IndexedDB cache
             for (const [path, previewData] of Object.entries(data)) {
@@ -295,12 +273,7 @@ export function BrowsePage() {
   const fetchCoordinates = async (filePath: string) => {
     setIsLoadingCoordinates(true)
     try {
-      const response = await fetch('/get_theta_rho_coordinates', {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ file_name: filePath }),
-      })
-      const data = await response.json()
+      const data = await apiClient.post<{ coordinates?: Coordinate[] }>('/get_theta_rho_coordinates', { file_name: filePath })
       setCoordinates(data.coordinates || [])
     } catch (error) {
       console.error('Error fetching coordinates:', error)
@@ -652,25 +625,18 @@ export function BrowsePage() {
 
     setIsRunning(true)
     try {
-      const response = await fetch('/run_theta_rho', {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({
-          file_name: selectedPattern.path,
-          pre_execution: preExecution,
-        }),
+      await apiClient.post('/run_theta_rho', {
+        file_name: selectedPattern.path,
+        pre_execution: preExecution,
       })
-
-      if (response.status === 409) {
+      toast.success(`Running ${selectedPattern.name}`)
+    } catch (error) {
+      const message = error instanceof Error ? error.message : 'Failed to run pattern'
+      if (message.includes('409') || message.includes('already running')) {
         toast.error('Another pattern is already running')
-      } else if (response.ok) {
-        toast.success(`Running ${selectedPattern.name}`)
       } else {
-        const data = await response.json()
-        throw new Error(data.detail || 'Failed to run pattern')
+        toast.error(message)
       }
-    } catch (error) {
-      toast.error(error instanceof Error ? error.message : 'Failed to run pattern')
     } finally {
       setIsRunning(false)
     }
@@ -689,21 +655,12 @@ export function BrowsePage() {
     }
 
     try {
-      const response = await fetch('/delete_theta_rho_file', {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ file_name: selectedPattern.path }),
-      })
-
-      if (response.ok) {
-        toast.success(`Deleted ${selectedPattern.name}`)
-        setIsPanelOpen(false)
-        setSelectedPattern(null)
-        fetchPatterns()
-      } else {
-        throw new Error('Failed to delete pattern')
-      }
-    } catch (error) {
+      await apiClient.post('/delete_theta_rho_file', { file_name: selectedPattern.path })
+      toast.success(`Deleted ${selectedPattern.name}`)
+      setIsPanelOpen(false)
+      setSelectedPattern(null)
+      fetchPatterns()
+    } catch {
       toast.error('Failed to delete pattern')
     }
   }
@@ -762,27 +719,12 @@ export function BrowsePage() {
 
     setIsUploading(true)
     try {
-      const formData = new FormData()
-      formData.append('file', file)
-
-      const response = await fetch('/upload_theta_rho', {
-        method: 'POST',
-        body: formData,
-      })
-
-      if (!response.ok) {
-        const error = await response.json()
-        throw new Error(error.detail || 'Upload failed')
-      }
-
+      await apiClient.uploadFile('/upload_theta_rho', file)
       toast.success(`Pattern "${file.name}" uploaded successfully`)
 
       // Refresh patterns list
-      const patternsRes = await fetch('/list_theta_rho_files')
-      if (patternsRes.ok) {
-        const data = await patternsRes.json()
-        setPatterns(data.files || [])
-      }
+      const data = await apiClient.get<{ files?: PatternMetadata[] }>('/list_theta_rho_files')
+      setPatterns(data.files || [])
     } catch (error) {
       console.error('Upload error:', error)
       toast.error(error instanceof Error ? error.message : 'Failed to upload pattern')

+ 30 - 79
frontend/src/pages/LEDPage.tsx

@@ -1,6 +1,7 @@
 import { useState, useEffect, useCallback } from 'react'
 import { Link } from 'react-router-dom'
 import { toast } from 'sonner'
+import { apiClient } from '@/lib/apiClient'
 import { Button } from '@/components/ui/button'
 import {
   Card,
@@ -82,11 +83,10 @@ export function LEDPage() {
   useEffect(() => {
     const fetchConfig = async () => {
       try {
-        const response = await fetch('/get_led_config')
-        const data = await response.json()
+        const data = await apiClient.get<{ provider?: string; wled_ip?: string; dw_led_num_leds?: number; dw_led_gpio_pin?: number }>('/get_led_config')
         // Map backend response fields to our interface
         setLedConfig({
-          provider: data.provider || 'none',
+          provider: (data.provider as LedConfig['provider']) || 'none',
           wled_ip: data.wled_ip,
           num_leds: data.dw_led_num_leds,
           gpio_pin: data.dw_led_gpio_pin,
@@ -112,8 +112,7 @@ export function LEDPage() {
 
   const fetchDWLedsStatus = async () => {
     try {
-      const response = await fetch('/api/dw_leds/status')
-      const data = await response.json()
+      const data = await apiClient.get<DWLedsStatus>('/api/dw_leds/status')
       setDwStatus(data)
       if (data.connected) {
         setBrightness(data.brightness || 35)
@@ -134,12 +133,10 @@ export function LEDPage() {
 
   const fetchEffectsAndPalettes = async () => {
     try {
-      const [effectsRes, palettesRes] = await Promise.all([
-        fetch('/api/dw_leds/effects'),
-        fetch('/api/dw_leds/palettes'),
+      const [effectsData, palettesData] = await Promise.all([
+        apiClient.get<{ effects?: [number, string][] }>('/api/dw_leds/effects'),
+        apiClient.get<{ palettes?: [number, string][] }>('/api/dw_leds/palettes'),
       ])
-      const effectsData = await effectsRes.json()
-      const palettesData = await palettesRes.json()
 
       if (effectsData.effects) {
         const sorted = [...effectsData.effects].sort((a, b) => a[1].localeCompare(b[1]))
@@ -156,8 +153,7 @@ export function LEDPage() {
 
   const fetchEffectSettings = async () => {
     try {
-      const response = await fetch('/api/dw_leds/get_effect_settings')
-      const data = await response.json()
+      const data = await apiClient.get<{ idle_effect?: EffectSettings; playing_effect?: EffectSettings }>('/api/dw_leds/get_effect_settings')
       setIdleEffect(data.idle_effect || null)
       setPlayingEffect(data.playing_effect || null)
     } catch (error) {
@@ -167,8 +163,7 @@ export function LEDPage() {
 
   const fetchIdleTimeout = async () => {
     try {
-      const response = await fetch('/api/dw_leds/idle_timeout')
-      const data = await response.json()
+      const data = await apiClient.get<{ enabled?: boolean; minutes?: number }>('/api/dw_leds/idle_timeout')
       setIdleTimeoutEnabled(data.enabled || false)
       setIdleTimeoutMinutes(data.minutes || 30)
     } catch (error) {
@@ -178,19 +173,14 @@ export function LEDPage() {
 
   const handlePowerToggle = async () => {
     try {
-      const response = await fetch('/api/dw_leds/power', {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ state: 2 }), // Toggle
-      })
-      const data = await response.json()
+      const data = await apiClient.post<{ connected?: boolean; power_on?: boolean; error?: string }>('/api/dw_leds/power', { state: 2 })
       if (data.connected) {
         toast.success(`Power ${data.power_on ? 'ON' : 'OFF'}`)
         await fetchDWLedsStatus()
       } else {
         toast.error(data.error || 'Failed to toggle power')
       }
-    } catch (error) {
+    } catch {
       toast.error('Failed to toggle power')
     }
   }
@@ -201,16 +191,11 @@ export function LEDPage() {
 
   const handleBrightnessCommit = async (value: number[]) => {
     try {
-      const response = await fetch('/api/dw_leds/brightness', {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ value: value[0] }),
-      })
-      const data = await response.json()
+      const data = await apiClient.post<{ connected?: boolean }>('/api/dw_leds/brightness', { value: value[0] })
       if (data.connected) {
         toast.success(`Brightness: ${value[0]}%`)
       }
-    } catch (error) {
+    } catch {
       toast.error('Failed to set brightness')
     }
   }
@@ -221,13 +206,9 @@ export function LEDPage() {
 
   const handleSpeedCommit = async (value: number[]) => {
     try {
-      await fetch('/api/dw_leds/speed', {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ speed: value[0] }),
-      })
+      await apiClient.post('/api/dw_leds/speed', { speed: value[0] })
       toast.success(`Speed: ${value[0]}`)
-    } catch (error) {
+    } catch {
       toast.error('Failed to set speed')
     }
   }
@@ -238,13 +219,9 @@ export function LEDPage() {
 
   const handleIntensityCommit = async (value: number[]) => {
     try {
-      await fetch('/api/dw_leds/intensity', {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ intensity: value[0] }),
-      })
+      await apiClient.post('/api/dw_leds/intensity', { intensity: value[0] })
       toast.success(`Intensity: ${value[0]}`)
-    } catch (error) {
+    } catch {
       toast.error('Failed to set intensity')
     }
   }
@@ -252,19 +229,15 @@ export function LEDPage() {
   const handleEffectChange = async (value: string) => {
     setSelectedEffect(value)
     try {
-      const response = await fetch('/api/dw_leds/effect', {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ effect_id: parseInt(value) }),
-      })
-      const data = await response.json()
+      const data = await apiClient.post<{ connected?: boolean; power_on?: boolean }>('/api/dw_leds/effect', { effect_id: parseInt(value) })
       if (data.connected) {
         toast.success('Effect changed')
         if (data.power_on !== undefined) {
-          setDwStatus((prev) => prev ? { ...prev, power_on: data.power_on } : null)
+          const powerOn = data.power_on
+          setDwStatus((prev) => prev ? { ...prev, power_on: powerOn } : null)
         }
       }
-    } catch (error) {
+    } catch {
       toast.error('Failed to set effect')
     }
   }
@@ -272,16 +245,11 @@ export function LEDPage() {
   const handlePaletteChange = async (value: string) => {
     setSelectedPalette(value)
     try {
-      const response = await fetch('/api/dw_leds/palette', {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ palette_id: parseInt(value) }),
-      })
-      const data = await response.json()
+      const data = await apiClient.post<{ connected?: boolean }>('/api/dw_leds/palette', { palette_id: parseInt(value) })
       if (data.connected) {
         toast.success('Palette changed')
       }
-    } catch (error) {
+    } catch {
       toast.error('Failed to set palette')
     }
   }
@@ -303,11 +271,7 @@ export function LEDPage() {
       const payload: Record<string, number[]> = {}
       payload[`color${slot}`] = hexToRgb(value)
 
-      await fetch('/api/dw_leds/colors', {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify(payload),
-      })
+      await apiClient.post('/api/dw_leds/colors', payload)
     } catch (error) {
       console.error('Failed to set color:', error)
     }
@@ -326,29 +290,20 @@ export function LEDPage() {
         color3,
       }
 
-      await fetch('/api/dw_leds/save_effect_settings', {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify(settings),
-      })
-
+      await apiClient.post('/api/dw_leds/save_effect_settings', settings)
       toast.success(`${type.charAt(0).toUpperCase() + type.slice(1)} effect saved`)
       await fetchEffectSettings()
-    } catch (error) {
+    } catch {
       toast.error(`Failed to save ${type} effect`)
     }
   }
 
   const clearEffectSettings = async (type: 'idle' | 'playing') => {
     try {
-      await fetch('/api/dw_leds/clear_effect_settings', {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ type }),
-      })
+      await apiClient.post('/api/dw_leds/clear_effect_settings', { type })
       toast.success(`${type.charAt(0).toUpperCase() + type.slice(1)} effect cleared`)
       await fetchEffectSettings()
-    } catch (error) {
+    } catch {
       toast.error(`Failed to clear ${type} effect`)
     }
   }
@@ -357,13 +312,9 @@ export function LEDPage() {
     const finalEnabled = enabled !== undefined ? enabled : idleTimeoutEnabled
     const finalMinutes = minutes !== undefined ? minutes : idleTimeoutMinutes
     try {
-      await fetch('/api/dw_leds/idle_timeout', {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ enabled: finalEnabled, minutes: finalMinutes }),
-      })
+      await apiClient.post('/api/dw_leds/idle_timeout', { enabled: finalEnabled, minutes: finalMinutes })
       toast.success(`Idle timeout ${finalEnabled ? 'enabled' : 'disabled'}`)
-    } catch (error) {
+    } catch {
       toast.error('Failed to save idle timeout')
     }
   }

+ 40 - 90
frontend/src/pages/PlaylistsPage.tsx

@@ -1,5 +1,6 @@
 import { useState, useEffect, useMemo, useCallback, useRef } from 'react'
 import { toast } from 'sonner'
+import { apiClient } from '@/lib/apiClient'
 import {
   initPreviewCacheDB,
   getPreviewsFromCache,
@@ -166,8 +167,7 @@ export function PlaylistsPage() {
   const fetchPlaylists = async () => {
     setIsLoadingPlaylists(true)
     try {
-      const response = await fetch('/list_all_playlists')
-      const data = await response.json()
+      const data = await apiClient.get<string[]>('/list_all_playlists')
       // Backend returns array directly, not { playlists: [...] }
       setPlaylists(Array.isArray(data) ? data : [])
     } catch (error) {
@@ -180,9 +180,7 @@ export function PlaylistsPage() {
 
   const fetchPlaylistPatterns = async (name: string) => {
     try {
-      const response = await fetch(`/get_playlist?name=${encodeURIComponent(name)}`)
-      if (!response.ok) throw new Error('Playlist not found')
-      const data = await response.json()
+      const data = await apiClient.get<{ files: string[] }>(`/get_playlist?name=${encodeURIComponent(name)}`)
       setPlaylistPatterns(data.files || [])
 
       // Load previews for playlist patterns
@@ -198,8 +196,7 @@ export function PlaylistsPage() {
 
   const fetchAllPatterns = async () => {
     try {
-      const response = await fetch('/list_theta_rho_files_with_metadata')
-      const data = await response.json()
+      const data = await apiClient.get<PatternMetadata[]>('/list_theta_rho_files_with_metadata')
       setAllPatterns(data)
     } catch (error) {
       console.error('Error fetching patterns:', error)
@@ -232,12 +229,7 @@ export function PlaylistsPage() {
       const batch = paths.slice(i, i + BATCH_SIZE)
 
       try {
-        const response = await fetch('/preview_thr_batch', {
-          method: 'POST',
-          headers: { 'Content-Type': 'application/json' },
-          body: JSON.stringify({ file_names: batch }),
-        })
-        const data = await response.json()
+        const data = await apiClient.post<Record<string, PreviewData>>('/preview_thr_batch', { file_names: batch })
 
         const newPreviews: Record<string, PreviewData> = {}
         for (const [path, previewData] of Object.entries(data)) {
@@ -292,21 +284,12 @@ export function PlaylistsPage() {
 
     const name = newPlaylistName.trim()
     try {
-      const response = await fetch('/create_playlist', {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ playlist_name: name, files: [] }),
-      })
-      if (response.ok) {
-        toast.success('Playlist created')
-        setIsCreateModalOpen(false)
-        setNewPlaylistName('')
-        await fetchPlaylists()
-        handleSelectPlaylist(name)
-      } else {
-        const data = await response.json()
-        throw new Error(data.detail || 'Failed to create playlist')
-      }
+      await apiClient.post('/create_playlist', { playlist_name: name, files: [] })
+      toast.success('Playlist created')
+      setIsCreateModalOpen(false)
+      setNewPlaylistName('')
+      await fetchPlaylists()
+      handleSelectPlaylist(name)
     } catch (error) {
       console.error('Create playlist error:', error)
       toast.error(error instanceof Error ? error.message : 'Failed to create playlist')
@@ -317,20 +300,14 @@ export function PlaylistsPage() {
     if (!playlistToRename || !newPlaylistName.trim()) return
 
     try {
-      const response = await fetch('/rename_playlist', {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ old_name: playlistToRename, new_name: newPlaylistName.trim() }),
-      })
-      if (response.ok) {
-        toast.success('Playlist renamed')
-        setIsRenameModalOpen(false)
-        setNewPlaylistName('')
-        setPlaylistToRename(null)
-        fetchPlaylists()
-        if (selectedPlaylist === playlistToRename) {
-          setSelectedPlaylist(newPlaylistName.trim())
-        }
+      await apiClient.post('/rename_playlist', { old_name: playlistToRename, new_name: newPlaylistName.trim() })
+      toast.success('Playlist renamed')
+      setIsRenameModalOpen(false)
+      setNewPlaylistName('')
+      setPlaylistToRename(null)
+      fetchPlaylists()
+      if (selectedPlaylist === playlistToRename) {
+        setSelectedPlaylist(newPlaylistName.trim())
       }
     } catch (error) {
       toast.error('Failed to rename playlist')
@@ -341,18 +318,12 @@ export function PlaylistsPage() {
     if (!confirm(`Delete playlist "${name}"?`)) return
 
     try {
-      const response = await fetch('/delete_playlist', {
-        method: 'DELETE',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ playlist_name: name }),
-      })
-      if (response.ok) {
-        toast.success('Playlist deleted')
-        fetchPlaylists()
-        if (selectedPlaylist === name) {
-          setSelectedPlaylist(null)
-          setPlaylistPatterns([])
-        }
+      await apiClient.delete('/delete_playlist', { playlist_name: name })
+      toast.success('Playlist deleted')
+      fetchPlaylists()
+      if (selectedPlaylist === name) {
+        setSelectedPlaylist(null)
+        setPlaylistPatterns([])
       }
     } catch (error) {
       toast.error('Failed to delete playlist')
@@ -364,15 +335,9 @@ export function PlaylistsPage() {
 
     const newPatterns = playlistPatterns.filter(p => p !== patternPath)
     try {
-      const response = await fetch('/modify_playlist', {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ playlist_name: selectedPlaylist, files: newPatterns }),
-      })
-      if (response.ok) {
-        setPlaylistPatterns(newPatterns)
-        toast.success('Pattern removed')
-      }
+      await apiClient.post('/modify_playlist', { playlist_name: selectedPlaylist, files: newPatterns })
+      setPlaylistPatterns(newPatterns)
+      toast.success('Pattern removed')
     } catch (error) {
       toast.error('Failed to remove pattern')
     }
@@ -396,17 +361,11 @@ export function PlaylistsPage() {
 
     const newPatterns = Array.from(selectedPatternPaths)
     try {
-      const response = await fetch('/modify_playlist', {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ playlist_name: selectedPlaylist, files: newPatterns }),
-      })
-      if (response.ok) {
-        setPlaylistPatterns(newPatterns)
-        setIsPickerOpen(false)
-        toast.success('Playlist updated')
-        loadPreviewsForPaths(newPatterns)
-      }
+      await apiClient.post('/modify_playlist', { playlist_name: selectedPlaylist, files: newPatterns })
+      setPlaylistPatterns(newPatterns)
+      setIsPickerOpen(false)
+      toast.success('Playlist updated')
+      loadPreviewsForPaths(newPatterns)
     } catch (error) {
       toast.error('Failed to update playlist')
     }
@@ -430,23 +389,14 @@ export function PlaylistsPage() {
 
     setIsRunning(true)
     try {
-      const response = await fetch('/run_playlist', {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({
-          playlist_name: selectedPlaylist,
-          run_mode: runMode === 'indefinite' ? 'indefinite' : 'single',
-          pause_time: getPauseTimeInSeconds(),
-          clear_pattern: clearPattern,
-          shuffle: shuffle,
-        }),
+      await apiClient.post('/run_playlist', {
+        playlist_name: selectedPlaylist,
+        run_mode: runMode === 'indefinite' ? 'indefinite' : 'single',
+        pause_time: getPauseTimeInSeconds(),
+        clear_pattern: clearPattern,
+        shuffle: shuffle,
       })
-      if (response.ok) {
-        toast.success(`Started playlist: ${selectedPlaylist}`)
-      } else {
-        const data = await response.json()
-        throw new Error(data.detail || 'Failed to run playlist')
-      }
+      toast.success(`Started playlist: ${selectedPlaylist}`)
     } catch (error) {
       toast.error(error instanceof Error ? error.message : 'Failed to run playlist')
     } finally {

+ 84 - 171
frontend/src/pages/SettingsPage.tsx

@@ -1,6 +1,7 @@
 import { useState, useEffect } from 'react'
 import { useSearchParams } from 'react-router-dom'
 import { toast } from 'sonner'
+import { apiClient } from '@/lib/apiClient'
 import { useOnBackendConnected } from '@/hooks/useBackendConnection'
 import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
@@ -240,8 +241,7 @@ export function SettingsPage() {
 
   const fetchPatternFiles = async () => {
     try {
-      const response = await fetch('/list_theta_rho_files')
-      const data = await response.json()
+      const data = await apiClient.get<string[]>('/list_theta_rho_files')
       // Response is a flat array of file paths
       setPatternFiles(Array.isArray(data) ? data : [])
     } catch (error) {
@@ -251,8 +251,7 @@ export function SettingsPage() {
 
   const fetchVersionInfo = async () => {
     try {
-      const response = await fetch('/api/version')
-      const data = await response.json()
+      const data = await apiClient.get<{ current: string; latest: string; update_available: boolean }>('/api/version')
       setVersionInfo(data)
     } catch (error) {
       console.error('Failed to fetch version info:', error)
@@ -291,13 +290,11 @@ export function SettingsPage() {
   const fetchPorts = async () => {
     try {
       // Fetch available ports
-      const portsResponse = await fetch('/list_serial_ports')
-      const portsData = await portsResponse.json()
+      const portsData = await apiClient.get<string[]>('/list_serial_ports')
       setPorts(portsData || [])
 
       // Fetch connection status
-      const statusResponse = await fetch('/serial_status')
-      const statusData = await statusResponse.json()
+      const statusData = await apiClient.get<{ connected: boolean; port?: string }>('/serial_status')
       setIsConnected(statusData.connected || false)
       setConnectionStatus(statusData.connected ? 'Connected' : 'Disconnected')
       if (statusData.port) {
@@ -320,8 +317,8 @@ export function SettingsPage() {
 
   const fetchSettings = async () => {
     try {
-      const response = await fetch('/api/settings')
-      const data = await response.json()
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      const data = await apiClient.get<Record<string, any>>('/api/settings')
       // Map the nested API response to our flat Settings interface
       setSettings({
         app_name: data.app?.name,
@@ -390,8 +387,8 @@ export function SettingsPage() {
 
   const fetchLedConfig = async () => {
     try {
-      const response = await fetch('/get_led_config')
-      const data = await response.json()
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      const data = await apiClient.get<Record<string, any>>('/get_led_config')
       setLedConfig({
         provider: data.provider || 'none',
         wled_ip: data.wled_ip,
@@ -406,8 +403,7 @@ export function SettingsPage() {
 
   const fetchPlaylists = async () => {
     try {
-      const response = await fetch('/list_all_playlists')
-      const data = await response.json()
+      const data = await apiClient.get('/list_all_playlists')
       // Backend returns array directly, not { playlists: [...] }
       setPlaylists(Array.isArray(data) ? data : [])
     } catch (error) {
@@ -422,12 +418,7 @@ export function SettingsPage() {
     }
     setIsLoading('connect')
     try {
-      const response = await fetch('/connect', {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ port: selectedPort }),
-      })
-      const data = await response.json()
+      const data = await apiClient.post<{ success?: boolean; message?: string }>('/connect', { port: selectedPort })
       if (data.success) {
         setIsConnected(true)
         setConnectionStatus(`Connected to ${selectedPort}`)
@@ -445,8 +436,7 @@ export function SettingsPage() {
   const handleDisconnect = async () => {
     setIsLoading('disconnect')
     try {
-      const response = await fetch('/disconnect', { method: 'POST' })
-      const data = await response.json()
+      const data = await apiClient.post<{ success?: boolean }>('/disconnect')
       if (data.success) {
         setIsConnected(false)
         setConnectionStatus('Disconnected')
@@ -462,20 +452,14 @@ export function SettingsPage() {
   const handleSavePreferredPort = async () => {
     setIsLoading('preferredPort')
     try {
-      const response = await fetch('/api/settings', {
-        method: 'PATCH',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({
-          connection: { preferred_port: settings.preferred_port || null },
-        }),
+      await apiClient.patch('/api/settings', {
+        connection: { preferred_port: settings.preferred_port || null },
       })
-      if (response.ok) {
-        toast.success(
-          settings.preferred_port
-            ? `Auto-connect set to ${settings.preferred_port}`
-            : 'Auto-connect disabled'
-        )
-      }
+      toast.success(
+        settings.preferred_port
+          ? `Auto-connect set to ${settings.preferred_port}`
+          : 'Auto-connect disabled'
+      )
     } catch (error) {
       toast.error('Failed to save preferred port')
     } finally {
@@ -486,14 +470,8 @@ export function SettingsPage() {
   const handleSaveAppName = async () => {
     setIsLoading('appName')
     try {
-      const response = await fetch('/api/settings', {
-        method: 'PATCH',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ app: { name: settings.app_name } }),
-      })
-      if (response.ok) {
-        toast.success('App name saved. Refresh to see changes.')
-      }
+      await apiClient.patch('/api/settings', { app: { name: settings.app_name } })
+      toast.success('App name saved. Refresh to see changes.')
     } catch (error) {
       toast.error('Failed to save app name')
     } finally {
@@ -527,23 +505,10 @@ export function SettingsPage() {
 
     setIsLoading('logo')
     try {
-      const formData = new FormData()
-      formData.append('file', file)
-
-      const response = await fetch('/api/upload-logo', {
-        method: 'POST',
-        body: formData,
-      })
-
-      if (response.ok) {
-        const data = await response.json()
-        setSettings({ ...settings, custom_logo: data.filename })
-        updateBranding(data.filename)
-        toast.success('Logo uploaded!')
-      } else {
-        const data = await response.json()
-        throw new Error(data.detail || 'Upload failed')
-      }
+      const data = await apiClient.uploadFile('/api/upload-logo', file, 'file') as { filename: string }
+      setSettings({ ...settings, custom_logo: data.filename })
+      updateBranding(data.filename)
+      toast.success('Logo uploaded!')
     } catch (error) {
       toast.error(error instanceof Error ? error.message : 'Failed to upload logo')
     } finally {
@@ -558,12 +523,10 @@ export function SettingsPage() {
 
     setIsLoading('logo')
     try {
-      const response = await fetch('/api/custom-logo', { method: 'DELETE' })
-      if (response.ok) {
-        setSettings({ ...settings, custom_logo: undefined })
-        updateBranding(null)
-        toast.success('Logo removed!')
-      }
+      await apiClient.delete('/api/custom-logo')
+      setSettings({ ...settings, custom_logo: undefined })
+      updateBranding(null)
+      toast.success('Logo removed!')
     } catch (error) {
       toast.error('Failed to remove logo')
     } finally {
@@ -575,23 +538,14 @@ export function SettingsPage() {
     setIsLoading('led')
     try {
       // Use the /set_led_config endpoint (deprecated but still works)
-      const response = await fetch('/set_led_config', {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({
-          provider: ledConfig.provider,
-          ip_address: ledConfig.wled_ip,
-          num_leds: ledConfig.num_leds,
-          gpio_pin: ledConfig.gpio_pin,
-          pixel_order: ledConfig.pixel_order,
-        }),
+      await apiClient.post('/set_led_config', {
+        provider: ledConfig.provider,
+        ip_address: ledConfig.wled_ip,
+        num_leds: ledConfig.num_leds,
+        gpio_pin: ledConfig.gpio_pin,
+        pixel_order: ledConfig.pixel_order,
       })
-      if (response.ok) {
-        toast.success('LED configuration saved')
-      } else {
-        const data = await response.json()
-        throw new Error(data.detail || 'Failed to save LED config')
-      }
+      toast.success('LED configuration saved')
     } catch (error) {
       toast.error(error instanceof Error ? error.message : 'Failed to save LED config')
     } finally {
@@ -602,26 +556,20 @@ export function SettingsPage() {
   const handleSaveMqttConfig = async () => {
     setIsLoading('mqtt')
     try {
-      const response = await fetch('/api/settings', {
-        method: 'PATCH',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({
-          mqtt: {
-            enabled: mqttConfig.enabled,
-            broker: mqttConfig.broker,
-            port: mqttConfig.port,
-            username: mqttConfig.username,
-            password: mqttConfig.password,
-            device_name: mqttConfig.device_name,
-            device_id: mqttConfig.device_id,
-            client_id: mqttConfig.client_id,
-            discovery_prefix: mqttConfig.discovery_prefix,
-          },
-        }),
+      await apiClient.patch('/api/settings', {
+        mqtt: {
+          enabled: mqttConfig.enabled,
+          broker: mqttConfig.broker,
+          port: mqttConfig.port,
+          username: mqttConfig.username,
+          password: mqttConfig.password,
+          device_name: mqttConfig.device_name,
+          device_id: mqttConfig.device_id,
+          client_id: mqttConfig.client_id,
+          discovery_prefix: mqttConfig.discovery_prefix,
+        },
       })
-      if (response.ok) {
-        toast.success('MQTT configuration saved. Restart required.')
-      }
+      toast.success('MQTT configuration saved. Restart required.')
     } catch (error) {
       toast.error('Failed to save MQTT config')
     } finally {
@@ -636,17 +584,12 @@ export function SettingsPage() {
     }
     setIsLoading('mqttTest')
     try {
-      const response = await fetch('/api/mqtt-test', {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({
-          broker: mqttConfig.broker,
-          port: mqttConfig.port || 1883,
-          username: mqttConfig.username || '',
-          password: mqttConfig.password || '',
-        }),
+      const data = await apiClient.post<{ success?: boolean; error?: string }>('/api/mqtt-test', {
+        broker: mqttConfig.broker,
+        port: mqttConfig.port || 1883,
+        username: mqttConfig.username || '',
+        password: mqttConfig.password || '',
       })
-      const data = await response.json()
       if (data.success) {
         toast.success('MQTT connection successful!')
       } else {
@@ -662,18 +605,12 @@ export function SettingsPage() {
   const handleSaveMachineSettings = async () => {
     setIsLoading('machine')
     try {
-      const response = await fetch('/api/settings', {
-        method: 'PATCH',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({
-          machine: {
-            table_type_override: settings.table_type_override || '',
-          },
-        }),
+      await apiClient.patch('/api/settings', {
+        machine: {
+          table_type_override: settings.table_type_override || '',
+        },
       })
-      if (response.ok) {
-        toast.success('Machine settings saved')
-      }
+      toast.success('Machine settings saved')
     } catch (error) {
       toast.error('Failed to save machine settings')
     } finally {
@@ -684,21 +621,15 @@ export function SettingsPage() {
   const handleSaveHomingConfig = async () => {
     setIsLoading('homing')
     try {
-      const response = await fetch('/api/settings', {
-        method: 'PATCH',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({
-          homing: {
-            mode: settings.homing_mode,
-            angular_offset_degrees: settings.angular_offset,
-            auto_home_enabled: settings.auto_home_enabled,
-            auto_home_after_patterns: settings.auto_home_after_patterns,
-          },
-        }),
+      await apiClient.patch('/api/settings', {
+        homing: {
+          mode: settings.homing_mode,
+          angular_offset_degrees: settings.angular_offset,
+          auto_home_enabled: settings.auto_home_enabled,
+          auto_home_after_patterns: settings.auto_home_after_patterns,
+        },
       })
-      if (response.ok) {
-        toast.success('Homing configuration saved')
-      }
+      toast.success('Homing configuration saved')
     } catch (error) {
       toast.error('Failed to save homing configuration')
     } finally {
@@ -709,21 +640,15 @@ export function SettingsPage() {
   const handleSaveClearingSettings = async () => {
     setIsLoading('clearing')
     try {
-      const response = await fetch('/api/settings', {
-        method: 'PATCH',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({
-          patterns: {
-            // Send 0 to indicate "reset to default" - backend interprets 0 or negative as None
-            clear_pattern_speed: settings.clear_pattern_speed ?? 0,
-            custom_clear_from_in: settings.custom_clear_from_in || null,
-            custom_clear_from_out: settings.custom_clear_from_out || null,
-          },
-        }),
+      await apiClient.patch('/api/settings', {
+        patterns: {
+          // Send 0 to indicate "reset to default" - backend interprets 0 or negative as None
+          clear_pattern_speed: settings.clear_pattern_speed ?? 0,
+          custom_clear_from_in: settings.custom_clear_from_in || null,
+          custom_clear_from_out: settings.custom_clear_from_out || null,
+        },
       })
-      if (response.ok) {
-        toast.success('Clearing settings saved')
-      }
+      toast.success('Clearing settings saved')
     } catch (error) {
       toast.error('Failed to save clearing settings')
     } finally {
@@ -736,19 +661,13 @@ export function SettingsPage() {
     try {
       // Convert pause value + unit to seconds
       const pauseTimeSeconds = displayPauseToSeconds(autoPlayPauseValue, autoPlayPauseUnit)
-      const response = await fetch('/api/settings', {
-        method: 'PATCH',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({
-          auto_play: {
-            ...autoPlaySettings,
-            pause_time: pauseTimeSeconds,
-          },
-        }),
+      await apiClient.patch('/api/settings', {
+        auto_play: {
+          ...autoPlaySettings,
+          pause_time: pauseTimeSeconds,
+        },
       })
-      if (response.ok) {
-        toast.success('Auto-play settings saved')
-      }
+      toast.success('Auto-play settings saved')
     } catch (error) {
       toast.error('Failed to save auto-play settings')
     } finally {
@@ -759,16 +678,10 @@ export function SettingsPage() {
   const handleSaveStillSandsSettings = async () => {
     setIsLoading('stillsands')
     try {
-      const response = await fetch('/api/settings', {
-        method: 'PATCH',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({
-          scheduled_pause: stillSandsSettings,
-        }),
+      await apiClient.patch('/api/settings', {
+        scheduled_pause: stillSandsSettings,
       })
-      if (response.ok) {
-        toast.success('Still Sands settings saved')
-      }
+      toast.success('Still Sands settings saved')
     } catch (error) {
       toast.error('Failed to save Still Sands settings')
     } finally {

+ 28 - 20
frontend/src/pages/TableControlPage.tsx

@@ -49,25 +49,38 @@ export function TableControlPage() {
 
   // Connect to status WebSocket to get current speed and playback status
   useEffect(() => {
-    const ws = new WebSocket(apiClient.getWebSocketUrl('/ws/status'))
-
-    ws.onmessage = (event) => {
-      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)
+    let ws: WebSocket | null = null
+
+    const connect = () => {
+      ws = new WebSocket(apiClient.getWebSocketUrl('/ws/status'))
+
+      ws.onmessage = (event) => {
+        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)
           }
-          // 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)
         }
-      } catch (error) {
-        console.error('Failed to parse status:', error)
       }
     }
 
+    connect()
+
+    // Reconnect when table changes
+    const unsubscribe = apiClient.onBaseUrlChange(() => {
+      if (ws) ws.close()
+      connect()
+    })
+
     return () => {
-      ws.close()
+      unsubscribe()
+      if (ws) ws.close()
     }
   }, [])
 
@@ -78,13 +91,8 @@ export function TableControlPage() {
   ) => {
     setIsLoading(action)
     try {
-      const response = await fetch(endpoint, {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        ...(body && { body: JSON.stringify(body) }),
-      })
-      const data = await response.json()
-      if (data.success || response.ok) {
+      const data = await apiClient.post<{ success?: boolean; detail?: string }>(endpoint, body)
+      if (data.success !== false) {
         return { success: true, data }
       }
       throw new Error(data.detail || 'Action failed')

+ 2 - 2
modules/led/dw_led_controller.py

@@ -586,8 +586,8 @@ def effect_idle(controller: DWLEDController, effect_settings: Optional[dict] = N
                     color3=(r3, g3, b3)
                 )
         else:
-            # Default: Rainbow effect with current controller parameters
-            controller.set_effect(8, speed=controller._speed, intensity=controller._intensity)
+            # Default: Rainbow effect with speed 60 for smoother animation
+            controller.set_effect(8, speed=60, intensity=controller._intensity)
             controller.set_colors(
                 color1=controller._color1,
                 color2=controller._color2,