Explorar o código

Improve mobile UX with swipe gestures and UI refinements

- Add swipe gestures to NowPlayingBar (up to expand, down to collapse/close)
- Add swipe-to-close for BrowsePage pattern detail panel on mobile
- Add marquee animation for long pattern names in mini Now Playing bar
- Fix expanded view positioning to align with header
- Remove play button from header (floating button remains)
- Add localStorage caching for playlist settings and selected playlist
- Fix Settings page version loading with lazy fetch
- Reorganize TableControlPage into 4-card layout with Position card
- Add WebSocket connection to TableControlPage for speed sync
- Improve WebSocket proxy error handling in vite.config.ts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
tuanchris hai 3 semanas
pai
achega
15bfe4dbe5

+ 597 - 203
frontend/src/components/NowPlayingBar.tsx

@@ -1,9 +1,11 @@
-import { useState, useEffect, useRef } from 'react'
+import { useState, useEffect, useRef, useCallback } from 'react'
 import { toast } from 'sonner'
 import { Button } from '@/components/ui/button'
 import { Progress } from '@/components/ui/progress'
 import { Input } from '@/components/ui/input'
 
+type Coordinate = [number, number]
+
 interface PlaybackStatus {
   current_file: string | null
   is_paused: boolean
@@ -48,15 +50,87 @@ function formatPatternName(path: string | null): string {
 interface NowPlayingBarProps {
   isLogsOpen?: boolean
   isVisible: boolean
+  openExpanded?: boolean
   onClose: () => void
 }
 
-export function NowPlayingBar({ isLogsOpen = false, isVisible, onClose }: NowPlayingBarProps) {
+export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = false, onClose }: NowPlayingBarProps) {
   const [status, setStatus] = useState<PlaybackStatus | null>(null)
-  const [isExpanded, setIsExpanded] = useState(false)
   const [previewUrl, setPreviewUrl] = useState<string | null>(null)
   const wsRef = useRef<WebSocket | null>(null)
 
+  // Expanded state for slide-up view
+  const [isExpanded, setIsExpanded] = useState(false)
+
+  // Swipe gesture handling
+  const touchStartY = useRef<number | null>(null)
+  const barRef = useRef<HTMLDivElement>(null)
+
+  const handleTouchStart = (e: React.TouchEvent) => {
+    touchStartY.current = e.touches[0].clientY
+  }
+  const handleTouchEnd = (e: React.TouchEvent) => {
+    if (touchStartY.current === null) return
+    const touchEndY = e.changedTouches[0].clientY
+    const deltaY = touchEndY - touchStartY.current
+
+    if (deltaY > 50) {
+      // Swipe down
+      if (isExpanded) {
+        setIsExpanded(false) // Collapse to mini
+      } else {
+        onClose() // Hide the bar
+      }
+    } else if (deltaY < -50 && isPlaying) {
+      // Swipe up - expand (only if playing)
+      setIsExpanded(true)
+    }
+    touchStartY.current = null
+  }
+
+  // Use native event listener for touchmove to prevent background scroll
+  useEffect(() => {
+    const bar = barRef.current
+    if (!bar) return
+
+    const handleTouchMove = (e: TouchEvent) => {
+      e.preventDefault()
+    }
+
+    bar.addEventListener('touchmove', handleTouchMove, { passive: false })
+    return () => {
+      bar.removeEventListener('touchmove', handleTouchMove)
+    }
+  }, [])
+
+  // Open in expanded mode when openExpanded prop changes to true
+  useEffect(() => {
+    if (openExpanded && isVisible) {
+      setIsExpanded(true)
+    }
+  }, [openExpanded, isVisible])
+
+  // Auto-collapse when nothing is playing
+  const isPlaying = status?.is_running || status?.is_paused
+  useEffect(() => {
+    if (!isPlaying && isExpanded) {
+      setIsExpanded(false)
+    }
+  }, [isPlaying, isExpanded])
+
+  const [coordinates, setCoordinates] = useState<Coordinate[]>([])
+  const canvasRef = useRef<HTMLCanvasElement>(null)
+  const offscreenCanvasRef = useRef<HTMLCanvasElement | null>(null)
+  const lastDrawnIndexRef = useRef<number>(-1)
+  const lastFileRef = useRef<string | null>(null)
+  const lastThemeRef = useRef<boolean | null>(null)
+
+  // Smooth animation refs
+  const animationFrameRef = useRef<number | null>(null)
+  const lastProgressRef = useRef<number>(0)
+  const lastProgressTimeRef = useRef<number>(0)
+  const smoothProgressRef = useRef<number>(0)
+
   // Connect to status WebSocket
   useEffect(() => {
     const connectWebSocket = () => {
@@ -129,6 +203,255 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, onClose }: NowPla
     }
   }, [status?.current_file, status?.playlist?.next_file])
 
+  // Canvas drawing functions for real-time preview
+  const polarToCartesian = useCallback((theta: number, rho: number, size: number) => {
+    const centerX = size / 2
+    const centerY = size / 2
+    const radius = (size / 2) * 0.9 * rho
+    const x = centerX + radius * Math.cos(theta)
+    const y = centerY + radius * Math.sin(theta)
+    return { x, y }
+  }, [])
+
+  const getThemeColors = useCallback(() => {
+    const isDark = document.documentElement.classList.contains('dark')
+    return {
+      isDark,
+      bgOuter: isDark ? '#1a1a1a' : '#f5f5f5',
+      bgInner: isDark ? '#262626' : '#ffffff',
+      borderColor: isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(128, 128, 128, 0.3)',
+      lineColor: isDark ? '#e5e5e5' : '#333333',
+      markerBorder: isDark ? '#333333' : '#ffffff',
+    }
+  }, [])
+
+  const initOffscreenCanvas = useCallback((size: number, coords: Coordinate[]) => {
+    const colors = getThemeColors()
+
+    if (!offscreenCanvasRef.current) {
+      offscreenCanvasRef.current = document.createElement('canvas')
+    }
+
+    const offscreen = offscreenCanvasRef.current
+    offscreen.width = size
+    offscreen.height = size
+
+    const ctx = offscreen.getContext('2d')
+    if (!ctx) return
+
+    ctx.fillStyle = colors.bgOuter
+    ctx.fillRect(0, 0, size, size)
+
+    ctx.beginPath()
+    ctx.arc(size / 2, size / 2, (size / 2) * 0.95, 0, Math.PI * 2)
+    ctx.fillStyle = colors.bgInner
+    ctx.fill()
+    ctx.strokeStyle = colors.borderColor
+    ctx.lineWidth = 1
+    ctx.stroke()
+
+    ctx.strokeStyle = colors.lineColor
+    ctx.lineWidth = 1.5
+    ctx.lineCap = 'round'
+    ctx.lineJoin = 'round'
+
+    if (coords.length > 0) {
+      const firstPoint = polarToCartesian(coords[0][0], coords[0][1], size)
+      ctx.beginPath()
+      ctx.moveTo(firstPoint.x, firstPoint.y)
+      ctx.stroke()
+    }
+
+    lastDrawnIndexRef.current = 0
+    lastThemeRef.current = colors.isDark
+  }, [getThemeColors, polarToCartesian])
+
+  const drawPattern = useCallback((ctx: CanvasRenderingContext2D, coords: Coordinate[], smoothIndex: number, forceRedraw = false) => {
+    const canvas = ctx.canvas
+    const size = canvas.width
+    const colors = getThemeColors()
+
+    // Apply 16 coordinate offset for physical latency
+    const adjustedSmoothIndex = Math.max(0, smoothIndex - 16)
+    const adjustedIndex = Math.floor(adjustedSmoothIndex)
+
+    const needsReinit = forceRedraw ||
+      !offscreenCanvasRef.current ||
+      lastThemeRef.current !== colors.isDark ||
+      adjustedIndex < lastDrawnIndexRef.current
+
+    if (needsReinit) {
+      initOffscreenCanvas(size, coords)
+    }
+
+    const offscreen = offscreenCanvasRef.current
+    if (!offscreen) return
+
+    const offCtx = offscreen.getContext('2d')
+    if (!offCtx) return
+
+    if (coords.length > 0 && adjustedIndex > lastDrawnIndexRef.current) {
+      offCtx.strokeStyle = colors.lineColor
+      offCtx.lineWidth = 1.5
+      offCtx.lineCap = 'round'
+      offCtx.lineJoin = 'round'
+
+      offCtx.beginPath()
+      const startPoint = polarToCartesian(
+        coords[lastDrawnIndexRef.current][0],
+        coords[lastDrawnIndexRef.current][1],
+        size
+      )
+      offCtx.moveTo(startPoint.x, startPoint.y)
+
+      for (let i = lastDrawnIndexRef.current + 1; i <= adjustedIndex && i < coords.length; i++) {
+        const point = polarToCartesian(coords[i][0], coords[i][1], size)
+        offCtx.lineTo(point.x, point.y)
+      }
+      offCtx.stroke()
+
+      lastDrawnIndexRef.current = adjustedIndex
+    }
+
+    ctx.drawImage(offscreen, 0, 0)
+
+    // Draw current position marker with smooth interpolation between coordinates
+    if (coords.length > 0 && adjustedIndex < coords.length - 1) {
+      const fraction = adjustedSmoothIndex - adjustedIndex
+      const currentCoord = coords[adjustedIndex]
+      const nextCoord = coords[Math.min(adjustedIndex + 1, coords.length - 1)]
+
+      // Interpolate theta and rho
+      const interpTheta = currentCoord[0] + (nextCoord[0] - currentCoord[0]) * fraction
+      const interpRho = currentCoord[1] + (nextCoord[1] - currentCoord[1]) * fraction
+
+      const currentPoint = polarToCartesian(interpTheta, interpRho, size)
+      ctx.beginPath()
+      ctx.arc(currentPoint.x, currentPoint.y, 8, 0, Math.PI * 2)
+      ctx.fillStyle = '#0b80ee'
+      ctx.fill()
+      ctx.strokeStyle = colors.markerBorder
+      ctx.lineWidth = 2
+      ctx.stroke()
+    } else if (coords.length > 0 && adjustedIndex < coords.length) {
+      // At the last coordinate, just draw without interpolation
+      const currentPoint = polarToCartesian(coords[adjustedIndex][0], coords[adjustedIndex][1], size)
+      ctx.beginPath()
+      ctx.arc(currentPoint.x, currentPoint.y, 8, 0, Math.PI * 2)
+      ctx.fillStyle = '#0b80ee'
+      ctx.fill()
+      ctx.strokeStyle = colors.markerBorder
+      ctx.lineWidth = 2
+      ctx.stroke()
+    }
+  }, [getThemeColors, initOffscreenCanvas, polarToCartesian])
+
+  // Fetch coordinates when file changes or fullscreen opens
+  useEffect(() => {
+    const currentFile = status?.current_file
+    if (!currentFile) return
+
+    // Only fetch if file changed or we don't have coordinates yet
+    const needsFetch = currentFile !== lastFileRef.current || coordinates.length === 0
+
+    if (!needsFetch) return
+
+    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())
+      .then((data) => {
+        if (data.coordinates && Array.isArray(data.coordinates)) {
+          setCoordinates(data.coordinates)
+        }
+      })
+      .catch((err) => {
+        console.error('Failed to fetch coordinates:', err)
+        setCoordinates([])
+      })
+  }, [status?.current_file, coordinates.length])
+
+  // Get target index from progress percentage
+  const getTargetIndex = useCallback((coords: Coordinate[]): number => {
+    if (coords.length === 0) return 0
+    const progressPercent = status?.progress?.percentage || 0
+    return (progressPercent / 100) * coords.length
+  }, [status?.progress?.percentage])
+
+  // Track progress updates for smooth interpolation
+  useEffect(() => {
+    const currentProgress = status?.progress?.percentage || 0
+    if (currentProgress !== lastProgressRef.current) {
+      lastProgressRef.current = currentProgress
+      lastProgressTimeRef.current = performance.now()
+    }
+  }, [status?.progress?.percentage])
+
+  // Smooth animation loop
+  useEffect(() => {
+    if (!isExpanded || coordinates.length === 0) return
+
+    const isPaused = status?.is_paused || false
+    const coordsPerSecond = 4.2
+
+    const animate = () => {
+      if (!canvasRef.current) return
+
+      const ctx = canvasRef.current.getContext('2d')
+      if (!ctx) return
+
+      const targetIndex = getTargetIndex(coordinates)
+      const now = performance.now()
+      const timeSinceUpdate = (now - lastProgressTimeRef.current) / 1000
+
+      let smoothIndex: number
+      if (isPaused) {
+        // When paused, just use the target index directly
+        smoothIndex = targetIndex
+      } else {
+        // Interpolate: start from where we were at last update, advance based on time
+        const baseIndex = (lastProgressRef.current / 100) * coordinates.length
+        smoothIndex = baseIndex + (timeSinceUpdate * coordsPerSecond)
+        // Don't overshoot the target too much
+        smoothIndex = Math.min(smoothIndex, targetIndex + 2)
+      }
+
+      smoothProgressRef.current = smoothIndex
+      drawPattern(ctx, coordinates, smoothIndex)
+
+      animationFrameRef.current = requestAnimationFrame(animate)
+    }
+
+    // Initial draw with force redraw
+    const timer = setTimeout(() => {
+      if (!canvasRef.current) return
+      const ctx = canvasRef.current.getContext('2d')
+      if (!ctx) return
+
+      lastDrawnIndexRef.current = -1
+      offscreenCanvasRef.current = null
+      smoothProgressRef.current = getTargetIndex(coordinates)
+      lastProgressTimeRef.current = performance.now()
+
+      drawPattern(ctx, coordinates, smoothProgressRef.current, true)
+
+      // Start animation loop
+      animationFrameRef.current = requestAnimationFrame(animate)
+    }, 50)
+
+    return () => {
+      clearTimeout(timer)
+      if (animationFrameRef.current) {
+        cancelAnimationFrame(animationFrameRef.current)
+      }
+    }
+  }, [isExpanded, coordinates, status?.is_paused, drawPattern, getTargetIndex])
+
   const handlePause = async () => {
     try {
       const endpoint = status?.is_paused ? '/resume_execution' : '/pause_execution'
@@ -187,7 +510,6 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, onClose }: NowPla
     return null
   }
 
-  const isPlaying = status?.is_running || status?.is_paused
   const patternName = formatPatternName(status?.current_file ?? null)
   const progressPercent = status?.progress?.percentage || 0
   const remainingTime = status?.progress?.remaining_time || 0
@@ -203,231 +525,303 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, onClose }: NowPla
         />
       )}
 
-      {/* Now Playing Bar */}
+      {/* Now Playing Bar - slides up to full height on mobile, 50vh on desktop when expanded */}
       <div
-        className={`fixed left-0 right-0 z-40 bg-background border-t shadow-lg transition-all duration-300 ${
-          isExpanded ? 'rounded-t-xl' : ''
-        } ${isLogsOpen ? 'bottom-80' : 'bottom-16'}`}
+        ref={barRef}
+        className={`fixed left-0 right-0 z-40 bg-background border-t shadow-lg transition-all duration-300 ${isLogsOpen ? 'bottom-80' : isExpanded ? 'bottom-16' : 'bottom-20'}`}
+        style={{ height: isExpanded ? 'calc(100vh - 64px - 64px)' : '256px' }}
+        onTouchStart={handleTouchStart}
+        onTouchEnd={handleTouchEnd}
       >
-        {/* Mini Bar (always visible) */}
-        <div className="flex gap-5 px-5 py-4">
-          {/* Current Pattern Preview - Rounded */}
-          <div
-            className="w-32 h-32 rounded-full overflow-hidden bg-muted shrink-0 border-2 cursor-pointer"
-            onClick={() => isPlaying && setIsExpanded(!isExpanded)}
+        {/* Swipe indicator - only on mobile */}
+        <div className="md:hidden flex justify-center pt-2 pb-1">
+          <div className="w-10 h-1 bg-muted-foreground/30 rounded-full" />
+        </div>
+
+        {/* Header with action buttons */}
+        <div className="absolute top-3 right-3 flex items-center gap-1 z-10">
+          {isPlaying && (
+            <Button
+              variant="ghost"
+              size="icon"
+              className="h-8 w-8"
+              onClick={() => setIsExpanded(!isExpanded)}
+              title={isExpanded ? 'Collapse' : 'Expand'}
+            >
+              <span className="material-icons-outlined text-lg">
+                {isExpanded ? 'expand_more' : 'expand_less'}
+              </span>
+            </Button>
+          )}
+          <Button
+            variant="ghost"
+            size="icon"
+            className="h-8 w-8"
+            onClick={onClose}
+            title="Close"
           >
-            {previewUrl && isPlaying ? (
-              <img
-                src={previewUrl}
-                alt={patternName}
-                className="w-full h-full object-cover pattern-preview"
-              />
-            ) : (
-              <div className="w-full h-full flex items-center justify-center">
-                <span className="material-icons-outlined text-muted-foreground text-4xl">
-                  {isPlaying ? 'image' : 'hourglass_empty'}
-                </span>
-              </div>
-            )}
-          </div>
-
-          {/* Main Content Area */}
-          {isPlaying && status ? (
-            <>
-              <div className="flex-1 min-w-0 flex flex-col justify-between py-1">
-                {/* Top Row: Title + Controls */}
-                <div className="flex items-center gap-3">
-                  <div className="flex-1 min-w-0">
-                    <p className="text-lg font-semibold truncate">{patternName}</p>
-                    {status.playlist && (
-                      <p className="text-xs text-muted-foreground">
-                        Pattern {status.playlist.current_index + 1} of {status.playlist.total_files}
-                      </p>
-                    )}
-                  </div>
-                  {status.is_paused && (
-                    <span className="text-xs bg-amber-500/20 text-amber-600 dark:text-amber-400 px-2 py-1 rounded font-medium">Paused</span>
-                  )}
-                  <div className="flex items-center shrink-0">
-                    <Button variant="ghost" size="icon" className="h-10 w-10" onClick={handlePause}>
-                      <span className="material-icons text-xl">
-                        {status.is_paused ? 'play_arrow' : 'pause'}
+            <span className="material-icons-outlined text-lg">close</span>
+          </Button>
+        </div>
+
+        {/* Content container */}
+        <div className="h-full flex flex-col">
+          {/* Collapsed view - Mini Bar */}
+          {!isExpanded && (
+            <div className="flex-1 flex flex-col">
+              {/* Main row with preview and controls */}
+              <div className="flex-1 flex items-center gap-6 px-6">
+                {/* Current Pattern Preview - Rounded */}
+                <div className="w-48 h-48 rounded-full overflow-hidden bg-muted shrink-0 border-2">
+                  {previewUrl && isPlaying ? (
+                    <img
+                      src={previewUrl}
+                      alt={patternName}
+                      className="w-full h-full object-cover pattern-preview"
+                    />
+                  ) : (
+                    <div className="w-full h-full flex items-center justify-center">
+                      <span className="material-icons-outlined text-muted-foreground text-4xl">
+                        {isPlaying ? 'image' : 'hourglass_empty'}
                       </span>
-                    </Button>
-                    {status.playlist && (
-                      <Button variant="ghost" size="icon" className="h-10 w-10" onClick={handleSkip}>
-                        <span className="material-icons text-xl">skip_next</span>
-                      </Button>
+                    </div>
+                  )}
+                </div>
+
+                {/* Main Content Area */}
+                {isPlaying && status ? (
+                  <>
+                    <div className="flex-1 min-w-0 flex flex-col justify-center gap-2 py-2">
+                      {/* Title Row */}
+                      <div className="flex items-center gap-3 pr-12 md:pr-16">
+                        <div className="flex-1 min-w-0">
+                          <div className="marquee-container">
+                            <p className="text-sm md:text-base font-semibold whitespace-nowrap animate-marquee">
+                              {patternName}
+                            </p>
+                          </div>
+                          {status.playlist && (
+                            <p className="text-xs text-muted-foreground">
+                              Pattern {status.playlist.current_index + 1} of {status.playlist.total_files}
+                            </p>
+                          )}
+                        </div>
+                        {status.is_paused && (
+                          <span className="text-xs bg-amber-500/20 text-amber-600 dark:text-amber-400 px-2 py-1 rounded font-medium shrink-0">Paused</span>
+                        )}
+                      </div>
+
+                      {/* Playback Controls - Centered */}
+                      <div className="flex items-center justify-center gap-3">
+                        <Button
+                          variant="outline"
+                          size="icon"
+                          className="h-10 w-10 rounded-full"
+                          onClick={handleStop}
+                          title="Stop"
+                        >
+                          <span className="material-icons">stop</span>
+                        </Button>
+                        <Button
+                          variant="default"
+                          size="icon"
+                          className="h-12 w-12 rounded-full"
+                          onClick={handlePause}
+                        >
+                          <span className="material-icons text-xl">
+                            {status.is_paused ? 'play_arrow' : 'pause'}
+                          </span>
+                        </Button>
+                        {status.playlist && (
+                          <Button
+                            variant="outline"
+                            size="icon"
+                            className="h-10 w-10 rounded-full"
+                            onClick={handleSkip}
+                            title="Skip to next"
+                          >
+                            <span className="material-icons">skip_next</span>
+                          </Button>
+                        )}
+                      </div>
+
+                      {/* Speed Control */}
+                      <div className="flex items-center justify-center gap-2">
+                        <span className="text-sm text-muted-foreground">Speed:</span>
+                        <Input
+                          type="number"
+                          placeholder={String(status.speed)}
+                          value={speedInput}
+                          onChange={(e) => setSpeedInput(e.target.value)}
+                          onKeyDown={(e) => e.key === 'Enter' && handleSpeedSubmit()}
+                          className="h-7 w-20 text-sm px-2"
+                        />
+                        <span className="text-sm text-muted-foreground">mm/s</span>
+                      </div>
+                    </div>
+
+                    {/* Next Pattern Preview - hidden on mobile */}
+                    {status.playlist?.next_file && (
+                      <div className="hidden md:flex shrink-0 flex-col items-center gap-1">
+                        <p className="text-xs text-muted-foreground font-medium">Up Next</p>
+                        <div className="w-24 h-24 rounded-full overflow-hidden bg-muted border-2">
+                          {nextPreviewUrl ? (
+                            <img
+                              src={nextPreviewUrl}
+                              alt="Next pattern"
+                              className="w-full h-full object-cover pattern-preview"
+                            />
+                          ) : (
+                            <div className="w-full h-full flex items-center justify-center">
+                              <span className="material-icons-outlined text-muted-foreground text-2xl">image</span>
+                            </div>
+                          )}
+                        </div>
+                        <p className="text-xs text-muted-foreground text-center max-w-24 truncate">
+                          {formatPatternName(status.playlist.next_file)}
+                        </p>
+                      </div>
                     )}
-                    <Button variant="ghost" size="icon" className="h-10 w-10" onClick={handleStop}>
-                      <span className="material-icons text-xl">stop</span>
-                    </Button>
+                  </>
+                ) : (
+                  <div className="flex-1 flex items-center">
+                    <p className="text-lg text-muted-foreground">Not playing</p>
                   </div>
-                </div>
+                )}
+              </div>
 
-                {/* Middle Row: Progress */}
-                <div className="flex items-center gap-3">
+              {/* Progress Bar - Full width at bottom */}
+              {isPlaying && status && (
+                <div className="flex items-center gap-3 px-6 pb-3">
                   <span className="text-sm text-muted-foreground w-12 font-mono">{formatTime(elapsedTime)}</span>
                   <Progress value={progressPercent} className="h-2 flex-1" />
                   <span className="text-sm text-muted-foreground w-12 text-right font-mono">-{formatTime(remainingTime)}</span>
                 </div>
+              )}
+            </div>
+          )}
+
+          {/* Expanded view - Real-time canvas preview */}
+          {isExpanded && isPlaying && (
+            <div className="flex-1 flex flex-col md:justify-center px-4 py-2 md:py-4 overflow-hidden">
+              <div className="w-full max-w-5xl mx-auto flex flex-col md:flex-row gap-3 md:gap-6 md:-ml-16">
+                {/* Canvas - full width on mobile */}
+                <div className="flex items-center justify-center flex-1 min-h-0">
+                  <canvas
+                    ref={canvasRef}
+                    width={600}
+                    height={600}
+                    className="w-full max-h-full rounded-full border-2 md:w-auto"
+                    style={{ aspectRatio: '1/1', maxHeight: '40vh' }}
+                  />
+                </div>
 
-                {/* Bottom Row: Speed */}
-                <div className="flex items-center gap-2">
+                {/* Controls */}
+                <div className="md:w-80 shrink-0 flex flex-col justify-start md:justify-center gap-2 md:gap-4">
+                {/* Pattern Info */}
+                <div className="text-center md:text-left">
+                  <h2 className="text-lg md:text-xl font-semibold truncate">{patternName}</h2>
+                  {status?.playlist && (
+                    <p className="text-sm text-muted-foreground">
+                      Pattern {status.playlist.current_index + 1} of {status.playlist.total_files}
+                    </p>
+                  )}
+                </div>
+
+                {/* Progress */}
+                <div className="space-y-1 md:space-y-2">
+                  <Progress value={progressPercent} className="h-1.5 md:h-2" />
+                  <div className="flex justify-between text-xs md:text-sm text-muted-foreground font-mono">
+                    <span>{formatTime(elapsedTime)}</span>
+                    <span>{progressPercent.toFixed(0)}%</span>
+                    <span>-{formatTime(remainingTime)}</span>
+                  </div>
+                </div>
+
+                {/* Playback Controls */}
+                <div className="flex items-center justify-center gap-2 md:gap-3">
+                  <Button
+                    variant="outline"
+                    size="icon"
+                    className="h-10 w-10 md:h-12 md:w-12 rounded-full"
+                    onClick={handleStop}
+                    title="Stop"
+                  >
+                    <span className="material-icons text-lg md:text-2xl">stop</span>
+                  </Button>
+                  <Button
+                    variant="default"
+                    size="icon"
+                    className="h-12 w-12 md:h-14 md:w-14 rounded-full"
+                    onClick={handlePause}
+                  >
+                    <span className="material-icons text-xl md:text-2xl">
+                      {status?.is_paused ? 'play_arrow' : 'pause'}
+                    </span>
+                  </Button>
+                  {status?.playlist && (
+                    <Button
+                      variant="outline"
+                      size="icon"
+                      className="h-10 w-10 md:h-12 md:w-12 rounded-full"
+                      onClick={handleSkip}
+                      title="Skip to next"
+                    >
+                      <span className="material-icons text-lg md:text-2xl">skip_next</span>
+                    </Button>
+                  )}
+                </div>
+
+                {/* Speed Control */}
+                <div className="flex items-center justify-center gap-2">
                   <span className="text-sm text-muted-foreground">Speed:</span>
                   <Input
                     type="number"
-                    placeholder={String(status.speed)}
+                    placeholder={String(status?.speed || 1000)}
                     value={speedInput}
                     onChange={(e) => setSpeedInput(e.target.value)}
                     onKeyDown={(e) => e.key === 'Enter' && handleSpeedSubmit()}
-                    className="h-7 w-20 text-sm px-2"
-                    onClick={(e) => e.stopPropagation()}
+                    className="h-8 w-24 text-sm px-2"
                   />
                   <span className="text-sm text-muted-foreground">mm/s</span>
                 </div>
-              </div>
 
-              {/* Next Pattern Preview */}
-              {status.playlist?.next_file && (
-                <div className="shrink-0 flex flex-col items-center gap-2">
-                  <p className="text-xs text-muted-foreground">Up Next</p>
-                  <div className="w-20 h-20 rounded-full overflow-hidden bg-muted border">
-                    {nextPreviewUrl ? (
-                      <img
-                        src={nextPreviewUrl}
-                        alt="Next pattern"
-                        className="w-full h-full object-cover pattern-preview"
-                      />
-                    ) : (
-                      <div className="w-full h-full flex items-center justify-center">
-                        <span className="material-icons-outlined text-muted-foreground text-2xl">image</span>
-                      </div>
-                    )}
+                {/* Status indicators - hidden on mobile */}
+                {status?.is_paused && (
+                  <div className="hidden md:block bg-amber-500/10 border border-amber-500/20 rounded-lg p-2 text-center">
+                    <span className="text-sm text-amber-600 dark:text-amber-400 font-medium">Paused</span>
                   </div>
-                  <p className="text-xs text-muted-foreground text-center max-w-20 truncate">
-                    {formatPatternName(status.playlist.next_file)}
-                  </p>
-                </div>
-              )}
-            </>
-          ) : (
-            <div className="flex-1 flex items-center">
-              <p className="text-lg text-muted-foreground">Not playing</p>
+                )}
+
+                {/* Next Pattern */}
+                {status?.playlist?.next_file && (
+                  <div className="flex items-center gap-3 bg-muted/50 rounded-lg p-2 md:p-3">
+                    <div className="w-10 h-10 md:w-12 md:h-12 rounded-full overflow-hidden bg-muted border shrink-0">
+                      {nextPreviewUrl ? (
+                        <img
+                          src={nextPreviewUrl}
+                          alt="Next pattern"
+                          className="w-full h-full object-cover pattern-preview"
+                        />
+                      ) : (
+                        <div className="w-full h-full flex items-center justify-center">
+                          <span className="material-icons-outlined text-muted-foreground text-sm">image</span>
+                        </div>
+                      )}
+                    </div>
+                    <div className="min-w-0">
+                      <p className="text-xs text-muted-foreground">Up Next</p>
+                      <p className="text-sm font-medium truncate">
+                        {formatPatternName(status.playlist.next_file)}
+                      </p>
+                    </div>
+                  </div>
+                )}
+              </div>
+              </div>
             </div>
           )}
-
-          {/* Close Button */}
-          {!isPlaying && (
-            <Button
-              variant="ghost"
-              size="icon"
-              className="h-8 w-8 self-center"
-              onClick={onClose}
-            >
-              <span className="material-icons-outlined text-lg">close</span>
-            </Button>
-          )}
         </div>
-
-        {/* Expanded View */}
-        {isExpanded && isPlaying && (
-          <div className="px-4 pb-4 pt-2 border-t space-y-4">
-            {/* Time Info */}
-            <div className="flex items-center justify-between text-sm text-muted-foreground">
-              <span>{formatTime(elapsedTime)}</span>
-              <span>{progressPercent.toFixed(0)}%</span>
-              <span>-{formatTime(remainingTime)}</span>
-            </div>
-
-            {/* Playback Controls */}
-            <div className="flex items-center justify-center gap-4">
-              {status.playlist && (
-                <Button
-                  variant="outline"
-                  size="icon"
-                  className="h-10 w-10 rounded-full"
-                  onClick={handleSkip}
-                  title="Skip to next"
-                >
-                  <span className="material-icons">skip_next</span>
-                </Button>
-              )}
-              <Button
-                variant="default"
-                size="icon"
-                className="h-12 w-12 rounded-full"
-                onClick={handlePause}
-              >
-                <span className="material-icons text-xl">
-                  {status.is_paused ? 'play_arrow' : 'pause'}
-                </span>
-              </Button>
-              <Button
-                variant="outline"
-                size="icon"
-                className="h-10 w-10 rounded-full"
-                onClick={handleStop}
-                title="Stop"
-              >
-                <span className="material-icons">stop</span>
-              </Button>
-            </div>
-
-            {/* Details Grid */}
-            <div className="grid grid-cols-2 gap-3 text-sm">
-              <div className="bg-muted/50 rounded-lg p-3">
-                <p className="text-muted-foreground text-xs">Speed</p>
-                <p className="font-medium">{status.speed} mm/s</p>
-              </div>
-              {status.playlist ? (
-                <div className="bg-muted/50 rounded-lg p-3">
-                  <p className="text-muted-foreground text-xs">Playlist</p>
-                  <p className="font-medium">
-                    {status.playlist.current_index + 1} of {status.playlist.total_files}
-                  </p>
-                </div>
-              ) : (
-                <div className="bg-muted/50 rounded-lg p-3">
-                  <p className="text-muted-foreground text-xs">Mode</p>
-                  <p className="font-medium">Single Pattern</p>
-                </div>
-              )}
-              {status.playlist?.next_file && (
-                <div className="bg-muted/50 rounded-lg p-3 col-span-2">
-                  <p className="text-muted-foreground text-xs">Next Pattern</p>
-                  <p className="font-medium truncate">
-                    {formatPatternName(status.playlist.next_file)}
-                  </p>
-                </div>
-              )}
-            </div>
-
-            {/* Pause Time Remaining (if in pause between patterns) */}
-            {status.pause_time_remaining > 0 && (
-              <div className="bg-amber-500/10 border border-amber-500/20 rounded-lg p-3 text-center">
-                <p className="text-sm text-amber-600 dark:text-amber-400">
-                  <span className="material-icons-outlined text-base align-middle mr-1">
-                    schedule
-                  </span>
-                  Next pattern in {formatTime(status.pause_time_remaining)}
-                </p>
-              </div>
-            )}
-
-            {/* Scheduled Pause Indicator */}
-            {status.scheduled_pause && (
-              <div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-3 text-center">
-                <p className="text-sm text-blue-600 dark:text-blue-400">
-                  <span className="material-icons-outlined text-base align-middle mr-1">
-                    bedtime
-                  </span>
-                  Scheduled pause active
-                </p>
-              </div>
-            )}
-          </div>
-        )}
       </div>
     </>
   )

+ 66 - 21
frontend/src/components/layout/Layout.tsx

@@ -68,6 +68,7 @@ export function Layout() {
 
   // Now Playing bar state
   const [isNowPlayingOpen, setIsNowPlayingOpen] = useState(false)
+  const [openNowPlayingExpanded, setOpenNowPlayingExpanded] = useState(false)
   const wasPlayingRef = useRef(false) // Track previous playing state to detect start
   const [logs, setLogs] = useState<Array<{ timestamp: string; level: string; logger: string; message: string }>>([])
   const [logLevelFilter, setLogLevelFilter] = useState<string>('ALL')
@@ -83,21 +84,34 @@ export function Layout() {
       ws.onopen = () => {
         setIsBackendConnected(true)
         setConnectionAttempts(0)
+        // Dispatch event so pages can refetch data
+        window.dispatchEvent(new CustomEvent('backend-connected'))
       }
 
       ws.onmessage = (event) => {
         try {
           const data = JSON.parse(event.data)
-          // Use device connection status from the status message
-          if (data.connected !== undefined) {
-            setIsConnected(data.connected)
-          }
-          // Auto-open Now Playing bar when playback starts
+          // 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)
+            }
+            // Auto-open/close Now Playing bar based on playback state
             const isPlaying = data.data.is_running || data.data.is_paused
             if (isPlaying && !wasPlayingRef.current) {
-              // Playback just started - open the Now Playing bar
+              // 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
           }
@@ -299,6 +313,21 @@ export function Layout() {
     }
   }
 
+  const handleShutdown = async () => {
+    if (!confirm('Are you sure you want to shutdown the system?')) return
+
+    try {
+      const response = await fetch('/shutdown', { method: 'POST' })
+      if (response.ok) {
+        toast.success('System is shutting down...')
+      } else {
+        throw new Error('Shutdown failed')
+      }
+    } catch {
+      toast.error('Failed to shutdown system')
+    }
+  }
+
   // Update document title based on current page
   useEffect(() => {
     const currentNav = navItems.find((item) => item.path === location.pathname)
@@ -453,14 +482,14 @@ export function Layout() {
           </Link>
           <div className="flex items-center gap-1">
             <button
-              onClick={() => setIsNowPlayingOpen(!isNowPlayingOpen)}
-              className={`rounded-full w-10 h-10 flex items-center justify-center hover:bg-accent ${
-                isNowPlayingOpen ? 'text-primary' : ''
-              }`}
-              aria-label="Now playing"
-              title="Now Playing"
+              onClick={() => setIsDark(!isDark)}
+              className="rounded-full w-10 h-10 flex items-center justify-center hover:bg-accent"
+              aria-label="Toggle dark mode"
+              title="Toggle Theme"
             >
-              <span className="material-icons-outlined">play_circle</span>
+              <span className="material-icons-outlined">
+                {isDark ? 'light_mode' : 'dark_mode'}
+              </span>
             </button>
             <button
               onClick={handleOpenLogs}
@@ -472,27 +501,31 @@ export function Layout() {
             </button>
             <button
               onClick={handleRestart}
-              className="rounded-full w-10 h-10 flex items-center justify-center hover:bg-accent hover:text-amber-500"
+              className="rounded-full w-10 h-10 flex items-center justify-center hover:bg-accent text-amber-500"
               aria-label="Restart system"
               title="Restart System"
             >
               <span className="material-icons-outlined">restart_alt</span>
             </button>
             <button
-              onClick={() => setIsDark(!isDark)}
-              className="rounded-full w-10 h-10 flex items-center justify-center hover:bg-accent"
-              aria-label="Toggle dark mode"
+              onClick={handleShutdown}
+              className="rounded-full w-10 h-10 flex items-center justify-center hover:bg-accent text-red-500"
+              aria-label="Shutdown system"
+              title="Shutdown System"
             >
-              <span className="material-icons-outlined">
-                {isDark ? 'light_mode' : 'dark_mode'}
-              </span>
+              <span className="material-icons-outlined">power_settings_new</span>
             </button>
           </div>
         </div>
       </header>
 
       {/* Main Content */}
-      <main className={`container mx-auto px-4 transition-all duration-300 ${isLogsOpen ? 'pb-80' : 'pb-20'}`}>
+      <main className={`container mx-auto px-4 transition-all duration-300 ${
+        isLogsOpen && isNowPlayingOpen ? 'pb-[576px]' :
+        isLogsOpen ? 'pb-80' :
+        isNowPlayingOpen ? 'pb-80' :
+        'pb-20'
+      }`}>
         <Outlet />
       </main>
 
@@ -500,9 +533,21 @@ export function Layout() {
       <NowPlayingBar
         isLogsOpen={isLogsOpen}
         isVisible={isNowPlayingOpen}
+        openExpanded={openNowPlayingExpanded}
         onClose={() => setIsNowPlayingOpen(false)}
       />
 
+      {/* Floating Now Playing Button */}
+      {!isNowPlayingOpen && (
+        <button
+          onClick={() => setIsNowPlayingOpen(true)}
+          className="fixed right-4 bottom-20 z-30 w-12 h-12 rounded-full bg-primary text-primary-foreground shadow-lg flex items-center justify-center hover:bg-primary/90 transition-all"
+          title="Now Playing"
+        >
+          <span className="material-icons">play_circle</span>
+        </button>
+      )}
+
       {/* Logs Drawer */}
       <div
         className={`fixed left-0 right-0 z-30 bg-background border-t border-border transition-all duration-300 ${

+ 44 - 0
frontend/src/hooks/useBackendConnection.ts

@@ -0,0 +1,44 @@
+import { useEffect, useRef } from 'react'
+
+/**
+ * Hook that triggers a callback when the backend connection is established.
+ * Useful for refetching data after the app reconnects to the backend.
+ */
+export function useOnBackendConnected(callback: () => void) {
+  const callbackRef = useRef(callback)
+
+  // Keep callback ref up to date
+  useEffect(() => {
+    callbackRef.current = callback
+  }, [callback])
+
+  useEffect(() => {
+    const handleConnected = () => {
+      callbackRef.current()
+    }
+
+    window.addEventListener('backend-connected', handleConnected)
+    return () => {
+      window.removeEventListener('backend-connected', handleConnected)
+    }
+  }, [])
+}
+
+/**
+ * Hook that returns a function wrapped to also be called on backend reconnection.
+ * Automatically calls the function on mount and whenever backend reconnects.
+ */
+export function useFetchOnConnect<T extends (...args: unknown[]) => unknown>(fetchFn: T): T {
+  const fetchRef = useRef(fetchFn)
+
+  useEffect(() => {
+    fetchRef.current = fetchFn
+  }, [fetchFn])
+
+  // Call on backend connect
+  useOnBackendConnected(() => {
+    fetchRef.current()
+  })
+
+  return fetchFn
+}

+ 28 - 0
frontend/src/index.css

@@ -112,3 +112,31 @@ body {
 .dark .pattern-preview {
   filter: invert(1);
 }
+
+/* Marquee animation for scrolling text */
+@keyframes marquee {
+  0%, 10% {
+    transform: translateX(calc(-100% + 100cqw));
+  }
+  45%, 55% {
+    transform: translateX(0);
+  }
+  90%, 100% {
+    transform: translateX(calc(-100% + 100cqw));
+  }
+}
+
+.marquee-container {
+  container-type: inline-size;
+  overflow: hidden;
+}
+
+.animate-marquee {
+  display: inline-block;
+  animation: marquee 8s ease-in-out infinite;
+  animation-play-state: running;
+}
+
+.animate-marquee:hover {
+  animation-play-state: paused;
+}

+ 37 - 0
frontend/src/pages/BrowsePage.tsx

@@ -5,6 +5,7 @@ import {
   getPreviewsFromCache,
   savePreviewToCache,
 } from '@/lib/previewCache'
+import { useOnBackendConnected } from '@/hooks/useBackendConnection'
 import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
 import { Label } from '@/components/ui/label'
@@ -100,6 +101,15 @@ export function BrowsePage() {
   // Favorites state
   const [favorites, setFavorites] = useState<Set<string>>(new Set())
 
+  // Close panel when playback starts
+  useEffect(() => {
+    const handlePlaybackStarted = () => {
+      setIsPanelOpen(false)
+    }
+    window.addEventListener('playback-started', handlePlaybackStarted)
+    return () => window.removeEventListener('playback-started', handlePlaybackStarted)
+  }, [])
+
   // Initialize IndexedDB cache and fetch patterns on mount
   useEffect(() => {
     initPreviewCacheDB().then(() => {
@@ -111,6 +121,12 @@ export function BrowsePage() {
     loadFavorites()
   }, [])
 
+  // Refetch when backend reconnects
+  useOnBackendConnected(() => {
+    fetchPatterns()
+    loadFavorites()
+  })
+
   // Load favorites from "Favorites" playlist
   const loadFavorites = async () => {
     try {
@@ -537,6 +553,24 @@ export function BrowsePage() {
     setIsPanelOpen(false)
   }
 
+  // Swipe to close panel handling
+  const panelRef = useRef<HTMLDivElement>(null)
+  const panelTouchStartX = useRef<number | null>(null)
+
+  const handlePanelTouchStart = (e: React.TouchEvent) => {
+    panelTouchStartX.current = e.touches[0].clientX
+  }
+  const handlePanelTouchEnd = (e: React.TouchEvent) => {
+    if (panelTouchStartX.current === null) return
+    const touchEndX = e.changedTouches[0].clientX
+    const deltaX = touchEndX - panelTouchStartX.current
+    // Swipe right more than 50px to close
+    if (deltaX > 50) {
+      handleClosePanel()
+    }
+    panelTouchStartX.current = null
+  }
+
   const handleOpenAnimatedPreview = async () => {
     if (!selectedPattern) return
     setIsAnimatedPreviewOpen(true)
@@ -944,6 +978,9 @@ export function BrowsePage() {
         className={`fixed top-0 bottom-0 right-0 w-full max-w-md transform transition-transform duration-300 ease-in-out z-40 ${
           isPanelOpen ? 'translate-x-0' : 'translate-x-full'
         }`}
+        ref={panelRef}
+        onTouchStart={handlePanelTouchStart}
+        onTouchEnd={handlePanelTouchEnd}
       >
         <div className="h-full bg-background border-l shadow-xl flex flex-col">
           <header className="flex h-14 items-center justify-between border-b px-4 shrink-0">

+ 86 - 7
frontend/src/pages/PlaylistsPage.tsx

@@ -5,6 +5,7 @@ import {
   getPreviewsFromCache,
   savePreviewToCache,
 } from '@/lib/previewCache'
+import { useOnBackendConnected } from '@/hooks/useBackendConnection'
 import type { PatternMetadata, PreviewData, SortOption, PreExecution, RunMode } from '@/lib/types'
 import { preExecutionOptions } from '@/lib/types'
 import { Button } from '@/components/ui/button'
@@ -30,7 +31,9 @@ import {
 export function PlaylistsPage() {
   // Playlists state
   const [playlists, setPlaylists] = useState<string[]>([])
-  const [selectedPlaylist, setSelectedPlaylist] = useState<string | null>(null)
+  const [selectedPlaylist, setSelectedPlaylist] = useState<string | null>(() => {
+    return localStorage.getItem('playlist-selected')
+  })
   const [playlistPatterns, setPlaylistPatterns] = useState<string[]>([])
   const [isLoadingPlaylists, setIsLoadingPlaylists] = useState(true)
 
@@ -52,12 +55,82 @@ export function PlaylistsPage() {
   const [newPlaylistName, setNewPlaylistName] = useState('')
   const [playlistToRename, setPlaylistToRename] = useState<string | null>(null)
 
-  // Playback settings
-  const [runMode, setRunMode] = useState<RunMode>('single')
-  const [shuffle, setShuffle] = useState(false)
-  const [pauseTime, setPauseTime] = useState(5)
-  const [pauseUnit, setPauseUnit] = useState<'sec' | 'min' | 'hr'>('min')
-  const [clearPattern, setClearPattern] = useState<PreExecution>('adaptive')
+  // Playback settings - initialized from localStorage
+  const [runMode, setRunMode] = useState<RunMode>(() => {
+    const cached = localStorage.getItem('playlist-runMode')
+    return (cached === 'single' || cached === 'indefinite') ? cached : 'single'
+  })
+  const [shuffle, setShuffle] = useState(() => {
+    return localStorage.getItem('playlist-shuffle') === 'true'
+  })
+  const [pauseTime, setPauseTime] = useState(() => {
+    const cached = localStorage.getItem('playlist-pauseTime')
+    return cached ? Number(cached) : 5
+  })
+  const [pauseUnit, setPauseUnit] = useState<'sec' | 'min' | 'hr'>(() => {
+    const cached = localStorage.getItem('playlist-pauseUnit')
+    return (cached === 'sec' || cached === 'min' || cached === 'hr') ? cached : 'min'
+  })
+  const [clearPattern, setClearPattern] = useState<PreExecution>(() => {
+    const cached = localStorage.getItem('playlist-clearPattern')
+    return (cached as PreExecution) || 'adaptive'
+  })
+
+  // Persist playback settings to localStorage
+  useEffect(() => {
+    localStorage.setItem('playlist-runMode', runMode)
+  }, [runMode])
+  useEffect(() => {
+    localStorage.setItem('playlist-shuffle', String(shuffle))
+  }, [shuffle])
+  useEffect(() => {
+    localStorage.setItem('playlist-pauseTime', String(pauseTime))
+  }, [pauseTime])
+  useEffect(() => {
+    localStorage.setItem('playlist-pauseUnit', pauseUnit)
+  }, [pauseUnit])
+  useEffect(() => {
+    localStorage.setItem('playlist-clearPattern', clearPattern)
+  }, [clearPattern])
+
+  // Persist selected playlist to localStorage
+  useEffect(() => {
+    if (selectedPlaylist) {
+      localStorage.setItem('playlist-selected', selectedPlaylist)
+    } else {
+      localStorage.removeItem('playlist-selected')
+    }
+  }, [selectedPlaylist])
+
+  // Validate cached playlist exists and load its patterns after playlists load
+  const initialLoadDoneRef = useRef(false)
+  useEffect(() => {
+    if (isLoadingPlaylists) return
+
+    if (selectedPlaylist) {
+      if (playlists.includes(selectedPlaylist)) {
+        // Load patterns for cached playlist on initial load only
+        if (!initialLoadDoneRef.current) {
+          initialLoadDoneRef.current = true
+          fetchPlaylistPatterns(selectedPlaylist)
+        }
+      } else {
+        // Cached playlist no longer exists
+        setSelectedPlaylist(null)
+      }
+    }
+  }, [isLoadingPlaylists, playlists, selectedPlaylist])
+
+  // Close modals when playback starts
+  useEffect(() => {
+    const handlePlaybackStarted = () => {
+      setIsPickerOpen(false)
+      setIsCreateModalOpen(false)
+      setIsRenameModalOpen(false)
+    }
+    window.addEventListener('playback-started', handlePlaybackStarted)
+    return () => window.removeEventListener('playback-started', handlePlaybackStarted)
+  }, [])
   const [isRunning, setIsRunning] = useState(false)
 
   // Convert pause time to seconds based on unit
@@ -83,6 +156,12 @@ export function PlaylistsPage() {
     fetchAllPatterns()
   }, [])
 
+  // Refetch when backend reconnects
+  useOnBackendConnected(() => {
+    fetchPlaylists()
+    fetchAllPatterns()
+  })
+
   const fetchPlaylists = async () => {
     setIsLoadingPlaylists(true)
     try {

+ 38 - 3
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 { useOnBackendConnected } from '@/hooks/useBackendConnection'
 import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
 import { Label } from '@/components/ui/label'
@@ -92,6 +93,13 @@ export function SettingsPage() {
   // Still Sands state
   const [stillSandsEnabled, setStillSandsEnabled] = useState(false)
 
+  // Version state
+  const [versionInfo, setVersionInfo] = useState<{
+    current: string
+    latest: string
+    update_available: boolean
+  } | null>(null)
+
   // Scroll to section and clear URL param after navigation
   useEffect(() => {
     if (sectionParam) {
@@ -134,6 +142,19 @@ export function SettingsPage() {
       case 'led':
         await fetchLedConfig()
         break
+      case 'version':
+        await fetchVersionInfo()
+        break
+    }
+  }
+
+  const fetchVersionInfo = async () => {
+    try {
+      const response = await fetch('/api/version')
+      const data = await response.json()
+      setVersionInfo(data)
+    } catch (error) {
+      console.error('Failed to fetch version info:', error)
     }
   }
 
@@ -194,6 +215,11 @@ export function SettingsPage() {
     fetchPorts()
   }, [])
 
+  // Refetch when backend reconnects
+  useOnBackendConnected(() => {
+    fetchPorts()
+  })
+
   const fetchSettings = async () => {
     try {
       const response = await fetch('/api/settings')
@@ -1092,7 +1118,9 @@ export function SettingsPage() {
               </div>
               <div className="flex-1">
                 <p className="font-medium">Current Version</p>
-                <p className="text-sm text-muted-foreground">v1.0.0</p>
+                <p className="text-sm text-muted-foreground">
+                  {versionInfo?.current ? `v${versionInfo.current}` : 'Loading...'}
+                </p>
               </div>
             </div>
 
@@ -1102,9 +1130,16 @@ export function SettingsPage() {
               </div>
               <div className="flex-1">
                 <p className="font-medium">Latest Version</p>
-                <p className="text-sm text-muted-foreground">Checking...</p>
+                <p className={`text-sm ${versionInfo?.update_available ? 'text-green-600 dark:text-green-400 font-medium' : 'text-muted-foreground'}`}>
+                  {versionInfo?.latest ? `v${versionInfo.latest}` : 'Checking...'}
+                  {versionInfo?.update_available && ' (Update available!)'}
+                </p>
               </div>
-              <Button variant="secondary" size="sm" disabled>
+              <Button
+                variant={versionInfo?.update_available ? 'default' : 'secondary'}
+                size="sm"
+                disabled={!versionInfo?.update_available}
+              >
                 <span className="material-icons-outlined text-base mr-1">download</span>
                 Update
               </Button>

+ 201 - 179
frontend/src/pages/TableControlPage.tsx

@@ -1,4 +1,4 @@
-import { useState } from 'react'
+import { useState, useEffect } from 'react'
 import { toast } from 'sonner'
 import { Button } from '@/components/ui/button'
 import {
@@ -34,6 +34,29 @@ export function TableControlPage() {
   const [currentTheta, setCurrentTheta] = useState(0)
   const [isLoading, setIsLoading] = useState<string | null>(null)
 
+  // Connect to status WebSocket to get current speed
+  useEffect(() => {
+    const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
+    const ws = new WebSocket(`${protocol}//${window.location.host}/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)
+          }
+        }
+      } catch (error) {
+        console.error('Failed to parse status:', error)
+      }
+    }
+
+    return () => {
+      ws.close()
+    }
+  }, [])
+
   const handleAction = async (
     action: string,
     endpoint: string,
@@ -152,188 +175,52 @@ export function TableControlPage() {
 
         <Separator />
 
-        {/* Main Controls Grid */}
+        {/* Main Controls Grid - 2x2 */}
         <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
-          {/* Movement Controls */}
-          <Card className="lg:row-span-2">
+          {/* Primary Actions */}
+          <Card>
             <CardHeader className="pb-3">
-              <div className="flex items-center justify-between">
-                <div>
-                  <CardTitle className="text-lg">Movement</CardTitle>
-                  <CardDescription>Control ball position</CardDescription>
-                </div>
-                <Dialog>
-                  <DialogTrigger asChild>
-                    <Button variant="ghost" size="icon" className="h-8 w-8">
-                      <span className="material-icons-outlined text-lg">help_outline</span>
-                    </Button>
-                  </DialogTrigger>
-                  <DialogContent className="sm:max-w-md">
-                    <DialogHeader>
-                      <DialogTitle>Pattern Orientation Alignment</DialogTitle>
-                      <DialogDescription>
-                        Follow these steps to align your patterns with their previews
-                      </DialogDescription>
-                    </DialogHeader>
-                    <div className="space-y-4 py-4">
-                      <ol className="space-y-3 text-sm">
-                        {[
-                          'Home the table then select move to perimeter. Look at your pattern preview and decide where the "bottom" should be.',
-                          'Manually move the radial arm or use the rotation buttons below to point 90° to the right of where you want the pattern bottom.',
-                          'Click the "Home" button to establish this as the reference position.',
-                          'All patterns will now be oriented according to their previews!',
-                        ].map((step, i) => (
-                          <li key={i} className="flex gap-3">
-                            <Badge
-                              variant="outline"
-                              className="h-6 w-6 shrink-0 items-center justify-center rounded-full p-0"
-                            >
-                              {i + 1}
-                            </Badge>
-                            <span className="text-muted-foreground">{step}</span>
-                          </li>
-                        ))}
-                      </ol>
-
-                      <Separator />
-
-                      <Alert className="flex items-start border-amber-500/50">
-                        <span className="material-icons-outlined text-amber-500 text-base mr-2 shrink-0">
-                          warning
-                        </span>
-                        <AlertDescription className="text-amber-600 dark:text-amber-400">
-                          Only perform this when you want to change the orientation reference.
-                        </AlertDescription>
-                      </Alert>
-
-                      <div className="space-y-3">
-                        <p className="text-sm font-medium text-center">Fine Adjustment</p>
-                        <div className="flex justify-center gap-2">
-                          <Button
-                            variant="outline"
-                            onClick={() => handleRotate(-10)}
-                            disabled={isLoading === 'rotate'}
-                          >
-                            <span className="material-icons text-lg mr-1">rotate_left</span>
-                            CCW 10°
-                          </Button>
-                          <Button
-                            variant="outline"
-                            onClick={() => handleRotate(10)}
-                            disabled={isLoading === 'rotate'}
-                          >
-                            CW 10°
-                            <span className="material-icons text-lg ml-1">rotate_right</span>
-                          </Button>
-                        </div>
-                        <p className="text-xs text-muted-foreground text-center">
-                          Each click rotates 10 degrees
-                        </p>
-                      </div>
-                    </div>
-                    <DialogFooter>
-                      <DialogTrigger asChild>
-                        <Button>Got it</Button>
-                      </DialogTrigger>
-                    </DialogFooter>
-                  </DialogContent>
-                </Dialog>
-              </div>
+              <CardTitle className="text-lg">Primary Actions</CardTitle>
+              <CardDescription>Calibrate or stop the table</CardDescription>
             </CardHeader>
-            <CardContent className="space-y-6">
-              {/* Primary Actions */}
-              <div className="space-y-3">
-                <div>
-                  <p className="text-sm font-medium">Primary Actions</p>
-                  <p className="text-xs text-muted-foreground">Calibrate or stop the table</p>
-                </div>
-                <div className="grid grid-cols-2 gap-3">
-                  <Tooltip>
-                    <TooltipTrigger asChild>
-                      <Button
-                        onClick={handleHome}
-                        disabled={isLoading === 'home'}
-                        className="h-16 gap-1 flex-col items-center justify-center"
-                      >
-                        {isLoading === 'home' ? (
-                          <span className="material-icons-outlined animate-spin text-2xl">sync</span>
-                        ) : (
-                          <span className="material-icons-outlined text-2xl">home</span>
-                        )}
-                        <span className="text-xs">Home</span>
-                      </Button>
-                    </TooltipTrigger>
-                    <TooltipContent>Return to home position</TooltipContent>
-                  </Tooltip>
-
-                  <Tooltip>
-                    <TooltipTrigger asChild>
-                      <Button
-                        onClick={handleStop}
-                        disabled={isLoading === 'stop'}
-                        variant="destructive"
-                        className="h-16 gap-1 flex-col items-center justify-center"
-                      >
-                        {isLoading === 'stop' ? (
-                          <span className="material-icons-outlined animate-spin text-2xl">sync</span>
-                        ) : (
-                          <span className="material-icons-outlined text-2xl">stop_circle</span>
-                        )}
-                        <span className="text-xs">Stop</span>
-                      </Button>
-                    </TooltipTrigger>
-                    <TooltipContent>Emergency stop</TooltipContent>
-                  </Tooltip>
-                </div>
-              </div>
-
-              <Separator />
-
-              {/* Position Controls */}
-              <div className="space-y-3">
-                <div>
-                  <p className="text-sm font-medium">Position</p>
-                  <p className="text-xs text-muted-foreground">Move ball to a specific location</p>
-                </div>
-                <div className="grid grid-cols-2 gap-3">
-                  <Tooltip>
-                    <TooltipTrigger asChild>
-                      <Button
-                        onClick={handleMoveToCenter}
-                        disabled={isLoading === 'center'}
-                        variant="secondary"
-                        className="h-16 gap-1 flex-col items-center justify-center"
-                      >
-                        {isLoading === 'center' ? (
-                          <span className="material-icons-outlined animate-spin text-2xl">sync</span>
-                        ) : (
-                          <span className="material-icons-outlined text-2xl">filter_center_focus</span>
-                        )}
-                        <span className="text-xs">Center</span>
-                      </Button>
-                    </TooltipTrigger>
-                    <TooltipContent>Move ball to center</TooltipContent>
-                  </Tooltip>
+            <CardContent>
+              <div className="grid grid-cols-2 gap-3">
+                <Tooltip>
+                  <TooltipTrigger asChild>
+                    <Button
+                      onClick={handleHome}
+                      disabled={isLoading === 'home'}
+                      className="h-16 gap-1 flex-col items-center justify-center"
+                    >
+                      {isLoading === 'home' ? (
+                        <span className="material-icons-outlined animate-spin text-2xl">sync</span>
+                      ) : (
+                        <span className="material-icons-outlined text-2xl">home</span>
+                      )}
+                      <span className="text-xs">Home</span>
+                    </Button>
+                  </TooltipTrigger>
+                  <TooltipContent>Return to home position</TooltipContent>
+                </Tooltip>
 
-                  <Tooltip>
-                    <TooltipTrigger asChild>
-                      <Button
-                        onClick={handleMoveToPerimeter}
-                        disabled={isLoading === 'perimeter'}
-                        variant="secondary"
-                        className="h-16 gap-1 flex-col items-center justify-center"
-                      >
-                        {isLoading === 'perimeter' ? (
-                          <span className="material-icons-outlined animate-spin text-2xl">sync</span>
-                        ) : (
-                          <span className="material-icons-outlined text-2xl">all_out</span>
-                        )}
-                        <span className="text-xs">Perimeter</span>
-                      </Button>
-                    </TooltipTrigger>
-                    <TooltipContent>Move ball to edge</TooltipContent>
-                  </Tooltip>
-                </div>
+                <Tooltip>
+                  <TooltipTrigger asChild>
+                    <Button
+                      onClick={handleStop}
+                      disabled={isLoading === 'stop'}
+                      variant="destructive"
+                      className="h-16 gap-1 flex-col items-center justify-center"
+                    >
+                      {isLoading === 'stop' ? (
+                        <span className="material-icons-outlined animate-spin text-2xl">sync</span>
+                      ) : (
+                        <span className="material-icons-outlined text-2xl">stop_circle</span>
+                      )}
+                      <span className="text-xs">Stop</span>
+                    </Button>
+                  </TooltipTrigger>
+                  <TooltipContent>Emergency stop</TooltipContent>
+                </Tooltip>
               </div>
             </CardContent>
           </Card>
@@ -379,6 +266,141 @@ export function TableControlPage() {
             </CardContent>
           </Card>
 
+          {/* Position */}
+          <Card>
+            <CardHeader className="pb-3">
+              <CardTitle className="text-lg">Position</CardTitle>
+              <CardDescription>Move ball to a specific location</CardDescription>
+            </CardHeader>
+            <CardContent>
+              <div className="grid grid-cols-3 gap-3">
+                <Tooltip>
+                  <TooltipTrigger asChild>
+                    <Button
+                      onClick={handleMoveToCenter}
+                      disabled={isLoading === 'center'}
+                      variant="outline"
+                      className="h-16 gap-1 flex-col items-center justify-center"
+                    >
+                      {isLoading === 'center' ? (
+                        <span className="material-icons-outlined animate-spin text-2xl">sync</span>
+                      ) : (
+                        <span className="material-icons-outlined text-2xl">center_focus_strong</span>
+                      )}
+                      <span className="text-xs">Center</span>
+                    </Button>
+                  </TooltipTrigger>
+                  <TooltipContent>Move ball to center</TooltipContent>
+                </Tooltip>
+
+                <Tooltip>
+                  <TooltipTrigger asChild>
+                    <Button
+                      onClick={handleMoveToPerimeter}
+                      disabled={isLoading === 'perimeter'}
+                      variant="outline"
+                      className="h-16 gap-1 flex-col items-center justify-center"
+                    >
+                      {isLoading === 'perimeter' ? (
+                        <span className="material-icons-outlined animate-spin text-2xl">sync</span>
+                      ) : (
+                        <span className="material-icons-outlined text-2xl">trip_origin</span>
+                      )}
+                      <span className="text-xs">Perimeter</span>
+                    </Button>
+                  </TooltipTrigger>
+                  <TooltipContent>Move ball to edge</TooltipContent>
+                </Tooltip>
+
+                <Dialog>
+                  <Tooltip>
+                    <TooltipTrigger asChild>
+                      <DialogTrigger asChild>
+                        <Button
+                          variant="outline"
+                          className="h-16 gap-1 flex-col items-center justify-center"
+                        >
+                          <span className="material-icons-outlined text-2xl">screen_rotation</span>
+                          <span className="text-xs">Align</span>
+                        </Button>
+                      </DialogTrigger>
+                    </TooltipTrigger>
+                    <TooltipContent>Align pattern orientation</TooltipContent>
+                  </Tooltip>
+                <DialogContent className="sm:max-w-md">
+                  <DialogHeader>
+                    <DialogTitle>Pattern Orientation Alignment</DialogTitle>
+                    <DialogDescription>
+                      Follow these steps to align your patterns with their previews
+                    </DialogDescription>
+                  </DialogHeader>
+                  <div className="space-y-4 py-4">
+                    <ol className="space-y-3 text-sm">
+                      {[
+                        'Home the table then select move to perimeter. Look at your pattern preview and decide where the "bottom" should be.',
+                        'Manually move the radial arm or use the rotation buttons below to point 90° to the right of where you want the pattern bottom.',
+                        'Click the "Home" button to establish this as the reference position.',
+                        'All patterns will now be oriented according to their previews!',
+                      ].map((step, i) => (
+                        <li key={i} className="flex gap-3">
+                          <Badge
+                            variant="outline"
+                            className="h-6 w-6 shrink-0 items-center justify-center rounded-full p-0"
+                          >
+                            {i + 1}
+                          </Badge>
+                          <span className="text-muted-foreground">{step}</span>
+                        </li>
+                      ))}
+                    </ol>
+
+                    <Separator />
+
+                    <Alert className="flex items-start border-amber-500/50">
+                      <span className="material-icons-outlined text-amber-500 text-base mr-2 shrink-0">
+                        warning
+                      </span>
+                      <AlertDescription className="text-amber-600 dark:text-amber-400">
+                        Only perform this when you want to change the orientation reference.
+                      </AlertDescription>
+                    </Alert>
+
+                    <div className="space-y-3">
+                      <p className="text-sm font-medium text-center">Fine Adjustment</p>
+                      <div className="flex justify-center gap-2">
+                        <Button
+                          variant="outline"
+                          onClick={() => handleRotate(-10)}
+                          disabled={isLoading === 'rotate'}
+                        >
+                          <span className="material-icons text-lg mr-1">rotate_left</span>
+                          CCW 10°
+                        </Button>
+                        <Button
+                          variant="outline"
+                          onClick={() => handleRotate(10)}
+                          disabled={isLoading === 'rotate'}
+                        >
+                          CW 10°
+                          <span className="material-icons text-lg ml-1">rotate_right</span>
+                        </Button>
+                      </div>
+                      <p className="text-xs text-muted-foreground text-center">
+                        Each click rotates 10 degrees
+                      </p>
+                    </div>
+                  </div>
+                  <DialogFooter>
+                    <DialogTrigger asChild>
+                      <Button>Got it</Button>
+                    </DialogTrigger>
+                  </DialogFooter>
+                </DialogContent>
+                </Dialog>
+              </div>
+            </CardContent>
+          </Card>
+
           {/* Clear Patterns */}
           <Card>
             <CardHeader className="pb-3">

+ 27 - 6
frontend/vite.config.ts

@@ -17,19 +17,40 @@ export default defineConfig({
       '/ws': {
         target: 'ws://localhost:8080',
         ws: true,
-        // Suppress connection reset errors (common during backend restarts)
+        // Suppress connection errors (common during backend restarts)
         configure: (proxy, _options) => {
           // Handle proxy errors silently for expected connection issues
-          const handleError = (err: Error) => {
+          const isConnectionError = (err: Error & { code?: string }) => {
             const msg = err.message || ''
-            if (msg.includes('ECONNRESET') || msg.includes('ECONNREFUSED') || msg.includes('EPIPE')) {
-              return // Silently ignore
+            const code = err.code || ''
+            // Check error code (most reliable for AggregateError)
+            if (['ECONNRESET', 'ECONNREFUSED', 'EPIPE', 'ETIMEDOUT'].includes(code)) {
+              return true
+            }
+            // Check message as fallback
+            if (msg.includes('ECONNRESET') || msg.includes('ECONNREFUSED') ||
+                msg.includes('EPIPE') || msg.includes('ETIMEDOUT') ||
+                msg.includes('AggregateError')) {
+              return true
             }
-            console.error('WebSocket proxy error:', msg)
+            return false
           }
+
+          const handleError = (err: Error) => {
+            if (isConnectionError(err)) {
+              return // Silently ignore connection errors
+            }
+            // Only log unexpected errors
+            console.error('WebSocket proxy error:', err.message)
+          }
+
           proxy.on('error', handleError)
           proxy.on('proxyReqWs', (_proxyReq, _req, socket) => {
-            socket.on('error', handleError)
+            socket.on('error', (err) => {
+              if (!isConnectionError(err)) {
+                console.error('WebSocket socket error:', err.message)
+              }
+            })
           })
         },
       },