Преглед на файлове

Improve UI responsiveness, auto-connect options, and LED stability

Frontend:
- Fix Select dropdowns overflowing viewport (add max-height + scroll)
- Make Custom Logo upload button stack on mobile
- Add all LED color order options (BGR, RBG, GBR, BRG) with grouping
- Fix LED page icon alignment and mobile speed/intensity layout
- Redesign mobile filter bar (compact single row)
- Rename "Uncategorized" to "Default Patterns"

Backend:
- Add auto-connect disabled mode (__none__ option)
- Deprioritize /dev/ttyS0 during auto-connect (still available manually)
- Fix LED hardware change detection using wrong defaults
- Add 500ms delay between LED controller stop/reinit for stability
- Change default pixel order to RGB (WS2815) and GPIO to 18

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
tuanchris преди 2 седмици
родител
ревизия
7dd6834309

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

@@ -832,7 +832,7 @@ export function Layout() {
     : 0
 
   return (
-    <div className="min-h-dvh bg-background flex flex-col overflow-x-hidden">
+    <div className="min-h-dvh bg-background flex flex-col">
       {/* Cache Progress Blocking Overlay */}
       {cacheProgress?.is_running && (
         <div className="fixed inset-0 z-50 bg-background/95 backdrop-blur-sm flex flex-col items-center justify-center p-4">
@@ -1124,7 +1124,7 @@ export function Layout() {
       )}
 
       {/* Header */}
-      <header className="sticky top-0 z-40 w-full border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
+      <header className="fixed top-0 left-0 right-0 z-40 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
         <div className="flex h-14 items-center justify-between px-4">
           <Link to="/" className="flex items-center gap-2">
             <img
@@ -1268,7 +1268,7 @@ export function Layout() {
 
       {/* Main Content */}
       <main
-        className={`container mx-auto px-4 transition-all duration-300 ${
+        className={`container mx-auto px-4 pt-16 transition-all duration-300 ${
           !isLogsOpen && !isNowPlayingOpen ? 'pb-20' :
           !isLogsOpen && isNowPlayingOpen ? 'pb-80' : ''
         }`}

+ 2 - 2
frontend/src/components/ui/select.tsx

@@ -73,7 +73,7 @@ const SelectContent = React.forwardRef<
     <SelectPrimitive.Content
       ref={ref}
       className={cn(
-        "relative z-[9999] max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
+        "relative z-[9999] max-h-[min(var(--radix-select-content-available-height,384px),384px)] min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
         position === "popper" &&
           "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
         className
@@ -84,7 +84,7 @@ const SelectContent = React.forwardRef<
       <SelectScrollUpButton />
       <SelectPrimitive.Viewport
         className={cn(
-          "p-1",
+          "p-1 max-h-[inherit] overflow-y-auto",
           position === "popper" &&
             "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
         )}

+ 66 - 64
frontend/src/pages/BrowsePage.tsx

@@ -819,81 +819,83 @@ export function BrowsePage() {
 
       {/* Sticky Filters - Compact on mobile */}
       <div className="sticky top-14 z-30 py-2 sm:py-4 -mx-4 px-4 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
-        <div className="flex flex-col sm:flex-row gap-2 sm:gap-3">
-          {/* Search + Category on same row on mobile */}
-          <div className="flex gap-2 flex-1">
-            <div className="relative flex-1">
-              <span className="material-icons-outlined absolute left-2.5 sm:left-3 top-1/2 -translate-y-1/2 text-muted-foreground text-lg sm:text-xl">
-                search
-              </span>
-              <Input
-                value={searchQuery}
-                onChange={(e) => setSearchQuery(e.target.value)}
-                placeholder="Search..."
-                className="pl-8 sm:pl-10 pr-8 sm:pr-10 h-9 sm:h-10 text-sm"
-              />
-              {searchQuery && (
-                <Button
-                  variant="ghost"
-                  size="icon-sm"
-                  onClick={() => setSearchQuery('')}
-                  className="absolute right-1.5 sm:right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground h-6 w-6"
-                >
-                  <span className="material-icons-outlined text-lg">close</span>
-                </Button>
-              )}
-            </div>
-
-            <Select value={selectedCategory} onValueChange={setSelectedCategory}>
-              <SelectTrigger className="w-28 sm:w-44 h-9 sm:h-10 text-sm">
-                <SelectValue placeholder="Category" />
-              </SelectTrigger>
-              <SelectContent>
-                {categories.map((cat) => (
-                  <SelectItem key={cat} value={cat}>
-                    {cat === 'all' ? 'All Categories' : cat === 'root' ? 'Uncategorized' : cat}
-                  </SelectItem>
-                ))}
-              </SelectContent>
-            </Select>
+        {/* Mobile: Single row with all controls */}
+        <div className="flex items-center gap-1.5 sm:gap-3">
+          {/* Search */}
+          <div className="relative flex-1 min-w-0">
+            <span className="material-icons-outlined absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground text-lg">
+              search
+            </span>
+            <Input
+              value={searchQuery}
+              onChange={(e) => setSearchQuery(e.target.value)}
+              placeholder="Search..."
+              className="pl-8 pr-8 h-9 text-sm"
+            />
+            {searchQuery && (
+              <Button
+                variant="ghost"
+                size="icon-sm"
+                onClick={() => setSearchQuery('')}
+                className="absolute right-1 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground h-6 w-6"
+              >
+                <span className="material-icons-outlined text-lg">close</span>
+              </Button>
+            )}
           </div>
 
-          {/* Sort controls */}
-          <div className="flex gap-2">
-            <Select value={sortBy} onValueChange={(v) => setSortBy(v as SortOption)}>
-              <SelectTrigger className="w-24 sm:w-36 h-9 sm:h-10 text-sm">
-                <SelectValue placeholder="Sort by" />
-              </SelectTrigger>
-              <SelectContent>
-                <SelectItem value="name">Name</SelectItem>
-                <SelectItem value="date">Date Modified</SelectItem>
-                <SelectItem value="category">Category</SelectItem>
-              </SelectContent>
-            </Select>
-
-            <Button
-              variant="outline"
-              size="icon"
-              onClick={() => setSortAsc(!sortAsc)}
-              className="shrink-0 h-9 w-9 sm:h-10 sm:w-10"
-              title={sortAsc ? 'Ascending' : 'Descending'}
-            >
-              <span className="material-icons-outlined text-lg">
-                {sortAsc ? 'arrow_upward' : 'arrow_downward'}
-              </span>
-            </Button>
-          </div>
+          {/* Category */}
+          <Select value={selectedCategory} onValueChange={setSelectedCategory}>
+            <SelectTrigger className="w-[4.5rem] sm:w-36 h-9 text-sm shrink-0">
+              <SelectValue placeholder="All" />
+            </SelectTrigger>
+            <SelectContent>
+              {categories.map((cat) => (
+                <SelectItem key={cat} value={cat}>
+                  {cat === 'all' ? 'All' : cat === 'root' ? 'Default Patterns' : cat}
+                </SelectItem>
+              ))}
+            </SelectContent>
+          </Select>
+
+          {/* Sort */}
+          <Select value={sortBy} onValueChange={(v) => setSortBy(v as SortOption)}>
+            <SelectTrigger className="w-[4.5rem] sm:w-32 h-9 text-sm shrink-0">
+              <SelectValue placeholder="Sort" />
+            </SelectTrigger>
+            <SelectContent>
+              <SelectItem value="name">Name</SelectItem>
+              <SelectItem value="date">Date</SelectItem>
+              <SelectItem value="category">Category</SelectItem>
+            </SelectContent>
+          </Select>
+
+          {/* Sort direction */}
+          <Button
+            variant="outline"
+            size="icon"
+            onClick={() => setSortAsc(!sortAsc)}
+            className="shrink-0 h-9 w-9"
+            title={sortAsc ? 'Ascending' : 'Descending'}
+          >
+            <span className="material-icons-outlined text-lg">
+              {sortAsc ? 'arrow_upward' : 'arrow_downward'}
+            </span>
+          </Button>
 
+          {/* Cache button - icon only on mobile */}
           {!allCached && (
             <Button
               variant="outline"
+              size="icon"
               onClick={handleCacheAllPreviews}
-              className="gap-2 whitespace-nowrap"
+              className="shrink-0 h-9 w-9 sm:w-auto sm:px-3 sm:gap-2"
+              title="Cache All Previews"
             >
               {isCaching ? (
                 <>
                   <span className="material-icons-outlined animate-spin text-lg">sync</span>
-                  <span>{cacheProgress}%</span>
+                  <span className="hidden sm:inline">{cacheProgress}%</span>
                 </>
               ) : (
                 <>

+ 8 - 8
frontend/src/pages/LEDPage.tsx

@@ -443,9 +443,9 @@ export function LEDPage() {
 
                   {/* Brightness Slider */}
                   <div className="space-y-2">
-                    <div className="flex justify-between">
-                      <Label className="flex items-center gap-2">
-                        <span className="material-icons-outlined text-base text-muted-foreground">brightness_6</span>
+                    <div className="flex justify-between items-center">
+                      <Label>
+                        <span className="material-icons-outlined text-sm mr-2 align-[-6px] text-muted-foreground">brightness_6</span>
                         Brightness
                       </Label>
                       <span className="text-sm font-medium">{brightness}%</span>
@@ -507,11 +507,11 @@ export function LEDPage() {
               </div>
 
               {/* Speed and Intensity in styled boxes */}
-              <div className="grid grid-cols-2 gap-4">
+              <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
                 <div className="p-4 rounded-lg border space-y-3">
                   <div className="flex justify-between items-center">
-                    <Label className="flex items-center gap-2">
-                      <span className="material-icons-outlined text-base text-muted-foreground">speed</span>
+                    <Label>
+                      <span className="material-icons-outlined text-sm mr-2 align-[-6px] text-muted-foreground">speed</span>
                       Speed
                     </Label>
                     <span className="text-sm font-medium">{speed}</span>
@@ -526,8 +526,8 @@ export function LEDPage() {
                 </div>
                 <div className="p-4 rounded-lg border space-y-3">
                   <div className="flex justify-between items-center">
-                    <Label className="flex items-center gap-2">
-                      <span className="material-icons-outlined text-base text-muted-foreground">tungsten</span>
+                    <Label>
+                      <span className="material-icons-outlined text-sm mr-2 align-[-6px] text-muted-foreground">tungsten</span>
                       Intensity
                     </Label>
                     <span className="text-sm font-medium">{intensity}</span>

+ 64 - 44
frontend/src/pages/SettingsPage.tsx

@@ -19,6 +19,8 @@ import {
   Select,
   SelectContent,
   SelectItem,
+  SelectLabel,
+  SelectSeparator,
   SelectTrigger,
   SelectValue,
 } from '@/components/ui/select'
@@ -452,16 +454,19 @@ export function SettingsPage() {
   const handleSavePreferredPort = async () => {
     setIsLoading('preferredPort')
     try {
+      const portValue = settings.preferred_port === '__none__' ? '__none__' : (settings.preferred_port || null)
       await apiClient.patch('/api/settings', {
-        connection: { preferred_port: settings.preferred_port || null },
+        connection: { preferred_port: portValue },
       })
-      toast.success(
-        settings.preferred_port
-          ? `Auto-connect set to ${settings.preferred_port}`
-          : 'Auto-connect disabled'
-      )
+      if (!settings.preferred_port || settings.preferred_port === '__auto__') {
+        toast.success('Auto-connect: Auto (first available port)')
+      } else if (settings.preferred_port === '__none__') {
+        toast.success('Auto-connect: Disabled')
+      } else {
+        toast.success(`Auto-connect: ${settings.preferred_port}`)
+      }
     } catch (error) {
-      toast.error('Failed to save preferred port')
+      toast.error('Failed to save auto-connect setting')
     } finally {
       setIsLoading(null)
     }
@@ -817,24 +822,30 @@ export function SettingsPage() {
 
             {/* Preferred Port for Auto-Connect */}
             <div className="space-y-3">
-              <Label>Preferred Port (Auto-Connect)</Label>
+              <Label>Auto-Connect</Label>
               <div className="flex gap-3">
                 <Select
-                  value={settings.preferred_port || '__none__'}
+                  value={settings.preferred_port || '__auto__'}
                   onValueChange={(value) =>
-                    setSettings({ ...settings, preferred_port: value === '__none__' ? undefined : value })
+                    setSettings({ ...settings, preferred_port: value === '__auto__' ? undefined : value })
                   }
                 >
                   <SelectTrigger className="flex-1">
-                    <SelectValue placeholder="Select preferred port..." />
+                    <SelectValue placeholder="Select auto-connect option..." />
                   </SelectTrigger>
                   <SelectContent>
-                    {ports.map((port) => (
-                      <SelectItem key={port} value={port}>
-                        {port}
-                      </SelectItem>
-                    ))}
-                    <SelectItem value="__none__">None (Disable auto-connect)</SelectItem>
+                    <SelectItem value="__auto__">Auto (pick first available)</SelectItem>
+                    <SelectItem value="__none__">Disabled (no auto-connect)</SelectItem>
+                    {ports.length > 0 && (
+                      <>
+                        <div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">Available Ports</div>
+                        {ports.map((port) => (
+                          <SelectItem key={port} value={port}>
+                            {port}
+                          </SelectItem>
+                        ))}
+                      </>
+                    )}
                   </SelectContent>
                 </Select>
                 <Button
@@ -851,7 +862,7 @@ export function SettingsPage() {
                 </Button>
               </div>
               <p className="text-xs text-muted-foreground">
-                When set, the system will automatically connect to this port on startup. Set to "None" to disable auto-connect.
+                Choose how the system connects on startup: Auto picks the first available port, Disabled requires manual connection, or select a specific port.
               </p>
             </div>
           </AccordionContent>
@@ -1096,31 +1107,33 @@ export function SettingsPage() {
             {/* Custom Logo */}
             <div className="space-y-3">
               <Label>Custom Logo</Label>
-              <div className="flex items-center gap-4 p-4 rounded-lg border">
-                <div className="w-16 h-16 rounded-full overflow-hidden border bg-background flex items-center justify-center shrink-0">
-                  {settings.custom_logo ? (
-                    <img
-                      src={`/static/custom/${settings.custom_logo}`}
-                      alt="Custom Logo"
-                      className="w-full h-full object-cover"
-                    />
-                  ) : (
-                    <img
-                      src="/static/android-chrome-192x192.png"
-                      alt="Default Logo"
-                      className="w-full h-full object-cover"
-                    />
-                  )}
-                </div>
-                <div className="flex-1">
-                  <p className="font-medium">
-                    {settings.custom_logo ? 'Custom logo active' : 'Using default logo'}
-                  </p>
-                  <p className="text-sm text-muted-foreground">
-                    PNG, JPG, GIF, WebP or SVG (max 5MB)
-                  </p>
+              <div className="flex flex-col sm:flex-row sm:items-center gap-4 p-4 rounded-lg border">
+                <div className="flex items-center gap-4">
+                  <div className="w-16 h-16 rounded-full overflow-hidden border bg-background flex items-center justify-center shrink-0">
+                    {settings.custom_logo ? (
+                      <img
+                        src={`/static/custom/${settings.custom_logo}`}
+                        alt="Custom Logo"
+                        className="w-full h-full object-cover"
+                      />
+                    ) : (
+                      <img
+                        src="/static/android-chrome-192x192.png"
+                        alt="Default Logo"
+                        className="w-full h-full object-cover"
+                      />
+                    )}
+                  </div>
+                  <div className="flex-1">
+                    <p className="font-medium">
+                      {settings.custom_logo ? 'Custom logo active' : 'Using default logo'}
+                    </p>
+                    <p className="text-sm text-muted-foreground">
+                      PNG, JPG, GIF, WebP or SVG (max 5MB)
+                    </p>
+                  </div>
                 </div>
-                <div className="flex gap-2">
+                <div className="flex gap-2 sm:ml-auto">
                   <Button
                     variant="outline"
                     size="sm"
@@ -1431,9 +1444,16 @@ export function SettingsPage() {
                       <SelectValue />
                     </SelectTrigger>
                     <SelectContent>
+                      <SelectLabel>RGB Strips (3-channel)</SelectLabel>
                       <SelectItem value="RGB">RGB - WS2815/WS2811</SelectItem>
-                      <SelectItem value="GRB">GRB - WS2812/WS2812B</SelectItem>
-                      <SelectItem value="GRBW">GRBW - SK6812 RGBW</SelectItem>
+                      <SelectItem value="GRB">GRB - WS2812/WS2812B (common)</SelectItem>
+                      <SelectItem value="BGR">BGR - Some WS2811 variants</SelectItem>
+                      <SelectItem value="RBG">RBG - Rare variant</SelectItem>
+                      <SelectItem value="GBR">GBR - Rare variant</SelectItem>
+                      <SelectItem value="BRG">BRG - Rare variant</SelectItem>
+                      <SelectSeparator />
+                      <SelectLabel>RGBW Strips (4-channel)</SelectLabel>
+                      <SelectItem value="GRBW">GRBW - SK6812 RGBW (common)</SelectItem>
                       <SelectItem value="RGBW">RGBW - SK6812 variant</SelectItem>
                     </SelectContent>
                   </Select>

+ 12 - 5
main.py

@@ -749,7 +749,11 @@ async def update_settings(settings_update: SettingsUpdate):
     if settings_update.connection:
         if settings_update.connection.preferred_port is not None:
             port = settings_update.connection.preferred_port
-            state.preferred_port = None if port in ("", "none") else port
+            # "" or "none" = auto mode (None), "__none__" = disabled, else specific port
+            if port in ("", "none"):
+                state.preferred_port = None  # Auto mode
+            else:
+                state.preferred_port = port  # "__none__" for disabled, or specific port
         updated_categories.append("connection")
 
     # Pattern settings
@@ -2067,8 +2071,8 @@ async def set_led_config(request: LEDConfigRequest):
         old_gpio_pin = state.dw_led_gpio_pin
         old_pixel_order = state.dw_led_pixel_order
         hardware_changed = (
-            old_gpio_pin != (request.gpio_pin or 12) or
-            old_pixel_order != (request.pixel_order or "GRB")
+            old_gpio_pin != (request.gpio_pin or 18) or
+            old_pixel_order != (request.pixel_order or "RGB")
         )
 
         # Stop existing DW LED controller if hardware settings changed
@@ -2081,10 +2085,13 @@ async def set_led_config(request: LEDConfigRequest):
                     logger.info("LED controller stopped successfully")
                 except Exception as e:
                     logger.error(f"Error stopping LED controller: {e}")
+            # Clear the reference and give hardware time to release
+            state.led_controller = None
+            await asyncio.sleep(0.5)
 
         state.dw_led_num_leds = request.num_leds or 60
-        state.dw_led_gpio_pin = request.gpio_pin or 12
-        state.dw_led_pixel_order = request.pixel_order or "GRB"
+        state.dw_led_gpio_pin = request.gpio_pin or 18
+        state.dw_led_pixel_order = request.pixel_order or "RGB"
         state.dw_led_brightness = request.brightness or 35
         state.wled_ip = None
 

+ 18 - 3
modules/connection/connection_manager.py

@@ -16,6 +16,9 @@ logger = logging.getLogger(__name__)
 
 IGNORE_PORTS = ['/dev/cu.debug-console', '/dev/cu.Bluetooth-Incoming-Port']
 
+# Ports to deprioritize during auto-connect (shown in UI but not auto-selected)
+DEPRIORITIZED_PORTS = ['/dev/ttyS0']
+
 
 async def _check_table_is_idle() -> bool:
     """Helper function to check if table is idle."""
@@ -256,19 +259,31 @@ def connect_device(homing=True):
 
     ports = list_serial_ports()
 
+    # Check if auto-connect is disabled
+    if state.preferred_port == "__none__":
+        logger.info("Auto-connect disabled by user preference")
+        # Skip all auto-connect logic, no connection will be established
     # Priority for auto-connect:
     # 1. Preferred port (user's explicit choice) if available
     # 2. Last used port if available
     # 3. First available port as fallback
-    if state.preferred_port and state.preferred_port in ports:
+    elif state.preferred_port and state.preferred_port in ports:
         logger.info(f"Connecting to preferred port: {state.preferred_port}")
         state.conn = SerialConnection(state.preferred_port)
     elif state.port and state.port in ports:
         logger.info(f"Connecting to last used port: {state.port}")
         state.conn = SerialConnection(state.port)
     elif ports:
-        logger.info(f"Connecting to first available port: {ports[0]}")
-        state.conn = SerialConnection(ports[0])
+        # Prefer non-deprioritized ports (e.g., USB serial over hardware UART)
+        preferred_ports = [p for p in ports if p not in DEPRIORITIZED_PORTS]
+        fallback_ports = [p for p in ports if p in DEPRIORITIZED_PORTS]
+
+        if preferred_ports:
+            logger.info(f"Connecting to first available port: {preferred_ports[0]}")
+            state.conn = SerialConnection(preferred_ports[0])
+        elif fallback_ports:
+            logger.info(f"Connecting to deprioritized port (no better option): {fallback_ports[0]}")
+            state.conn = SerialConnection(fallback_ports[0])
     else:
         logger.error("Auto connect failed: No serial ports available")
         # state.conn = WebSocketConnection('ws://fluidnc.local:81')

+ 2 - 2
modules/core/state.py

@@ -75,7 +75,7 @@ class AppState:
         # DW LED settings
         self.dw_led_num_leds = 60  # Number of LEDs in strip
         self.dw_led_gpio_pin = 18  # GPIO pin (12, 13, 18, or 19)
-        self.dw_led_pixel_order = "GRB"  # Pixel color order for WS281x (GRB, RGB, BGR, etc.)
+        self.dw_led_pixel_order = "RGB"  # Pixel color order for WS281x (RGB for WS2815, GRB for WS2812)
         self.dw_led_brightness = 35  # Brightness 0-100
         self.dw_led_speed = 128  # Effect speed 0-255
         self.dw_led_intensity = 128  # Effect intensity 0-255
@@ -347,7 +347,7 @@ class AppState:
         self.led_provider = data.get('led_provider', "none")
         self.dw_led_num_leds = data.get('dw_led_num_leds', 60)
         self.dw_led_gpio_pin = data.get('dw_led_gpio_pin', 18)
-        self.dw_led_pixel_order = data.get('dw_led_pixel_order', "GRB")
+        self.dw_led_pixel_order = data.get('dw_led_pixel_order', "RGB")
         self.dw_led_brightness = data.get('dw_led_brightness', 35)
         self.dw_led_speed = data.get('dw_led_speed', 128)
         self.dw_led_intensity = data.get('dw_led_intensity', 128)