Bläddra i källkod

fix(pwa): iOS safe area handling and PWA icon transparency

- Add env(safe-area-inset-bottom) to main content bottom padding in Layout
  to prevent content from being hidden behind nav bar on iPhone
- Increase base bottom padding from 5rem to 8rem to clear floating Now
  Playing pill button
- Add safe area inset subtraction to viewport height calculations in
  PlaylistsPage, LEDPage, and Now Playing bar expanded state
- Fix PWA icon generation to composite onto solid #0a0a0a background,
  preventing iOS from showing white behind transparent custom logos
- Add maskable purpose icon entries to web manifest for better Android
  adaptive icon support
- Add hard_reset_theta setting for optional machine reset on theta
  normalization

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
tuanchris 6 dagar sedan
förälder
incheckning
876ba57dd0

+ 6 - 7
frontend/src/components/layout/Layout.tsx

@@ -1664,17 +1664,16 @@ export function Layout() {
 
       {/* Main Content */}
       <main
-        className={`container mx-auto px-4 transition-all duration-300 ${
-          !isLogsOpen && !isNowPlayingOpen ? 'pb-20' :
-          !isLogsOpen && isNowPlayingOpen ? 'pb-80' : ''
-        }`}
+        className="container mx-auto px-4 transition-all duration-300"
         style={{
           paddingTop: 'calc(4.5rem + env(safe-area-inset-top, 0px))',
           paddingBottom: isLogsOpen
             ? isNowPlayingOpen
-              ? logsDrawerHeight + 256 + 64 // drawer + now playing + nav
-              : logsDrawerHeight + 64 // drawer + nav
-            : undefined
+              ? `calc(${logsDrawerHeight + 256 + 64}px + env(safe-area-inset-bottom, 0px))` // drawer + now playing + nav + safe area
+              : `calc(${logsDrawerHeight + 64}px + env(safe-area-inset-bottom, 0px))` // drawer + nav + safe area
+            : isNowPlayingOpen
+              ? 'calc(20rem + env(safe-area-inset-bottom, 0px))' // now playing bar + nav + safe area
+              : 'calc(8rem + env(safe-area-inset-bottom, 0px))' // floating pill + nav + safe area
         }}
       >
         <Outlet />

+ 1 - 1
frontend/src/index.css

@@ -196,7 +196,7 @@ body {
   }
 
   [data-now-playing-bar="expanded"] {
-    height: calc(100vh - 64px - 64px);
+    height: calc(100vh - 64px - 64px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px));
   }
 }
 

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

@@ -389,7 +389,7 @@ export function LEDPage() {
   // WLED iframe view
   if (ledConfig.provider === 'wled' && ledConfig.wled_ip) {
     return (
-      <div className="flex flex-col w-full h-[calc(100vh-180px)] py-4">
+      <div className="flex flex-col w-full py-4" style={{ height: 'calc(100vh - 180px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px))' }}>
         <iframe
           src={`http://${ledConfig.wled_ip}`}
           className="w-full h-full rounded-lg border border-border"

+ 1 - 1
frontend/src/pages/PlaylistsPage.tsx

@@ -520,7 +520,7 @@ export function PlaylistsPage() {
   }
 
   return (
-    <div className="flex flex-col w-full max-w-5xl mx-auto gap-4 sm:gap-6 py-3 sm:py-6 px-0 sm:px-4 h-[calc(100dvh-11rem)] overflow-hidden">
+    <div className="flex flex-col w-full max-w-5xl mx-auto gap-4 sm:gap-6 py-3 sm:py-6 px-0 sm:px-4 overflow-hidden" style={{ height: 'calc(100dvh - 14rem - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px))' }}>
       {/* Page Header */}
       <div className="space-y-0.5 sm:space-y-1 shrink-0 pl-1">
         <h1 className="text-xl font-semibold tracking-tight">Playlists</h1>

+ 28 - 0
frontend/src/pages/SettingsPage.tsx

@@ -46,6 +46,7 @@ interface Settings {
   angular_offset?: number
   auto_home_enabled?: boolean
   auto_home_after_patterns?: number
+  hard_reset_theta?: boolean
   // Pattern clearing settings
   clear_pattern_speed?: number
   custom_clear_from_in?: string
@@ -350,6 +351,7 @@ export function SettingsPage() {
         angular_offset: data.homing?.angular_offset_degrees,
         auto_home_enabled: data.homing?.auto_home_enabled,
         auto_home_after_patterns: data.homing?.auto_home_after_patterns,
+        hard_reset_theta: data.homing?.hard_reset_theta,
         // Pattern clearing settings
         clear_pattern_speed: data.patterns?.clear_pattern_speed,
         custom_clear_from_in: data.patterns?.custom_clear_from_in,
@@ -646,6 +648,7 @@ export function SettingsPage() {
           angular_offset_degrees: settings.angular_offset,
           auto_home_enabled: settings.auto_home_enabled,
           auto_home_after_patterns: settings.auto_home_after_patterns,
+          hard_reset_theta: settings.hard_reset_theta,
         },
       })
       toast.success('Homing configuration saved')
@@ -1087,6 +1090,31 @@ export function SettingsPage() {
               )}
             </div>
 
+            {/* Machine Reset on Theta Normalization */}
+            <div className="p-4 rounded-lg border space-y-3">
+              <div className="flex items-center justify-between">
+                <div>
+                  <p className="font-medium flex items-center gap-2">
+                    <span className="material-icons-outlined text-base">restart_alt</span>
+                    Reset Machine on Theta Normalization
+                  </p>
+                  <p className="text-xs text-muted-foreground mt-1">
+                    Also reset the machine controller when normalizing theta
+                  </p>
+                </div>
+                <Switch
+                  checked={settings.hard_reset_theta || false}
+                  onCheckedChange={(checked) =>
+                    setSettings({ ...settings, hard_reset_theta: checked })
+                  }
+                />
+              </div>
+              <p className="text-xs text-muted-foreground">
+                When disabled (default), theta normalization only adjusts the angle mathematically.
+                When enabled, also resets the machine controller to clear position counters.
+              </p>
+            </div>
+
             <Button
               onClick={handleSaveHomingConfig}
               disabled={isLoading === 'homing'}

+ 24 - 2
main.py

@@ -415,6 +415,7 @@ class HomingSettingsUpdate(BaseModel):
     angular_offset_degrees: Optional[float] = None
     auto_home_enabled: Optional[bool] = None
     auto_home_after_patterns: Optional[int] = None
+    hard_reset_theta: Optional[bool] = None  # Enable hard reset ($Bye) when resetting theta
 
 class DwLedSettingsUpdate(BaseModel):
     num_leds: Optional[int] = None
@@ -676,7 +677,8 @@ async def get_all_settings():
             "user_override": state.homing_user_override,  # True if user explicitly set, False if auto-detected
             "angular_offset_degrees": state.angular_homing_offset_degrees,
             "auto_home_enabled": state.auto_home_enabled,
-            "auto_home_after_patterns": state.auto_home_after_patterns
+            "auto_home_after_patterns": state.auto_home_after_patterns,
+            "hard_reset_theta": state.hard_reset_theta  # Enable hard reset when resetting theta
         },
         "led": {
             "provider": state.led_provider,
@@ -757,6 +759,18 @@ async def get_dynamic_manifest():
                 "sizes": "512x512",
                 "type": "image/png",
                 "purpose": "any"
+            },
+            {
+                "src": f"{icon_base}/android-chrome-192x192.png",
+                "sizes": "192x192",
+                "type": "image/png",
+                "purpose": "maskable"
+            },
+            {
+                "src": f"{icon_base}/android-chrome-512x512.png",
+                "sizes": "512x512",
+                "type": "image/png",
+                "purpose": "maskable"
             }
         ],
         "start_url": "/",
@@ -858,6 +872,8 @@ async def update_settings(settings_update: SettingsUpdate):
             state.auto_home_enabled = h.auto_home_enabled
         if h.auto_home_after_patterns is not None:
             state.auto_home_after_patterns = h.auto_home_after_patterns
+        if h.hard_reset_theta is not None:
+            state.hard_reset_theta = h.hard_reset_theta
         updated_categories.append("homing")
 
     # LED settings
@@ -2920,6 +2936,8 @@ def generate_pwa_icons_from_logo(logo_path: str, output_dir: str) -> bool:
     """Generate square PWA app icons from the uploaded logo.
 
     Creates square icons (no circular crop) - OS will apply its own mask.
+    Composites onto a solid background to avoid transparency issues
+    (iOS fills transparent areas with white on home screen icons).
 
     Generates:
     - apple-touch-icon.png (180x180)
@@ -2952,8 +2970,12 @@ def generate_pwa_icons_from_logo(logo_path: str, output_dir: str) -> bool:
 
             for filename, size in icon_sizes.items():
                 resized = img.resize((size, size), Image.Resampling.LANCZOS)
+                # Composite onto solid background to eliminate transparency
+                # (iOS shows white behind transparent areas on home screen)
+                background = Image.new('RGB', (size, size), (10, 10, 10))  # #0a0a0a theme color
+                background.paste(resized, (0, 0), resized)  # Use resized as its own alpha mask
                 icon_path = os.path.join(output_dir, filename)
-                resized.save(icon_path, format='PNG')
+                background.save(icon_path, format='PNG')
                 logger.info(f"Generated PWA icon: {filename}")
 
         return True

+ 28 - 18
modules/core/pattern_manager.py

@@ -1811,31 +1811,41 @@ def resume_execution():
     
 async def reset_theta():
     """
-    Reset theta to [0, 2π) range and hard reset machine position using $Bye.
+    Reset theta to [0, 2π) range and optionally hard reset machine position using $Bye.
 
-    $Bye sends a soft reset to FluidNC which resets the controller and clears
-    all position counters to 0. This is more reliable than G92 which only sets
-    a work coordinate offset without changing the actual machine position (MPos).
+    When state.hard_reset_theta is True:
+    - $Bye sends a soft reset to FluidNC which resets the controller and clears
+      all position counters to 0. This is more reliable than G92 which only sets
+      a work coordinate offset without changing the actual machine position (MPos).
+    - We wait for machine to be idle before sending $Bye to avoid error:25
 
-    IMPORTANT: We wait for machine to be idle before sending $Bye to avoid
-    error:25 ("Feed rate not specified in block") which can occur if the
-    controller is still processing commands when reset is triggered.
+    When state.hard_reset_theta is False (default):
+    - Only normalizes theta to [0, 2π) range without affecting machine position
+    - Faster and doesn't interrupt machine state
     """
     logger.info('Resetting Theta')
 
-    # Wait for machine to be idle before reset to prevent error:25
-    if state.conn and state.conn.is_connected():
-        logger.info("Waiting for machine to be idle before reset...")
-        idle = await connection_manager.check_idle_async(timeout=30)
-        if not idle:
-            logger.warning("Machine not idle after 30s, proceeding with reset anyway")
-
+    # Always normalize theta to [0, 2π) range
     state.current_theta = state.current_theta % (2 * pi)
+    logger.info(f'Theta normalized to {state.current_theta:.4f} radians')
+
+    # Only perform hard reset if enabled
+    if state.hard_reset_theta:
+        logger.info('Hard reset enabled - performing machine soft reset')
 
-    # Hard reset machine position using $Bye via connection_manager
-    success = await connection_manager.perform_soft_reset()
-    if not success:
-        logger.error("Soft reset failed - theta reset may be unreliable")
+        # Wait for machine to be idle before reset to prevent error:25
+        if state.conn and state.conn.is_connected():
+            logger.info("Waiting for machine to be idle before reset...")
+            idle = await connection_manager.check_idle_async(timeout=30)
+            if not idle:
+                logger.warning("Machine not idle after 30s, proceeding with reset anyway")
+
+        # Hard reset machine position using $Bye via connection_manager
+        success = await connection_manager.perform_soft_reset()
+        if not success:
+            logger.error("Soft reset failed - theta reset may be unreliable")
+    else:
+        logger.info('Hard reset disabled - skipping machine soft reset')
 
 def set_speed(new_speed):
     state.speed = new_speed

+ 7 - 0
modules/core/state.py

@@ -78,6 +78,11 @@ class AppState:
         self.auto_home_after_patterns = 5  # Number of patterns after which to auto-home
         self.patterns_since_last_home = 0  # Counter for patterns played since last home
 
+        # Hard reset on theta reset (sends $Bye to FluidNC to reset machine position)
+        # When False (default), only normalizes theta to [0, 2π) without machine reset
+        # When True, also performs soft reset which clears all position counters
+        self.hard_reset_theta = False
+
         self.STATE_FILE = "state.json"
         self.mqtt_handler = None  # Will be set by the MQTT handler
         self.conn = None
@@ -447,6 +452,7 @@ class AppState:
             "angular_homing_offset_degrees": self.angular_homing_offset_degrees,
             "auto_home_enabled": self.auto_home_enabled,
             "auto_home_after_patterns": self.auto_home_after_patterns,
+            "hard_reset_theta": self.hard_reset_theta,
             "current_playlist": self._current_playlist,
             "current_playlist_name": self._current_playlist_name,
             "current_playlist_index": self.current_playlist_index,
@@ -519,6 +525,7 @@ class AppState:
         self.angular_homing_offset_degrees = data.get('angular_homing_offset_degrees', 0.0)
         self.auto_home_enabled = data.get('auto_home_enabled', False)
         self.auto_home_after_patterns = data.get('auto_home_after_patterns', 5)
+        self.hard_reset_theta = data.get('hard_reset_theta', False)
         self._current_playlist = data.get("current_playlist", None)
         self._current_playlist_name = data.get("current_playlist_name", None)
         self.current_playlist_index = data.get("current_playlist_index", None)

+ 12 - 0
static/site.webmanifest

@@ -14,6 +14,18 @@
       "sizes": "512x512",
       "type": "image/png",
       "purpose": "any"
+    },
+    {
+      "src": "/static/android-chrome-192x192.png",
+      "sizes": "192x192",
+      "type": "image/png",
+      "purpose": "maskable"
+    },
+    {
+      "src": "/static/android-chrome-512x512.png",
+      "sizes": "512x512",
+      "type": "image/png",
+      "purpose": "maskable"
     }
   ],
   "start_url": "/",