Backend optimizations, frontend optimizations, and UI design improvements
Backend optimizations: - GZip middleware for compressed responses - Concurrent WebSocket broadcast - Skip status polling when no clients connected - Deduplicated token validation with caching - Fire-and-forget HA state callbacks - Single stat() per browser item - Metadata caching (LRU) - M3U playlist optimization - Autostart setup (Task Scheduler + hidden VBS launcher) Frontend code optimizations: - Fix thumbnail blob URL memory leak - Fix WebSocket ping interval leak on reconnect - Skip artwork re-fetch when same track playing - Deduplicate volume slider logic - Extract magic numbers into named constants - Standardize error handling with toast notifications - Cache play/pause SVG constants - Loading state management for async buttons - Request deduplication for rapid clicks - Cache 30+ DOM element references - Deduplicate volume updates over WebSocket Frontend design improvements: - Progress bar seek thumb and hover expansion - Custom themed scrollbars - Toast notification accent border strips - Keyboard focus-visible states - Album art ambient glow effect - Animated sliding tab indicator - Mini-player top progress line - Empty state SVG illustrations - Responsive tablet breakpoint (601-900px) - Horizontal player layout on wide screens (>900px) - Glassmorphism mini-player with backdrop blur - Vinyl spin animation (toggleable) - Table horizontal scroll on narrow screens Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
import logging
|
||||
import os
|
||||
import stat as stat_module
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -13,6 +14,10 @@ from ..config import settings
|
||||
_dir_cache: dict[str, tuple[float, list[dict]]] = {}
|
||||
DIR_CACHE_TTL = 300 # 5 minutes
|
||||
|
||||
# Media info cache: {(file_path_str, mtime): {duration, bitrate, title}}
|
||||
_media_info_cache: dict[tuple[str, float], dict] = {}
|
||||
_MEDIA_INFO_CACHE_MAX = 5000
|
||||
|
||||
try:
|
||||
from mutagen import File as MutagenFile
|
||||
HAS_MUTAGEN = True
|
||||
@@ -121,11 +126,14 @@ class BrowserService:
|
||||
return "other"
|
||||
|
||||
@staticmethod
|
||||
def get_media_info(file_path: Path) -> dict:
|
||||
def get_media_info(file_path: Path, mtime: float | None = None) -> dict:
|
||||
"""Get duration, bitrate, and title of a media file (header-only read).
|
||||
|
||||
Results are cached by (path, mtime) to avoid re-reading unchanged files.
|
||||
|
||||
Args:
|
||||
file_path: Path to the media file.
|
||||
mtime: File modification time (avoids an extra stat call).
|
||||
|
||||
Returns:
|
||||
Dict with 'duration' (float or None), 'bitrate' (int or None),
|
||||
@@ -134,6 +142,20 @@ class BrowserService:
|
||||
result = {"duration": None, "bitrate": None, "title": None}
|
||||
if not HAS_MUTAGEN:
|
||||
return result
|
||||
|
||||
# Use mtime-based cache to skip mutagen reads for unchanged files
|
||||
if mtime is None:
|
||||
try:
|
||||
mtime = file_path.stat().st_mtime
|
||||
except (OSError, PermissionError):
|
||||
pass
|
||||
|
||||
if mtime is not None:
|
||||
cache_key = (str(file_path), mtime)
|
||||
cached = _media_info_cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
try:
|
||||
audio = MutagenFile(str(file_path), easy=True)
|
||||
if audio is not None and hasattr(audio, "info"):
|
||||
@@ -155,6 +177,16 @@ class BrowserService:
|
||||
result["title"] = title
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Cache result (evict oldest entries if cache is full)
|
||||
if mtime is not None:
|
||||
if len(_media_info_cache) >= _MEDIA_INFO_CACHE_MAX:
|
||||
# Remove oldest ~20% of entries
|
||||
to_remove = list(_media_info_cache.keys())[:_MEDIA_INFO_CACHE_MAX // 5]
|
||||
for k in to_remove:
|
||||
del _media_info_cache[k]
|
||||
_media_info_cache[(str(file_path), mtime)] = result
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
@@ -235,8 +267,13 @@ class BrowserService:
|
||||
if item.name.startswith("."):
|
||||
continue
|
||||
|
||||
is_dir = item.is_dir()
|
||||
if is_dir:
|
||||
# Single stat() call per item — reuse for type check and metadata
|
||||
try:
|
||||
st = item.stat()
|
||||
except (OSError, PermissionError):
|
||||
continue
|
||||
|
||||
if stat_module.S_ISDIR(st.st_mode):
|
||||
file_type = "folder"
|
||||
else:
|
||||
suffix = item.suffix.lower()
|
||||
@@ -251,6 +288,8 @@ class BrowserService:
|
||||
"name": item.name,
|
||||
"type": file_type,
|
||||
"is_media": file_type in ("audio", "video"),
|
||||
"_size": st.st_size,
|
||||
"_mtime": st.st_mtime,
|
||||
})
|
||||
|
||||
all_items.sort(key=lambda x: (x["type"] != "folder", x["name"].lower()))
|
||||
@@ -260,19 +299,14 @@ class BrowserService:
|
||||
total = len(all_items)
|
||||
items = all_items[offset:offset + limit]
|
||||
|
||||
# Fetch stat + duration only for items on the current page
|
||||
# Enrich items on the current page with metadata
|
||||
for item in items:
|
||||
item_path = full_path / item["name"]
|
||||
try:
|
||||
stat = item_path.stat()
|
||||
item["size"] = stat.st_size if item["type"] != "folder" else None
|
||||
item["modified"] = datetime.fromtimestamp(stat.st_mtime).isoformat()
|
||||
except (OSError, PermissionError):
|
||||
item["size"] = None
|
||||
item["modified"] = None
|
||||
item["size"] = item["_size"] if item["type"] != "folder" else None
|
||||
item["modified"] = datetime.fromtimestamp(item["_mtime"]).isoformat()
|
||||
|
||||
if item["is_media"]:
|
||||
info = BrowserService.get_media_info(item_path)
|
||||
item_path = full_path / item["name"]
|
||||
info = BrowserService.get_media_info(item_path, item["_mtime"])
|
||||
item["duration"] = info["duration"]
|
||||
item["bitrate"] = info["bitrate"]
|
||||
item["title"] = info["title"]
|
||||
|
||||
@@ -49,24 +49,27 @@ class ConnectionManager:
|
||||
)
|
||||
|
||||
async def broadcast(self, message: dict[str, Any]) -> None:
|
||||
"""Broadcast a message to all connected clients."""
|
||||
"""Broadcast a message to all connected clients concurrently."""
|
||||
async with self._lock:
|
||||
connections = list(self._active_connections)
|
||||
|
||||
if not connections:
|
||||
return
|
||||
|
||||
disconnected = []
|
||||
for websocket in connections:
|
||||
async def _send(ws: WebSocket) -> WebSocket | None:
|
||||
try:
|
||||
await websocket.send_json(message)
|
||||
await ws.send_json(message)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug("Failed to send to client: %s", e)
|
||||
disconnected.append(websocket)
|
||||
return ws
|
||||
|
||||
results = await asyncio.gather(*(_send(ws) for ws in connections))
|
||||
|
||||
# Clean up disconnected clients
|
||||
for ws in disconnected:
|
||||
await self.disconnect(ws)
|
||||
for ws in results:
|
||||
if ws is not None:
|
||||
await self.disconnect(ws)
|
||||
|
||||
async def broadcast_scripts_changed(self) -> None:
|
||||
"""Notify all connected clients that scripts have changed."""
|
||||
@@ -156,26 +159,25 @@ class ConnectionManager:
|
||||
async with self._lock:
|
||||
has_clients = len(self._active_connections) > 0
|
||||
|
||||
if has_clients:
|
||||
status = await get_status_func()
|
||||
status_dict = status.model_dump()
|
||||
if not has_clients:
|
||||
await asyncio.sleep(self._poll_interval)
|
||||
continue
|
||||
|
||||
# Only broadcast on actual state changes
|
||||
# Let HA handle position interpolation during playback
|
||||
if self.status_changed(self._last_status, status_dict):
|
||||
self._last_status = status_dict
|
||||
self._last_broadcast_time = time.time()
|
||||
await self.broadcast(
|
||||
{"type": "status_update", "data": status_dict}
|
||||
)
|
||||
logger.debug("Broadcast sent: status change")
|
||||
else:
|
||||
# Update cached status even without broadcast
|
||||
self._last_status = status_dict
|
||||
status = await get_status_func()
|
||||
status_dict = status.model_dump()
|
||||
|
||||
# Only broadcast on actual state changes
|
||||
# Let HA handle position interpolation during playback
|
||||
if self.status_changed(self._last_status, status_dict):
|
||||
self._last_status = status_dict
|
||||
self._last_broadcast_time = time.time()
|
||||
await self.broadcast(
|
||||
{"type": "status_update", "data": status_dict}
|
||||
)
|
||||
logger.debug("Broadcast sent: status change")
|
||||
else:
|
||||
# Still update cache for when clients connect
|
||||
status = await get_status_func()
|
||||
self._last_status = status.model_dump()
|
||||
# Update cached status even without broadcast
|
||||
self._last_status = status_dict
|
||||
|
||||
await asyncio.sleep(self._poll_interval)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user