Explorar o código

Optimize page load time + add update indicator (#91)

* optimize page load time

* optimize preview modal coordinates loading

* add update indicator
Tuan Nguyen hai 3 meses
pai
achega
8483304491
Modificáronse 7 ficheiros con 200 adicións e 30 borrados
  1. 9 5
      main.py
  2. 48 12
      modules/core/version_manager.py
  3. 64 11
      static/js/base.js
  4. 17 0
      static/js/settings.js
  5. 45 0
      templates/base.html
  6. 11 0
      templates/playlists.html
  7. 6 2
      templates/settings.html

+ 9 - 5
main.py

@@ -1977,17 +1977,21 @@ def signal_handler(signum, frame):
         os._exit(0)  # Force exit regardless of other threads
 
 @app.get("/api/version")
-async def get_version_info():
-    """Get current and latest version information"""
+async def get_version_info(force_refresh: bool = False):
+    """Get current and latest version information
+
+    Args:
+        force_refresh: If true, bypass cache and fetch fresh data from GitHub
+    """
     try:
-        version_info = await version_manager.get_version_info()
+        version_info = await version_manager.get_version_info(force_refresh=force_refresh)
         return JSONResponse(content=version_info)
     except Exception as e:
         logger.error(f"Error getting version info: {e}")
         return JSONResponse(
             content={
-                "current": version_manager.get_current_version(),
-                "latest": version_manager.get_current_version(),
+                "current": await version_manager.get_current_version(),
+                "latest": await version_manager.get_current_version(),
                 "update_available": False,
                 "error": "Unable to check for updates"
             },

+ 48 - 12
modules/core/version_manager.py

@@ -7,6 +7,7 @@ import asyncio
 import aiohttp
 import json
 import os
+import time
 from pathlib import Path
 from typing import Dict, Optional
 import logging
@@ -19,6 +20,11 @@ class VersionManager:
         self.repo_name = "dune-weaver"
         self.github_api_url = f"https://api.github.com/repos/{self.repo_owner}/{self.repo_name}"
         self._current_version = None
+
+        # Caching for GitHub API to avoid rate limits and slow requests
+        self._latest_release_cache = None
+        self._cache_timestamp = None
+        self._cache_duration = 3600  # Cache for 1 hour (in seconds)
         
     async def get_current_version(self) -> str:
         """Read current version from VERSION file (async)"""
@@ -37,8 +43,18 @@ class VersionManager:
 
         return self._current_version
     
-    async def get_latest_release(self) -> Dict[str, any]:
-        """Get latest release info from GitHub API"""
+    async def get_latest_release(self, force_refresh: bool = False) -> Dict[str, any]:
+        """Get latest release info from GitHub API with caching"""
+        # Check if we have a valid cache
+        current_time = time.time()
+        if not force_refresh and self._latest_release_cache is not None and self._cache_timestamp is not None:
+            cache_age = current_time - self._cache_timestamp
+            if cache_age < self._cache_duration:
+                logger.debug(f"Returning cached version info (age: {cache_age:.0f}s)")
+                return self._latest_release_cache
+
+        # Cache miss or expired - fetch from GitHub
+        logger.info("Fetching latest release from GitHub API")
         try:
             async with aiohttp.ClientSession() as session:
                 async with session.get(
@@ -47,7 +63,7 @@ class VersionManager:
                 ) as response:
                     if response.status == 200:
                         data = await response.json()
-                        return {
+                        release_data = {
                             "version": data.get("tag_name", "").lstrip("v"),
                             "name": data.get("name", ""),
                             "published_at": data.get("published_at", ""),
@@ -55,20 +71,30 @@ class VersionManager:
                             "body": data.get("body", ""),
                             "prerelease": data.get("prerelease", False)
                         }
+
+                        # Update cache
+                        self._latest_release_cache = release_data
+                        self._cache_timestamp = current_time
+                        logger.info(f"Cached new release info: {release_data.get('version')}")
+
+                        return release_data
                     elif response.status == 404:
                         # No releases found
                         logger.info("No releases found on GitHub")
                         return None
                     else:
                         logger.warning(f"GitHub API returned status {response.status}")
-                        return None
-                        
+                        # Return cached data if available, even if stale
+                        return self._latest_release_cache
+
         except asyncio.TimeoutError:
             logger.warning("Timeout while fetching latest release from GitHub")
-            return None
+            # Return cached data if available
+            return self._latest_release_cache
         except Exception as e:
             logger.error(f"Error fetching latest release: {e}")
-            return None
+            # Return cached data if available
+            return self._latest_release_cache
     
     def compare_versions(self, version1: str, version2: str) -> int:
         """Compare two semantic versions. Returns -1, 0, or 1"""
@@ -93,11 +119,15 @@ class VersionManager:
             logger.warning(f"Invalid version format: {version1} vs {version2}")
             return 0
     
-    async def get_version_info(self) -> Dict[str, any]:
-        """Get complete version information"""
+    async def get_version_info(self, force_refresh: bool = False) -> Dict[str, any]:
+        """Get complete version information
+
+        Args:
+            force_refresh: If True, bypass cache and fetch from GitHub API
+        """
         current = await self.get_current_version()
-        latest_release = await self.get_latest_release()
-        
+        latest_release = await self.get_latest_release(force_refresh=force_refresh)
+
         if latest_release:
             latest = latest_release["version"]
             comparison = self.compare_versions(current, latest)
@@ -105,7 +135,7 @@ class VersionManager:
         else:
             latest = current  # Fallback if no releases found
             update_available = False
-            
+
         return {
             "current": current,
             "latest": latest,
@@ -113,5 +143,11 @@ class VersionManager:
             "latest_release": latest_release
         }
 
+    def clear_cache(self):
+        """Clear the cached version data"""
+        self._latest_release_cache = None
+        self._cache_timestamp = None
+        logger.info("Version cache cleared")
+
 # Global instance
 version_manager = VersionManager()

+ 64 - 11
static/js/base.js

@@ -200,7 +200,16 @@ function connectWebSocket() {
                     const newFile = normalizeFilePath(data.data.current_file);
                     if (newFile !== currentPreviewFile) {
                         currentPreviewFile = newFile;
-                        loadPlayerPreviewData(data.data.current_file);
+
+                        // Only preload if we're on the browse page (index.html)
+                        // Other pages (playlists, table_control, LED, settings) will load on-demand
+                        const modal = document.getElementById('playerPreviewModal');
+                        const browsePage = document.getElementById('browseSortFieldSelect');
+
+                        if (modal && browsePage) {
+                            // We're on the browse page with the modal - preload coordinates
+                            loadPlayerPreviewData(data.data.current_file);
+                        }
                     }
                 } else {
                     currentPreviewFile = null;
@@ -290,20 +299,32 @@ async function openPlayerPreviewModal() {
         const canvas = document.getElementById('playerPreviewCanvas');
         const ctx = canvas.getContext('2d');
         const toggleBtn = document.getElementById('toggle-preview-modal-btn');
-        
-        // Set static title
-        title.textContent = 'Live Pattern Preview';
-        
-        // Show modal and update toggle button
+
+        // Show modal immediately for instant feedback
         modal.classList.remove('hidden');
 
-        
-        // Setup canvas
+        // Setup canvas (so it's ready to display loading state)
         setupPlayerPreviewCanvas(ctx);
-        
-        // Draw initial state
+
+        // Load preview data on-demand if not already loaded
+        if (!playerPreviewData && currentPreviewFile) {
+            // Show loading state
+            title.textContent = 'Loading pattern...';
+            drawLoadingState(ctx);
+
+            // Load data in background
+            await loadPlayerPreviewData(`./patterns/${currentPreviewFile}`);
+
+            // Update title when loaded
+            title.textContent = 'Live Pattern Preview';
+        } else {
+            // Data already loaded
+            title.textContent = 'Live Pattern Preview';
+        }
+
+        // Draw the pattern (either immediately if cached, or after loading)
         drawPlayerPreview(ctx, targetProgress);
-        
+
     } catch (error) {
         console.error(`Error opening player preview modal: ${error.message}`);
         showStatusMessage('Failed to load pattern for animation', 'error');
@@ -395,6 +416,38 @@ function getInterpolatedCoordinate(progress) {
     return [interpolatedTheta, interpolatedRho];
 }
 
+// Draw loading state on canvas
+function drawLoadingState(ctx) {
+    if (!ctx) return;
+
+    const canvas = ctx.canvas;
+    const pixelRatio = (window.devicePixelRatio || 1) * 2;
+    const containerSize = canvas.width / pixelRatio;
+    const center = containerSize / 2;
+
+    ctx.save();
+
+    // Clear canvas
+    ctx.clearRect(0, 0, canvas.width, canvas.height);
+
+    // Create circular clipping path
+    ctx.beginPath();
+    ctx.arc(canvas.width/2, canvas.height/2, canvas.width/2, 0, Math.PI * 2);
+    ctx.clip();
+
+    // Setup coordinate system
+    ctx.scale(pixelRatio, pixelRatio);
+
+    // Draw loading text only
+    ctx.fillStyle = '#9ca3af';
+    ctx.font = '16px sans-serif';
+    ctx.textAlign = 'center';
+    ctx.textBaseline = 'middle';
+    ctx.fillText('Loading pattern...', center, center);
+
+    ctx.restore();
+}
+
 // Draw player preview for modal
 function drawPlayerPreview(ctx, progress) {
     if (!ctx || !playerPreviewData || playerPreviewData.length === 0) return;

+ 17 - 0
static/js/settings.js

@@ -234,6 +234,23 @@ async function loadLedConfig() {
 document.addEventListener('DOMContentLoaded', async () => {
     // Initialize UI with default disconnected state
     updateConnectionUI({ connected: false });
+
+    // Handle scroll to section if hash is present in URL
+    if (window.location.hash) {
+        setTimeout(() => {
+            const targetSection = document.querySelector(window.location.hash);
+            if (targetSection) {
+                targetSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
+                // Add a subtle highlight animation
+                targetSection.style.transition = 'background-color 0.5s ease';
+                const originalBg = targetSection.style.backgroundColor;
+                targetSection.style.backgroundColor = 'rgba(14, 165, 233, 0.1)';
+                setTimeout(() => {
+                    targetSection.style.backgroundColor = originalBg;
+                }, 2000);
+            }
+        }, 300); // Delay to ensure page is fully loaded
+    }
     
     // Load all data asynchronously
     Promise.all([

+ 45 - 0
templates/base.html

@@ -272,6 +272,16 @@
             </a>
           </div>
           <div class="flex items-center gap-2">
+            <!-- Update Available Indicator -->
+            <button
+              id="update-indicator"
+              class="hidden p-1.5 flex rounded-lg hover:bg-green-100 dark:hover:bg-green-900 focus:outline-none focus:ring-2 focus:ring-green-500"
+              aria-label="Update available"
+              title="Software update available - Click to view"
+            >
+              <span class="material-icons text-green-600 dark:text-green-400 animate-pulse">system_update</span>
+            </button>
+
             <button
               id="theme-toggle"
               class="p-1.5 flex rounded-lg hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500"
@@ -722,6 +732,41 @@
           }
         });
       });
+
+      // Update indicator functionality
+      document.addEventListener('DOMContentLoaded', async function() {
+        const updateIndicator = document.getElementById('update-indicator');
+        if (!updateIndicator) return;
+
+        // Check for updates
+        async function checkForUpdates() {
+          try {
+            const response = await fetch('/api/version');
+            const data = await response.json();
+
+            // Show indicator if update is available
+            if (data.update_available) {
+              updateIndicator.classList.remove('hidden');
+            } else {
+              updateIndicator.classList.add('hidden');
+            }
+          } catch (error) {
+            console.error('Failed to check for updates:', error);
+          }
+        }
+
+        // Initial check
+        await checkForUpdates();
+
+        // Check every hour
+        setInterval(checkForUpdates, 3600000);
+
+        // Click handler - navigate to settings and scroll to update section
+        updateIndicator.addEventListener('click', () => {
+          // Navigate to settings page
+          window.location.href = '/settings#software-version-section';
+        });
+      });
     </script>
 
     <!-- Cache All Previews Prompt Modal -->

+ 11 - 0
templates/playlists.html

@@ -17,6 +17,17 @@
   filter: invert(1);
 }
 
+/* Fix checkbox visibility in light mode */
+html:not(.dark) input[type="checkbox"]:checked {
+  background-color: #2563eb !important; /* Darker blue for better contrast */
+  border-color: #2563eb !important;
+}
+
+html:not(.dark) input[type="checkbox"] {
+  background-color: #ffffff !important;
+  border-color: #d1d5db !important;
+}
+
 /* Mobile responsive utilities */
 @media (max-width: 768px) {
   .mobile-hidden {

+ 6 - 2
templates/settings.html

@@ -46,7 +46,8 @@ endblock %}
 }
 .dark .form-input,
 .dark input[type="number"],
-.dark input[type="text"] {
+.dark input[type="text"],
+.dark input[type="time"] {
   background-color: #1f1f1f;
   border-color: #404040;
   color: #e5e5e5;
@@ -58,6 +59,9 @@ endblock %}
   border-color: #0c7ff2;
   ring-color: #0c7ff2;
 }
+.dark input[type="time"]::-webkit-calendar-picker-indicator {
+  filter: invert(1);
+}
 .dark .form-select {
   background-color: #1f1f1f;
   border-color: #404040;
@@ -901,7 +905,7 @@ input:checked + .slider:before {
       </div>
     </div>
   </section>
-  <section class="bg-white rounded-xl shadow-sm overflow-hidden">
+  <section id="software-version-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"
     >