Ver Fonte

Auto play on boot (#76)

* add auto play on boot, fix UI loading performance issue

* clean up

* cache check should be async

* add ignored port

* clean up error

* handle wrong serial conn

* properly close conn
Tuan Nguyen há 4 meses atrás
pai
commit
af246055ee

+ 0 - 88
CHANGELOG.md

@@ -1,88 +0,0 @@
-# Changelog
-
-All notable changes to this project will be documented in this file.
-
-## [1.4.0] Soft- and Firmware updater
-
-### New Features
-- **Play/Pause Button:** Control execution flow without stopping.
-- **Scheduled Running Hours:** Automate table operation.
-- **Adaptive clear:** Select the appropriate clearing pattenr based on the starting coordinate
-- **Table Info on Serial Connection:** Real-time metadata and status.
-- **Table Status API:** Poll current status remotely.
-- **Firmware Versioning:** Track and fetch firmware details.
-
-### Fixes & Improvements
-- **Quick Fixes:** Stability and performance improvements.
-- **Firmware Updater (#34):** Version tracking, remote flashing, and bug fixes.
-- **UI/UX Enhancements:** Improved "Currently Playing," settings overlay, and styling.
-- **Software Updates:** Enhanced updater, dependency installation via `DockerFile`.
-- **Performance:** Better serial locking, retry logic, and pattern execution handling.
-- **ESP32_TMC2209 Support:** Added firmware flashing.
-
-### Contributors
-- **Thokoop** ([GitHub](https://github.com/Thokoop))
-- **Fabio De Simone** (https://github.com/ProtoxiDe22)
-
-## [1.3.0] Revamped UI
-
-Massive thanks to Thokoop for helping us redesigning the UI just within a few days! The new design looks gorgeous on both PC and mobile. 
-
-![New UI](./static/UI_1.3.png)
-
-![New UI mobile](./static/IMG_9753.png)
-
-## [1.2.0] - Playlist mode, Spirograph functionality
-
-### Added
-
-#### Playlist mode
-
-- Added UI selection mode for single pattern run, create a playlist, and run a playlist. The UI is work in progress and will be changed in the near future.
-- Created playlist will be saved as a JSON file on disk. There are options to:
-  - Run the playlist once or on an indefinite loop
-  - Shuffle the playlist
-  - Add a clear pattern between files that will run immmidiately before each pattern. If you would like to customize the clear pattern, select None here and add clear patterns manually.
-  - Add a pause time (in second) between each pattern.
-
-#### Spirograph mode
-
-- Added support for Spirograph mode for the arduino with DRV8825 or TMC2209 motor drivers
-- Can be used if optional hardware (two potentiometers and a button) is connected.
-
-### Changed
-
-- Fixed a bug that created conflicting threads, leading to serial errors
-- Fixed a bug that caused the speed setting functionality to not work
-- Fixed a bug that caused the ball to move in slightly when a pattern starts at the perimeter and theta != 0
-
-### Known issues
-
-- Patterns with theta does not start with 0 will behave abnormally. To get around this, be sure to select start from center or perimeter when creating your pattern in sandify.org.
-
-## [1.1.0] - Auto connect functionality
-
-### Added
-- **Auto-connect Serial Connection when app is started**
-    - Automatically selected the first available serial port if none was specified.
-- **Added Footer with:**
-  - Links to github
-  - Toggle button to show/hide the debug log
-
-### Changed
-- **Improved UI**
-  - Certain buttons are now only visible when it's applicable for the current state.
-  - Moved Stop button and Speed input to Quick Actions**
-- **Pattern file prioritization:**
-    - Updated the `/list_theta_rho_files` endpoint to:
-        - Only display files with the `.thr` extension.
-        - Include `custom_patterns/default_pattern.thr` at the top of the list if it exists.
-        - Prioritize files in the `custom_patterns/` folder over other files.
-
-## [1.0.0] - Initial Version
-- Initial implementation of the Flask application to control the Dune Weaver sand table.
-- Added core functionality for:
-    - Serial port connection and management.
-    - Parsing `.thr` files (theta-rho format).
-    - Executing patterns via Arduino.
-    - Basic Flask endpoints for listing, uploading, and running `.thr` files.

+ 1 - 1
VERSION

@@ -1 +1 @@
-3.1.2
+3.2.0

+ 3 - 6
docker-compose.yml

@@ -1,16 +1,13 @@
 services:
-  flask-app:
+  dune-weaver:
     build: . # Uncomment this if you need to build 
     image: ghcr.io/tuanchris/dune-weaver:main # Use latest production image
     restart: always
     ports:
-      - "8080:8080" # Map port 8080 of the container to 8080 of the host
+      - "8080:8080" # Map port 8080 of the container to 8080 of the host (access via http://localhost:8080)
     volumes:
       - .:/app
     devices:
       - "/dev/ttyACM0:/dev/ttyACM0"
     privileged: true
-    environment:
-      - FLASK_ENV=development # Set environment variables for Flask
-      - LOG_LEVEL=info # Set logging level (debug, info, warning, error, critical)
-    container_name: flask-theta-rho-app
+    container_name: dune-weaver

+ 87 - 10
main.py

@@ -70,6 +70,25 @@ async def lifespan(app: FastAPI):
         connection_manager.connect_device()
     except Exception as e:
         logger.warning(f"Failed to auto-connect to serial port: {str(e)}")
+    
+    # Check if auto_play mode is enabled and auto-play playlist (right after connection attempt)
+    if state.auto_play_enabled and state.auto_play_playlist:
+        logger.info(f"auto_play mode enabled, checking for connection before auto-playing playlist: {state.auto_play_playlist}")
+        try:
+            # Check if we have a valid connection before starting playlist
+            if state.conn and hasattr(state.conn, 'is_connected') and state.conn.is_connected():
+                logger.info(f"Connection available, starting auto-play playlist: {state.auto_play_playlist} with options: run_mode={state.auto_play_run_mode}, pause_time={state.auto_play_pause_time}, clear_pattern={state.auto_play_clear_pattern}, shuffle={state.auto_play_shuffle}")
+                asyncio.create_task(playlist_manager.run_playlist(
+                    state.auto_play_playlist,
+                    pause_time=state.auto_play_pause_time,
+                    clear_pattern=state.auto_play_clear_pattern,
+                    run_mode=state.auto_play_run_mode,
+                    shuffle=state.auto_play_shuffle
+                ))
+            else:
+                logger.warning("No hardware connection available, skipping auto_play mode auto-play")
+        except Exception as e:
+            logger.error(f"Failed to auto-play auto_play playlist: {str(e)}")
         
     try:
         mqtt_handler = mqtt.init_mqtt()
@@ -78,8 +97,8 @@ async def lifespan(app: FastAPI):
     
     # Start cache generation in background if needed
     try:
-        from modules.core.cache_manager import is_cache_generation_needed, generate_cache_background
-        if is_cache_generation_needed():
+        from modules.core.cache_manager import is_cache_generation_needed_async, generate_cache_background
+        if await is_cache_generation_needed_async():
             logger.info("Cache generation needed, starting background task...")
             asyncio.create_task(generate_cache_background())
         else:
@@ -98,6 +117,14 @@ app.mount("/static", StaticFiles(directory="static"), name="static")
 class ConnectRequest(BaseModel):
     port: Optional[str] = None
 
+class auto_playModeRequest(BaseModel):
+    enabled: bool
+    playlist: Optional[str] = None
+    run_mode: Optional[str] = "loop"
+    pause_time: Optional[float] = 5.0
+    clear_pattern: Optional[str] = "adaptive"
+    shuffle: Optional[bool] = False
+
 class CoordinateRequest(BaseModel):
     theta: float
     rho: float
@@ -217,6 +244,37 @@ async def index(request: Request):
 async def settings(request: Request):
     return templates.TemplateResponse("settings.html", {"request": request, "app_name": state.app_name})
 
+@app.get("/api/auto_play-mode")
+async def get_auto_play_mode():
+    """Get current auto_play mode settings."""
+    return {
+        "enabled": state.auto_play_enabled,
+        "playlist": state.auto_play_playlist,
+        "run_mode": state.auto_play_run_mode,
+        "pause_time": state.auto_play_pause_time,
+        "clear_pattern": state.auto_play_clear_pattern,
+        "shuffle": state.auto_play_shuffle
+    }
+
+@app.post("/api/auto_play-mode")
+async def set_auto_play_mode(request: auto_playModeRequest):
+    """Update auto_play mode settings."""
+    state.auto_play_enabled = request.enabled
+    if request.playlist is not None:
+        state.auto_play_playlist = request.playlist
+    if request.run_mode is not None:
+        state.auto_play_run_mode = request.run_mode
+    if request.pause_time is not None:
+        state.auto_play_pause_time = request.pause_time
+    if request.clear_pattern is not None:
+        state.auto_play_clear_pattern = request.clear_pattern
+    if request.shuffle is not None:
+        state.auto_play_shuffle = request.shuffle
+    state.save()
+    
+    logger.info(f"auto_play mode {'enabled' if request.enabled else 'disabled'}, playlist: {request.playlist}")
+    return {"success": True, "message": "auto_play mode settings updated"}
+
 @app.get("/list_serial_ports")
 async def list_ports():
     logger.debug("Listing available serial ports")
@@ -271,20 +329,29 @@ async def list_theta_rho_files():
 
 @app.get("/list_theta_rho_files_with_metadata")
 async def list_theta_rho_files_with_metadata():
-    """Get list of theta-rho files with metadata for sorting and filtering."""
+    """Get list of theta-rho files with metadata for sorting and filtering.
+    
+    Optimized to process files asynchronously and support request cancellation.
+    """
     from modules.core.cache_manager import get_pattern_metadata
+    import asyncio
+    from concurrent.futures import ThreadPoolExecutor
     
     files = pattern_manager.list_theta_rho_files()
     files_with_metadata = []
     
-    for file_path in files:
+    # Use ThreadPoolExecutor for I/O-bound operations
+    executor = ThreadPoolExecutor(max_workers=4)
+    
+    def process_file(file_path):
+        """Process a single file and return its metadata."""
         try:
             full_path = os.path.join(pattern_manager.THETA_RHO_DIR, file_path)
             
             # Get file stats
             file_stat = os.stat(full_path)
             
-            # Get cached metadata
+            # Get cached metadata (this should be fast if cached)
             metadata = get_pattern_metadata(file_path)
             
             # Extract full folder path from file path
@@ -301,7 +368,7 @@ async def list_theta_rho_files_with_metadata():
             # Use modification time (mtime) for "date modified"
             date_modified = file_stat.st_mtime
             
-            file_info = {
+            return {
                 'path': file_path,
                 'name': file_name,
                 'category': category,
@@ -309,8 +376,6 @@ async def list_theta_rho_files_with_metadata():
                 'coordinates_count': metadata.get('total_coordinates', 0) if metadata else 0
             }
             
-            files_with_metadata.append(file_info)
-            
         except Exception as e:
             logger.warning(f"Error getting metadata for {file_path}: {str(e)}")
             # Include file with minimal info if metadata fails
@@ -319,13 +384,25 @@ async def list_theta_rho_files_with_metadata():
                 category = '/'.join(path_parts[:-1])
             else:
                 category = 'root'
-            files_with_metadata.append({
+            return {
                 'path': file_path,
                 'name': os.path.splitext(os.path.basename(file_path))[0],
                 'category': category,
                 'date_modified': 0,
                 'coordinates_count': 0
-            })
+            }
+    
+    # Process files in parallel using asyncio
+    loop = asyncio.get_event_loop()
+    tasks = [loop.run_in_executor(executor, process_file, file_path) for file_path in files]
+    
+    # Process results as they complete
+    for task in asyncio.as_completed(tasks):
+        try:
+            result = await task
+            files_with_metadata.append(result)
+        except Exception as e:
+            logger.error(f"Error processing file: {str(e)}")
     
     return files_with_metadata
 

+ 8 - 4
modules/connection/connection_manager.py

@@ -9,7 +9,7 @@ from modules.core.state import state
 from modules.led.led_controller import effect_loading, effect_idle, effect_connected, LEDController
 logger = logging.getLogger(__name__)
 
-IGNORE_PORTS = ['/dev/cu.debug-console', '/dev/cu.Bluetooth-Incoming-Port']
+IGNORE_PORTS = ['/dev/cu.debug-console', '/dev/cu.Bluetooth-Incoming-Port', '/dev/ttyS0']
 
 ###############################################################################
 # Connection Abstraction
@@ -137,9 +137,14 @@ def device_init(homing=True):
     try:
         if get_machine_steps():
             logger.info(f"x_steps_per_mm: {state.x_steps_per_mm}, y_steps_per_mm: {state.y_steps_per_mm}, gear_ratio: {state.gear_ratio}")
+        else: 
+            logger.fatal("Failed to get machine steps")
+            state.conn.close()
+            return False
     except:
         logger.fatal("Not GRBL firmware")
-        pass
+        state.conn.close()
+        return False
 
     machine_x, machine_y = get_machine_position()
     if machine_x != state.machine_x or machine_y != state.machine_y:
@@ -170,9 +175,8 @@ def connect_device(homing=True):
     elif ports:
         state.conn = SerialConnection(ports[0])
     else:
-        logger.warning("No serial ports found. Falling back to WebSocket.")
+        logger.error("Auto connect failed.")
         # state.conn = WebSocketConnection('ws://fluidnc.local:81')
-        return
     if (state.conn.is_connected() if state.conn else False):
         device_init(homing)
         

+ 161 - 1
modules/core/cache_manager.py

@@ -102,6 +102,23 @@ def invalidate_cache():
         logger.error(f"Failed to invalidate metadata cache: {str(e)}")
         return False
 
+async def invalidate_cache_async():
+    """Async version: Delete only the metadata cache file, preserving image cache."""
+    try:
+        # Delete metadata cache file only
+        if await asyncio.to_thread(os.path.exists, METADATA_CACHE_FILE):
+            await asyncio.to_thread(os.remove, METADATA_CACHE_FILE)
+            logger.info("Deleted outdated metadata cache file")
+        
+        # Keep image cache directory intact - images are still valid
+        # Just ensure the cache directory structure exists
+        await ensure_cache_dir_async()
+        
+        return True
+    except Exception as e:
+        logger.error(f"Failed to invalidate metadata cache: {str(e)}")
+        return False
+
 def ensure_cache_dir():
     """Ensure the cache directory exists with proper permissions."""
     try:
@@ -138,6 +155,46 @@ def ensure_cache_dir():
     except Exception as e:
         logger.error(f"Failed to create cache directory: {str(e)}")
 
+async def ensure_cache_dir_async():
+    """Async version: Ensure the cache directory exists with proper permissions."""
+    try:
+        await asyncio.to_thread(Path(CACHE_DIR).mkdir, parents=True, exist_ok=True)
+        
+        # Initialize metadata cache if it doesn't exist
+        if not await asyncio.to_thread(os.path.exists, METADATA_CACHE_FILE):
+            initial_cache = {
+                'version': CACHE_SCHEMA_VERSION,
+                'data': {}
+            }
+            def _write_initial_cache():
+                with open(METADATA_CACHE_FILE, 'w') as f:
+                    json.dump(initial_cache, f)
+            
+            await asyncio.to_thread(_write_initial_cache)
+            try:
+                await asyncio.to_thread(os.chmod, METADATA_CACHE_FILE, 0o644)
+            except (OSError, PermissionError) as e:
+                logger.debug(f"Could not set metadata cache file permissions: {str(e)}")
+        
+        def _set_permissions():
+            for root, dirs, files in os.walk(CACHE_DIR):
+                try:
+                    os.chmod(root, 0o755)
+                    for file in files:
+                        file_path = os.path.join(root, file)
+                        try:
+                            os.chmod(file_path, 0o644)
+                        except (OSError, PermissionError) as e:
+                            logger.debug(f"Could not set permissions for file {file_path}: {str(e)}")
+                except (OSError, PermissionError) as e:
+                    logger.debug(f"Could not set permissions for directory {root}: {str(e)}")
+                    continue
+        
+        await asyncio.to_thread(_set_permissions)
+                
+    except Exception as e:
+        logger.error(f"Failed to create cache directory: {str(e)}")
+
 def get_cache_path(pattern_file):
     """Get the cache path for a pattern file."""
     # Normalize path separators to handle both forward slashes and backslashes
@@ -221,6 +278,40 @@ def load_metadata_cache():
         'data': {}
     }
 
+async def load_metadata_cache_async():
+    """Async version: Load the metadata cache from disk with schema validation."""
+    try:
+        if await asyncio.to_thread(os.path.exists, METADATA_CACHE_FILE):
+            def _load_json():
+                with open(METADATA_CACHE_FILE, 'r') as f:
+                    return json.load(f)
+            
+            cache_data = await asyncio.to_thread(_load_json)
+            
+            # Validate schema
+            if not validate_cache_schema(cache_data):
+                logger.info("Cache schema validation failed - invalidating cache")
+                await invalidate_cache_async()
+                # Return empty cache structure after invalidation
+                return {
+                    'version': CACHE_SCHEMA_VERSION,
+                    'data': {}
+                }
+            
+            return cache_data
+    except Exception as e:
+        logger.warning(f"Failed to load metadata cache: {str(e)} - invalidating cache")
+        try:
+            await invalidate_cache_async()
+        except Exception as invalidate_error:
+            logger.error(f"Failed to invalidate corrupted cache: {str(invalidate_error)}")
+    
+    # Return empty cache structure
+    return {
+        'version': CACHE_SCHEMA_VERSION,
+        'data': {}
+    }
+
 def save_metadata_cache(cache_data):
     """Save the metadata cache to disk with version info."""
     try:
@@ -264,6 +355,25 @@ def get_pattern_metadata(pattern_file):
     
     return None
 
+async def get_pattern_metadata_async(pattern_file):
+    """Async version: Get cached metadata for a pattern file."""
+    cache_data = await load_metadata_cache_async()
+    data_section = cache_data.get('data', {})
+    
+    # Check if we have cached metadata and if the file hasn't changed
+    if pattern_file in data_section:
+        cached_entry = data_section[pattern_file]
+        pattern_path = os.path.join(THETA_RHO_DIR, pattern_file)
+        
+        try:
+            file_mtime = await asyncio.to_thread(os.path.getmtime, pattern_path)
+            if cached_entry.get('mtime') == file_mtime:
+                return cached_entry.get('metadata')
+        except OSError:
+            pass
+    
+    return None
+
 def cache_pattern_metadata(pattern_file, first_coord, last_coord, total_coords):
     """Cache metadata for a pattern file."""
     try:
@@ -301,6 +411,20 @@ def needs_cache(pattern_file):
         
     return False
 
+async def needs_cache_async(pattern_file):
+    """Async version: Check if a pattern file needs its cache generated."""
+    # Check if image preview exists
+    cache_path = get_cache_path(pattern_file)
+    if not await asyncio.to_thread(os.path.exists, cache_path):
+        return True
+        
+    # Check if metadata cache exists and is valid
+    metadata = await get_pattern_metadata_async(pattern_file)
+    if metadata is None:
+        return True
+        
+    return False
+
 async def generate_image_preview(pattern_file):
     """Generate image preview for a single pattern file."""
     from modules.core.preview import generate_preview_image
@@ -594,4 +718,40 @@ def is_cache_generation_needed():
         if get_pattern_metadata(file_name) is None:
             files_needing_metadata.append(file_name)
     
-    return len(patterns_to_cache) > 0 or len(files_needing_metadata) > 0
+    return len(patterns_to_cache) > 0 or len(files_needing_metadata) > 0
+
+async def is_cache_generation_needed_async():
+    """Async version: Check if cache generation is needed."""
+    pattern_files = await list_theta_rho_files_async()
+    pattern_files = [f for f in pattern_files if f.endswith('.thr')]
+    
+    if not pattern_files:
+        return False
+    
+    # Check if any files need caching (check in parallel)
+    needs_cache_tasks = [needs_cache_async(f) for f in pattern_files]
+    needs_cache_results = await asyncio.gather(*needs_cache_tasks)
+    patterns_to_cache = [f for f, needs in zip(pattern_files, needs_cache_results) if needs]
+    
+    # Check metadata cache (check in parallel)
+    metadata_tasks = [get_pattern_metadata_async(f) for f in pattern_files]
+    metadata_results = await asyncio.gather(*metadata_tasks)
+    files_needing_metadata = [f for f, metadata in zip(pattern_files, metadata_results) if metadata is None]
+    
+    return len(patterns_to_cache) > 0 or len(files_needing_metadata) > 0
+
+async def list_theta_rho_files_async():
+    """Async version: List all theta-rho files."""
+    def _walk_files():
+        files = []
+        for root, _, filenames in os.walk(THETA_RHO_DIR):
+            for file in filenames:
+                relative_path = os.path.relpath(os.path.join(root, file), THETA_RHO_DIR)
+                # Normalize path separators to always use forward slashes for consistency across platforms
+                relative_path = relative_path.replace(os.sep, '/')
+                files.append(relative_path)
+        return files
+    
+    files = await asyncio.to_thread(_walk_files)
+    logger.debug(f"Found {len(files)} theta-rho files")
+    return [file for file in files if file.endswith('.thr')]

+ 20 - 0
modules/core/state.py

@@ -53,6 +53,14 @@ class AppState:
         # Application name setting
         self.app_name = "Dune Weaver"  # Default app name
         
+        # auto_play mode settings
+        self.auto_play_enabled = False
+        self.auto_play_playlist = None  # Playlist to auto-play in auto_play mode
+        self.auto_play_run_mode = "loop"  # "single" or "loop" 
+        self.auto_play_pause_time = 5.0  # Pause between patterns in seconds
+        self.auto_play_clear_pattern = "adaptive"  # Clear pattern option
+        self.auto_play_shuffle = False  # Shuffle playlist
+        
         self.load()
 
     @property
@@ -178,6 +186,12 @@ class AppState:
             "port": self.port,
             "wled_ip": self.wled_ip,
             "app_name": self.app_name,
+            "auto_play_enabled": self.auto_play_enabled,
+            "auto_play_playlist": self.auto_play_playlist,
+            "auto_play_run_mode": self.auto_play_run_mode,
+            "auto_play_pause_time": self.auto_play_pause_time,
+            "auto_play_clear_pattern": self.auto_play_clear_pattern,
+            "auto_play_shuffle": self.auto_play_shuffle,
         }
 
     def from_dict(self, data):
@@ -208,6 +222,12 @@ class AppState:
         self.port = data.get("port", None)
         self.wled_ip = data.get('wled_ip', None)
         self.app_name = data.get("app_name", "Dune Weaver")
+        self.auto_play_enabled = data.get("auto_play_enabled", False)
+        self.auto_play_playlist = data.get("auto_play_playlist", None)
+        self.auto_play_run_mode = data.get("auto_play_run_mode", "loop")
+        self.auto_play_pause_time = data.get("auto_play_pause_time", 5.0)
+        self.auto_play_clear_pattern = data.get("auto_play_clear_pattern", "adaptive")
+        self.auto_play_shuffle = data.get("auto_play_shuffle", False)
 
     def save(self):
         """Save the current state to a JSON file."""

+ 1 - 1
modules/mqtt/__init__.py

@@ -1,4 +1,4 @@
-"""MQTT module for Dune Weaver Flask application."""
+"""MQTT module for Dune Weaver application."""
 from .factory import create_mqtt_handler
 import logging
 

+ 55 - 15
static/js/index.js

@@ -4,6 +4,9 @@ let allPatternsWithMetadata = []; // Enhanced pattern data with metadata
 let currentSort = { field: 'name', direction: 'asc' };
 let currentFilters = { category: 'all' };
 
+// AbortController for cancelling in-flight requests when navigating away
+let metadataAbortController = null;
+
 // Helper function to normalize file paths for cross-platform compatibility
 function normalizeFilePath(filePath) {
     if (!filePath) return '';
@@ -666,23 +669,41 @@ async function loadPatterns(forceRefresh = false) {
         // Load metadata in background for enhanced features
         setTimeout(async () => {
             try {
-            logMessage('Loading enhanced metadata...', LOG_TYPE.DEBUG);
-            const metadataResponse = await fetch('/list_theta_rho_files_with_metadata');
-            const patternsWithMetadata = await metadataResponse.json();
-            
-            // Store enhanced patterns data
-            allPatternsWithMetadata = [...patternsWithMetadata];
-            
-            // Update category filter dropdown now that we have metadata
-            updateBrowseCategoryFilter();
-            
-            // Enable sort controls and display patterns consistently
-            enableSortControls();
-            
-            logMessage(`Enhanced metadata loaded for ${patternsWithMetadata.length} patterns`, LOG_TYPE.SUCCESS);
+                // Cancel any previous metadata loading request
+                if (metadataAbortController) {
+                    metadataAbortController.abort();
+                }
+                
+                // Create new AbortController for this request
+                metadataAbortController = new AbortController();
+                
+                logMessage('Loading enhanced metadata...', LOG_TYPE.DEBUG);
+                const metadataResponse = await fetch('/list_theta_rho_files_with_metadata', {
+                    signal: metadataAbortController.signal
+                });
+                const patternsWithMetadata = await metadataResponse.json();
+                
+                // Store enhanced patterns data
+                allPatternsWithMetadata = [...patternsWithMetadata];
+                
+                // Update category filter dropdown now that we have metadata
+                updateBrowseCategoryFilter();
+                
+                // Enable sort controls and display patterns consistently
+                enableSortControls();
+                
+                logMessage(`Enhanced metadata loaded for ${patternsWithMetadata.length} patterns`, LOG_TYPE.SUCCESS);
+                
+                // Clear the controller reference since request completed
+                metadataAbortController = null;
             } catch (metadataError) {
-                logMessage(`Failed to load enhanced metadata: ${metadataError.message}`, LOG_TYPE.WARNING);
+                if (metadataError.name === 'AbortError') {
+                    logMessage('Metadata loading cancelled (navigating away)', LOG_TYPE.DEBUG);
+                } else {
+                    logMessage(`Failed to load enhanced metadata: ${metadataError.message}`, LOG_TYPE.WARNING);
+                }
                 // No fallback needed - basic patterns already displayed
+                metadataAbortController = null;
             }
         }, 100); // Small delay to let initial render complete
         if (forceRefresh) {
@@ -1505,6 +1526,25 @@ document.addEventListener('DOMContentLoaded', async () => {
     }
 });
 
+// Cancel any pending requests when navigating away from the page
+window.addEventListener('beforeunload', () => {
+    if (metadataAbortController) {
+        metadataAbortController.abort();
+        metadataAbortController = null;
+        logMessage('Cancelled pending metadata request due to navigation', LOG_TYPE.DEBUG);
+    }
+});
+
+// Also handle page visibility changes (switching tabs, minimizing, etc.)
+document.addEventListener('visibilitychange', () => {
+    if (document.hidden && metadataAbortController) {
+        // Cancel long-running requests when page is hidden
+        metadataAbortController.abort();
+        metadataAbortController = null;
+        logMessage('Cancelled pending metadata request due to page hidden', LOG_TYPE.DEBUG);
+    }
+});
+
 function updateCurrentlyPlayingUI(status) {
     // Get all required DOM elements once
     const container = document.getElementById('currently-playing-container');

+ 72 - 30
static/js/playlists.js

@@ -1551,34 +1551,48 @@ function setupEventListeners() {
     });
 
     // Add patterns button
-    document.getElementById('addPatternsBtn').addEventListener('click', async () => {
-        // Load current playlist patterns first
-        if (currentPlaylist) {
-            const response = await fetch(`/get_playlist?name=${encodeURIComponent(currentPlaylist)}`);
-            if (response.ok) {
-                const playlistData = await response.json();
-                const currentFiles = playlistData.files || [];
-                // Pre-select current patterns
-                selectedPatterns.clear();
-                currentFiles.forEach(pattern => selectedPatterns.add(pattern));
-            }
-        }
-        
-        await loadAvailablePatterns();
-        updatePatternSelection();
-        updateSelectionCount();
-        
-        // Update modal title
+    document.getElementById('addPatternsBtn').addEventListener('click', () => {
+        // Update modal title immediately
         const modalTitle = document.getElementById('modalTitle');
         if (modalTitle) {
             modalTitle.textContent = currentPlaylist ? `Edit Patterns for "${currentPlaylist}"` : 'Add Patterns to Playlist';
         }
         
+        // Show modal immediately for better responsiveness
         document.getElementById('addPatternsModal').classList.remove('hidden');
+        
         // Focus search input when modal opens
         setTimeout(() => {
             document.getElementById('patternSearchInput').focus();
         }, 100);
+        
+        // Load data asynchronously after modal is visible
+        const loadModalData = async () => {
+            try {
+                // Load current playlist patterns first if editing
+                if (currentPlaylist) {
+                    const response = await fetch(`/get_playlist?name=${encodeURIComponent(currentPlaylist)}`);
+                    if (response.ok) {
+                        const playlistData = await response.json();
+                        const currentFiles = playlistData.files || [];
+                        // Pre-select current patterns
+                        selectedPatterns.clear();
+                        currentFiles.forEach(pattern => selectedPatterns.add(pattern));
+                    }
+                }
+                
+                // Load available patterns
+                await loadAvailablePatterns();
+                updatePatternSelection();
+                updateSelectionCount();
+            } catch (error) {
+                console.error('Failed to load modal data:', error);
+                showStatusMessage('Failed to load patterns', 'error');
+            }
+        };
+        
+        // Start loading data without blocking
+        loadModalData();
     });
 
     // Search functionality
@@ -1646,15 +1660,10 @@ function setupEventListeners() {
 }
 
 // Initialize playlists page
-document.addEventListener('DOMContentLoaded', async () => {
+document.addEventListener('DOMContentLoaded', () => {
     try {
-        // Initialize intersection observer for lazy loading
+        // Initialize UI immediately for fast page load
         initializeIntersectionObserver();
-        
-        // Initialize IndexedDB preview cache
-        await initPreviewCacheDB();
-        
-        // Setup event listeners
         setupEventListeners();
         
         // Initialize mobile view state
@@ -1672,13 +1681,46 @@ document.addEventListener('DOMContentLoaded', async () => {
         restorePlaybackSettings();
         setupPlaybackSettingsPersistence();
         
-        // Load playlists
-        await loadPlaylists();
+        // Show loading indicator for playlists
+        const playlistsNav = document.getElementById('playlistsNav');
+        if (playlistsNav) {
+            playlistsNav.innerHTML = `
+                <div class="flex items-center justify-center py-8 text-gray-500 dark:text-gray-400">
+                    <div class="flex items-center gap-2">
+                        <div class="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-500"></div>
+                        <span class="text-sm">Loading playlists...</span>
+                    </div>
+                </div>
+            `;
+        }
         
-        // Check serial connection status
-        await checkSerialStatus();
+        // Load data asynchronously without blocking page render
+        Promise.all([
+            // Initialize IndexedDB preview cache
+            initPreviewCacheDB().catch(err => {
+                console.error('Failed to init preview cache:', err);
+                return null;
+            }),
+            
+            // Load playlists
+            loadPlaylists().catch(err => {
+                console.error('Failed to load playlists:', err);
+                showStatusMessage('Failed to load playlists', 'error');
+                return null;
+            }),
+            
+            // Check serial connection status
+            checkSerialStatus().catch(err => {
+                console.error('Failed to check serial status:', err);
+                return null;
+            })
+        ]).then(() => {
+            logMessage('Playlists page initialized successfully', LOG_TYPE.SUCCESS);
+        }).catch(error => {
+            logMessage(`Error during initialization: ${error.message}`, LOG_TYPE.ERROR);
+            showStatusMessage('Failed to initialize playlists page', 'error');
+        });
         
-        logMessage('Playlists page initialized successfully', LOG_TYPE.SUCCESS);
     } catch (error) {
         logMessage(`Error during initialization: ${error.message}`, LOG_TYPE.ERROR);
         showStatusMessage('Failed to initialize playlists page', 'error');

+ 90 - 1
static/js/settings.js

@@ -931,4 +931,93 @@ function initializeAutocomplete(inputId, suggestionsId, clearButtonId, patterns)
     
     // Initialize clear button visibility
     updateClearButton();
-} 
+} 
+
+// auto_play Mode Functions
+async function initializeauto_playMode() {
+    const auto_playToggle = document.getElementById('auto_playModeToggle');
+    const auto_playSettings = document.getElementById('auto_playSettings');
+    const auto_playPlaylistSelect = document.getElementById('auto_playPlaylistSelect');
+    const auto_playRunModeSelect = document.getElementById('auto_playRunModeSelect');
+    const auto_playPauseTimeInput = document.getElementById('auto_playPauseTimeInput');
+    const auto_playClearPatternSelect = document.getElementById('auto_playClearPatternSelect');
+    const auto_playShuffleToggle = document.getElementById('auto_playShuffleToggle');
+    
+    // Load current auto_play settings
+    try {
+        const response = await fetch('/api/auto_play-mode');
+        const data = await response.json();
+        
+        auto_playToggle.checked = data.enabled;
+        if (data.enabled) {
+            auto_playSettings.style.display = 'block';
+        }
+        
+        // Set current values
+        auto_playRunModeSelect.value = data.run_mode || 'loop';
+        auto_playPauseTimeInput.value = data.pause_time || 5.0;
+        auto_playClearPatternSelect.value = data.clear_pattern || 'adaptive';
+        auto_playShuffleToggle.checked = data.shuffle || false;
+        
+        // Load playlists for selection
+        const playlistsResponse = await fetch('/list_all_playlists');
+        const playlists = await playlistsResponse.json();
+        
+        // Clear and populate playlist select
+        auto_playPlaylistSelect.innerHTML = '<option value="">Select a playlist...</option>';
+        playlists.forEach(playlist => {
+            const option = document.createElement('option');
+            option.value = playlist;
+            option.textContent = playlist;
+            if (playlist === data.playlist) {
+                option.selected = true;
+            }
+            auto_playPlaylistSelect.appendChild(option);
+        });
+    } catch (error) {
+        logMessage(`Error loading auto_play settings: ${error.message}`, LOG_TYPE.ERROR);
+    }
+    
+    // Function to save settings
+    async function saveSettings() {
+        try {
+            const response = await fetch('/api/auto_play-mode', {
+                method: 'POST',
+                headers: { 'Content-Type': 'application/json' },
+                body: JSON.stringify({
+                    enabled: auto_playToggle.checked,
+                    playlist: auto_playPlaylistSelect.value || null,
+                    run_mode: auto_playRunModeSelect.value,
+                    pause_time: parseFloat(auto_playPauseTimeInput.value) || 0,
+                    clear_pattern: auto_playClearPatternSelect.value,
+                    shuffle: auto_playShuffleToggle.checked
+                })
+            });
+            
+            if (!response.ok) {
+                throw new Error('Failed to save settings');
+            }
+        } catch (error) {
+            logMessage(`Error saving auto_play settings: ${error.message}`, LOG_TYPE.ERROR);
+        }
+    }
+    
+    // Toggle auto_play settings visibility and save
+    auto_playToggle.addEventListener('change', async () => {
+        auto_playSettings.style.display = auto_playToggle.checked ? 'block' : 'none';
+        await saveSettings();
+    });
+    
+    // Save when any setting changes
+    auto_playPlaylistSelect.addEventListener('change', saveSettings);
+    auto_playRunModeSelect.addEventListener('change', saveSettings);
+    auto_playPauseTimeInput.addEventListener('change', saveSettings);
+    auto_playPauseTimeInput.addEventListener('input', saveSettings); // Save as user types
+    auto_playClearPatternSelect.addEventListener('change', saveSettings);
+    auto_playShuffleToggle.addEventListener('change', saveSettings);
+}
+
+// Initialize auto_play mode when DOM is ready
+document.addEventListener('DOMContentLoaded', function() {
+    initializeauto_playMode();
+});

+ 100 - 0
templates/settings.html

@@ -388,6 +388,106 @@ endblock %}
       </label>
     </div>
   </section>
+  <section class="bg-white rounded-xl shadow-sm overflow-hidden">
+    <h2
+      class="text-slate-800 text-xl sm:text-2xl font-semibold leading-tight tracking-[-0.01em] px-6 py-4 border-b border-slate-200"
+    >
+      Auto-play on Boot
+    </h2>
+    <div class="px-6 py-5 space-y-6">
+      <div class="flex items-center justify-between">
+        <div class="flex-1">
+          <h3 class="text-slate-700 text-base font-medium leading-normal">Enable Auto-play on Boot</h3>
+          <p class="text-xs text-slate-500 mt-1">
+            Automatically start playing a selected playlist when the system boots up.
+          </p>
+        </div>
+        <label class="switch">
+          <input type="checkbox" id="auto_playModeToggle">
+          <span class="slider round"></span>
+        </label>
+      </div>
+      
+      <div id="auto_playSettings" class="space-y-4" style="display: none;">
+        <label class="flex flex-col gap-1.5">
+          <span class="text-slate-700 text-sm font-medium leading-normal">Startup Playlist</span>
+          <select
+            id="auto_playPlaylistSelect"
+            class="form-select flex-1 resize-none overflow-hidden rounded-lg text-slate-900 focus:outline-0 focus:ring-2 focus:ring-sky-500 border border-slate-300 bg-white focus:border-sky-500 h-10 placeholder:text-slate-400 px-4 text-base font-medium leading-normal transition-colors"
+          >
+            <option value="">Select a playlist...</option>
+          </select>
+          <p class="text-xs text-slate-500 mt-1">
+            Choose which playlist to automatically play when the system starts.
+          </p>
+        </label>
+
+        <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
+          <label class="flex flex-col gap-1.5">
+            <span class="text-slate-700 text-sm font-medium leading-normal">Run Mode</span>
+            <select
+              id="auto_playRunModeSelect"
+              class="form-select resize-none overflow-hidden rounded-lg text-slate-900 focus:outline-0 focus:ring-2 focus:ring-sky-500 border border-slate-300 bg-white focus:border-sky-500 h-10 placeholder:text-slate-400 px-4 text-base font-medium leading-normal transition-colors"
+            >
+              <option value="single">Single (play once)</option>
+              <option value="loop">Loop (repeat forever)</option>
+            </select>
+            <p class="text-xs text-slate-500 mt-1">
+              How to run the playlist when it finishes.
+            </p>
+          </label>
+
+          <label class="flex flex-col gap-1.5">
+            <span class="text-slate-700 text-sm font-medium leading-normal">Pause Between Patterns (seconds)</span>
+            <input
+              id="auto_playPauseTimeInput"
+              type="number"
+              min="0"
+              step="0.5"
+              class="form-input resize-none overflow-hidden rounded-lg text-slate-900 focus:outline-0 focus:ring-2 focus:ring-sky-500 border border-slate-300 bg-white focus:border-sky-500 h-10 placeholder:text-slate-400 px-4 text-base font-normal leading-normal transition-colors"
+              placeholder="5.0"
+            />
+            <p class="text-xs text-slate-500 mt-1">
+              Time to wait between each pattern (0 or more seconds).
+            </p>
+          </label>
+        </div>
+
+        <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
+          <label class="flex flex-col gap-1.5">
+            <span class="text-slate-700 text-sm font-medium leading-normal">Clear Pattern</span>
+            <select
+              id="auto_playClearPatternSelect"
+              class="form-select resize-none overflow-hidden rounded-lg text-slate-900 focus:outline-0 focus:ring-2 focus:ring-sky-500 border border-slate-300 bg-white focus:border-sky-500 h-10 placeholder:text-slate-400 px-4 text-base font-medium leading-normal transition-colors"
+            >
+              <option value="none">None</option>
+              <option value="adaptive">Adaptive</option>
+              <option value="clear_from_in">Clear From Center</option>
+              <option value="clear_from_out">Clear From Perimeter</option>
+              <option value="clear_sideway">Clear Sideway</option>
+              <option value="random">Random</option>
+            </select>
+            <p class="text-xs text-slate-500 mt-1">
+              Pattern to run before each main pattern.
+            </p>
+          </label>
+
+          <div class="flex items-center justify-between">
+            <div class="flex-1">
+              <h4 class="text-slate-700 text-sm font-medium leading-normal">Shuffle Playlist</h4>
+              <p class="text-xs text-slate-500 mt-1">
+                Randomize the order of patterns in the playlist.
+              </p>
+            </div>
+            <label class="switch">
+              <input type="checkbox" id="auto_playShuffleToggle">
+              <span class="slider round"></span>
+            </label>
+          </div>
+        </div>
+      </div>
+    </div>
+  </section>
   <section class="bg-white rounded-xl shadow-sm overflow-hidden">
     <h2
       class="text-slate-800 text-xl sm:text-2xl font-semibold leading-tight tracking-[-0.01em] px-6 py-4 border-b border-slate-200"