From 84b985e6dfbd0704947c981b38dd572c1cc5c73b Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Mon, 23 Feb 2026 20:38:35 +0300 Subject: [PATCH] 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 --- media_server/auth.py | 16 +- media_server/main.py | 4 + media_server/routes/browser.py | 62 ++- media_server/routes/media.py | 70 ++-- media_server/services/browser_service.py | 60 ++- media_server/services/websocket_manager.py | 52 +-- media_server/static/css/styles.css | 353 +++++++++++++++- media_server/static/index.html | 152 ++++--- media_server/static/js/app.js | 466 ++++++++++++++------- media_server/static/locales/en.json | 9 +- media_server/static/locales/ru.json | 9 +- scripts/install_task_windows.ps1 | 14 +- scripts/start-hidden.vbs | 7 + 13 files changed, 926 insertions(+), 348 deletions(-) create mode 100644 scripts/start-hidden.vbs diff --git a/media_server/auth.py b/media_server/auth.py index b374a9f..0a42d23 100644 --- a/media_server/auth.py +++ b/media_server/auth.py @@ -36,9 +36,7 @@ async def verify_token( ) -> str: """Verify the API token from the Authorization header. - Args: - request: The incoming request - credentials: The bearer token credentials + Reuses the label from middleware context when already validated. Returns: The token label @@ -46,6 +44,11 @@ async def verify_token( Raises: HTTPException: If the token is missing or invalid """ + # Reuse label already set by middleware to avoid redundant O(n) scan + existing = token_label_var.get("unknown") + if existing != "unknown": + return existing + if credentials is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -61,7 +64,6 @@ async def verify_token( headers={"WWW-Authenticate": "Bearer"}, ) - # Set label in context for logging token_label_var.set(label) return label @@ -120,6 +122,11 @@ async def verify_token_or_query( Raises: HTTPException: If the token is missing or invalid """ + # Reuse label already set by middleware + existing = token_label_var.get("unknown") + if existing != "unknown": + return existing + label = None # Try header first @@ -137,6 +144,5 @@ async def verify_token_or_query( headers={"WWW-Authenticate": "Bearer"}, ) - # Set label in context for logging token_label_var.set(label) return label diff --git a/media_server/main.py b/media_server/main.py index 4ecccc1..ef1af00 100644 --- a/media_server/main.py +++ b/media_server/main.py @@ -9,6 +9,7 @@ from pathlib import Path import uvicorn from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware +from fastapi.middleware.gzip import GZipMiddleware from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles @@ -74,6 +75,9 @@ def create_app() -> FastAPI: lifespan=lifespan, ) + # Compress responses > 1KB + app.add_middleware(GZipMiddleware, minimum_size=1000) + # Add CORS middleware for cross-origin requests app.add_middleware( CORSMiddleware, diff --git a/media_server/routes/browser.py b/media_server/routes/browser.py index dee5e63..78882c4 100644 --- a/media_server/routes/browser.py +++ b/media_server/routes/browser.py @@ -25,6 +25,28 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/browser", tags=["browser"]) +async def _broadcast_after_open(controller, label: str, max_wait: float = 2.0) -> None: + """Poll until media session registers, then broadcast status update. + + Fires as a background task so the HTTP response returns immediately. + """ + try: + interval = 0.3 + elapsed = 0.0 + while elapsed < max_wait: + await asyncio.sleep(interval) + elapsed += interval + status = await controller.get_status() + if status.state in ("playing", "paused"): + break + + status_dict = status.model_dump() + await ws_manager.broadcast({"type": "status", "data": status_dict}) + logger.info(f"Broadcasted status update after opening: {label}") + except Exception as e: + logger.warning(f"Failed to broadcast status after opening {label}: {e}") + + # Request/Response Models class FolderCreateRequest(BaseModel): """Request model for creating a media folder.""" @@ -412,21 +434,8 @@ async def play_file( if not success: raise HTTPException(status_code=500, detail="Failed to open file") - # Wait for media player to start and register with Windows Media Session API - # This allows the UI to update immediately with the new playback state - await asyncio.sleep(1.5) - - # Get updated status and broadcast to all connected clients - try: - status = await controller.get_status() - status_dict = status.model_dump() - await ws_manager.broadcast({ - "type": "status", - "data": status_dict - }) - logger.info(f"Broadcasted status update after opening file: {file_path.name}") - except Exception as e: - logger.warning(f"Failed to broadcast status after opening file: {e}") + # Poll until player registers with media session API (up to 2s) + asyncio.create_task(_broadcast_after_open(controller, file_path.name)) return { "success": True, @@ -476,10 +485,11 @@ async def play_folder( # Generate M3U playlist with absolute paths and EXTINF entries # Written to local temp dir to avoid extra SMB file handle on network shares # Uses utf-8-sig (BOM) so players detect encoding properly - m3u_content = "#EXTM3U\r\n" + lines = ["#EXTM3U"] for f in media_files: - m3u_content += f"#EXTINF:-1,{f.stem}\r\n" - m3u_content += f"{f}\r\n" + lines.append(f"#EXTINF:-1,{f.stem}") + lines.append(str(f)) + m3u_content = "\r\n".join(lines) + "\r\n" playlist_path = Path(tempfile.gettempdir()) / ".media_server_playlist.m3u" playlist_path.write_text(m3u_content, encoding="utf-8-sig") @@ -491,20 +501,8 @@ async def play_folder( if not success: raise HTTPException(status_code=500, detail="Failed to open playlist") - # Wait for media player to start - await asyncio.sleep(1.5) - - # Broadcast status update - try: - status = await controller.get_status() - status_dict = status.model_dump() - await ws_manager.broadcast({ - "type": "status", - "data": status_dict - }) - logger.info(f"Broadcasted status after opening playlist with {len(media_files)} files") - except Exception as e: - logger.warning(f"Failed to broadcast status after opening playlist: {e}") + # Poll until player registers with media session API (up to 2s) + asyncio.create_task(_broadcast_after_open(controller, f"playlist ({len(media_files)} files)")) return { "success": True, diff --git a/media_server/routes/media.py b/media_server/routes/media.py index ef99f7c..b0c27f2 100644 --- a/media_server/routes/media.py +++ b/media_server/routes/media.py @@ -18,31 +18,37 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/media", tags=["media"]) -async def _run_callback(callback_name: str) -> None: - """Run a callback if configured. Failures are logged but don't raise.""" +def _run_callback(callback_name: str) -> None: + """Fire-and-forget a callback if configured. Failures are logged but don't block.""" if not settings.callbacks or callback_name not in settings.callbacks: return - from .scripts import _run_script + async def _execute(): + from .scripts import _run_script - callback = settings.callbacks[callback_name] - loop = asyncio.get_event_loop() - result = await loop.run_in_executor( - None, - lambda: _run_script( - command=callback.command, - timeout=callback.timeout, - shell=callback.shell, - working_dir=callback.working_dir, - ), - ) - if result["exit_code"] != 0: - logger.warning( - "Callback %s failed with exit code %s: %s", - callback_name, - result["exit_code"], - result["stderr"], - ) + try: + callback = settings.callbacks[callback_name] + loop = asyncio.get_event_loop() + result = await loop.run_in_executor( + None, + lambda: _run_script( + command=callback.command, + timeout=callback.timeout, + shell=callback.shell, + working_dir=callback.working_dir, + ), + ) + if result["exit_code"] != 0: + logger.warning( + "Callback %s failed with exit code %s: %s", + callback_name, + result["exit_code"], + result["stderr"], + ) + except Exception as e: + logger.error("Callback %s error: %s", callback_name, e) + + asyncio.create_task(_execute()) @router.get("/status", response_model=MediaStatus) @@ -70,7 +76,7 @@ async def play(_: str = Depends(verify_token)) -> dict: status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Failed to start playback - no active media session", ) - await _run_callback("on_play") + _run_callback("on_play") return {"success": True} @@ -88,7 +94,7 @@ async def pause(_: str = Depends(verify_token)) -> dict: status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Failed to pause - no active media session", ) - await _run_callback("on_pause") + _run_callback("on_pause") return {"success": True} @@ -106,7 +112,7 @@ async def stop(_: str = Depends(verify_token)) -> dict: status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Failed to stop - no active media session", ) - await _run_callback("on_stop") + _run_callback("on_stop") return {"success": True} @@ -124,7 +130,7 @@ async def next_track(_: str = Depends(verify_token)) -> dict: status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Failed to skip - no active media session", ) - await _run_callback("on_next") + _run_callback("on_next") return {"success": True} @@ -142,7 +148,7 @@ async def previous_track(_: str = Depends(verify_token)) -> dict: status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Failed to go back - no active media session", ) - await _run_callback("on_previous") + _run_callback("on_previous") return {"success": True} @@ -165,7 +171,7 @@ async def set_volume( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Failed to set volume", ) - await _run_callback("on_volume") + _run_callback("on_volume") return {"success": True, "volume": request.volume} @@ -178,7 +184,7 @@ async def toggle_mute(_: str = Depends(verify_token)) -> dict: """ controller = get_media_controller() muted = await controller.toggle_mute() - await _run_callback("on_mute") + _run_callback("on_mute") return {"success": True, "muted": muted} @@ -199,7 +205,7 @@ async def seek(request: SeekRequest, _: str = Depends(verify_token)) -> dict: status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Failed to seek - no active media session or seek not supported", ) - await _run_callback("on_seek") + _run_callback("on_seek") return {"success": True, "position": request.position} @@ -210,7 +216,7 @@ async def turn_on(_: str = Depends(verify_token)) -> dict: Returns: Success status """ - await _run_callback("on_turn_on") + _run_callback("on_turn_on") return {"success": True} @@ -221,7 +227,7 @@ async def turn_off(_: str = Depends(verify_token)) -> dict: Returns: Success status """ - await _run_callback("on_turn_off") + _run_callback("on_turn_off") return {"success": True} @@ -232,7 +238,7 @@ async def toggle(_: str = Depends(verify_token)) -> dict: Returns: Success status """ - await _run_callback("on_toggle") + _run_callback("on_toggle") return {"success": True} diff --git a/media_server/services/browser_service.py b/media_server/services/browser_service.py index 7f073cd..72d751a 100644 --- a/media_server/services/browser_service.py +++ b/media_server/services/browser_service.py @@ -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"] diff --git a/media_server/services/websocket_manager.py b/media_server/services/websocket_manager.py index 03f1cb7..07ee85f 100644 --- a/media_server/services/websocket_manager.py +++ b/media_server/services/websocket_manager.py @@ -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) diff --git a/media_server/static/css/styles.css b/media_server/static/css/styles.css index e0f99a5..bf285aa 100644 --- a/media_server/static/css/styles.css +++ b/media_server/static/css/styles.css @@ -51,6 +51,55 @@ opacity: 1; } + /* Custom Scrollbars */ + ::-webkit-scrollbar { + width: 8px; + height: 8px; + } + + ::-webkit-scrollbar-track { + background: var(--bg-primary); + } + + ::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; + } + + ::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); + } + + * { + scrollbar-width: thin; + scrollbar-color: var(--border) var(--bg-primary); + } + + /* Focus-visible states for keyboard accessibility */ + :focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } + + button:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + box-shadow: 0 0 0 4px rgba(29, 185, 84, 0.2); + } + + input:focus-visible, + select:focus-visible, + textarea:focus-visible { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(29, 185, 84, 0.15); + } + + .tab-btn:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; + } + /* Prevent scrolling when dialog is open */ body.dialog-open { overflow: hidden; @@ -157,6 +206,20 @@ background: var(--bg-secondary); border-radius: 10px; border: 1px solid var(--border); + position: relative; + } + + .tab-indicator { + position: absolute; + top: 0.25rem; + bottom: 0.25rem; + left: 0; + background: var(--bg-tertiary); + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), width 0.3s cubic-bezier(0.4, 0, 0.2, 1); + z-index: 0; + pointer-events: none; } .tab-btn { @@ -173,21 +236,21 @@ font-size: 0.8rem; font-weight: 500; cursor: pointer; - transition: all 0.2s; + transition: color 0.2s; width: auto; height: auto; + position: relative; + z-index: 1; } .tab-btn:hover { color: var(--text-primary); - background: var(--bg-tertiary) !important; transform: none !important; } .tab-btn.active { color: var(--accent); - background: var(--bg-tertiary); - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + background: transparent; } .tab-btn svg { @@ -221,10 +284,35 @@ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); } + .player-layout { + display: flex; + flex-direction: column; + } + + .player-details { + flex: 1; + min-width: 0; + } + .album-art-container { display: flex; justify-content: center; margin-bottom: 2rem; + position: relative; + } + + .album-art-glow { + position: absolute; + width: 300px; + height: 300px; + object-fit: cover; + border-radius: 8px; + filter: blur(40px) saturate(1.5); + opacity: 0.5; + transform: scale(1.1); + z-index: 0; + pointer-events: none; + transition: opacity 0.5s ease; } #album-art { @@ -234,6 +322,38 @@ border-radius: 8px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); background: var(--bg-tertiary); + position: relative; + z-index: 1; + } + + :root[data-theme="light"] .album-art-glow { + opacity: 0.35; + filter: blur(50px) saturate(1.8); + } + + /* Vinyl Spin Animation */ + .album-art-container.vinyl #album-art { + border-radius: 50%; + transition: border-radius 0.5s ease, box-shadow 0.5s ease; + box-shadow: 0 0 0 8px var(--bg-tertiary), 0 0 0 10px var(--border), 0 8px 24px rgba(0, 0, 0, 0.5); + } + + .album-art-container.vinyl .album-art-glow { + border-radius: 50%; + } + + .album-art-container.vinyl.spinning #album-art { + animation: vinylSpin 4s linear infinite; + } + + .album-art-container.vinyl.paused #album-art { + animation: vinylSpin 4s linear infinite; + animation-play-state: paused; + } + + @keyframes vinylSpin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } } .track-info { @@ -293,7 +413,11 @@ border-radius: 3px; cursor: pointer; position: relative; - overflow: hidden; + transition: height 0.15s ease; + } + + .progress-bar:hover { + height: 8px; } .progress-fill { @@ -302,6 +426,25 @@ border-radius: 3px; width: 0; transition: width 0.1s linear; + position: relative; + } + + .progress-fill::after { + content: ''; + position: absolute; + right: -6px; + top: 50%; + transform: translateY(-50%) scale(0); + width: 12px; + height: 12px; + background: var(--accent); + border-radius: 50%; + box-shadow: 0 0 4px rgba(0, 0, 0, 0.3); + transition: transform 0.15s ease; + } + + .progress-bar:hover .progress-fill::after { + transform: translateY(-50%) scale(1); } .controls { @@ -375,6 +518,12 @@ background: var(--accent); border-radius: 50%; cursor: pointer; + transition: transform 0.15s ease, box-shadow 0.15s ease; + } + + #volume-slider:hover::-webkit-slider-thumb { + transform: scale(1.3); + box-shadow: 0 0 6px rgba(29, 185, 84, 0.4); } #volume-slider::-moz-range-thumb { @@ -384,6 +533,12 @@ border-radius: 50%; cursor: pointer; border: none; + transition: transform 0.15s ease, box-shadow 0.15s ease; + } + + #volume-slider:hover::-moz-range-thumb { + transform: scale(1.3); + box-shadow: 0 0 6px rgba(29, 185, 84, 0.4); } .volume-display { @@ -404,6 +559,44 @@ color: var(--text-muted); padding-top: 1rem; border-top: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + } + + .vinyl-toggle-btn { + width: 28px; + height: 28px; + padding: 0; + background: transparent; + border: 1px solid var(--border); + border-radius: 50%; + color: var(--text-muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + margin-left: auto; + } + + .vinyl-toggle-btn:hover { + color: var(--accent); + border-color: var(--accent); + background: transparent; + transform: none; + } + + .vinyl-toggle-btn.active { + color: var(--accent); + border-color: var(--accent); + background: rgba(29, 185, 84, 0.1); + } + + .vinyl-toggle-btn svg { + width: 16px; + height: 16px; } /* Scripts Section */ @@ -477,6 +670,7 @@ border-radius: 12px; padding: 2rem; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); + overflow-x: auto; } .script-management h2 { @@ -511,6 +705,7 @@ .scripts-table { width: 100%; + min-width: 500px; border-collapse: collapse; font-size: 0.875rem; } @@ -527,6 +722,7 @@ .scripts-table td { padding: 0.75rem; + word-break: break-word; border-bottom: 1px solid var(--border); } @@ -686,6 +882,7 @@ .dialog-header { padding: 1.5rem; + background: var(--bg-tertiary); border-bottom: 1px solid var(--border); } @@ -784,6 +981,31 @@ font-size: 0.875rem; } + .empty-state-illustration { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + padding: 3rem 2rem; + } + + .empty-state-illustration svg { + width: 64px; + height: 64px; + fill: none; + stroke: var(--text-muted); + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; + opacity: 0.5; + } + + .empty-state-illustration p { + color: var(--text-muted); + font-size: 0.875rem; + margin: 0; + } + .toast { position: fixed; bottom: 2rem; @@ -792,12 +1014,14 @@ border: 1px solid var(--border); border-radius: 8px; padding: 1rem 1.5rem; + padding-left: 1.25rem; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); opacity: 0; transform: translateY(20px); transition: all 0.3s; pointer-events: none; z-index: 1000; + border-left: 4px solid var(--border); } .toast.show { @@ -807,10 +1031,12 @@ .toast.success { border-color: var(--accent); + border-left-color: var(--accent); } .toast.error { border-color: var(--error); + border-left-color: var(--error); } /* Auth Modal */ @@ -933,16 +1159,36 @@ bottom: 0; left: 0; right: 0; - background: var(--bg-secondary); - border-top: 1px solid var(--border); + background: rgba(30, 30, 30, 0.8); + -webkit-backdrop-filter: blur(20px) saturate(1.5); + backdrop-filter: blur(20px) saturate(1.5); + border-top: 1px solid rgba(255, 255, 255, 0.08); padding: 0.75rem 1rem; + padding-top: calc(0.75rem + 2px); display: flex; align-items: center; gap: 1.5rem; z-index: 1000; transform: translateY(0); transition: transform 0.3s ease-in-out, opacity 0.3s ease-in-out; - box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.3); + box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.3); + } + + :root[data-theme="light"] .mini-player { + background: rgba(245, 245, 245, 0.75); + border-top: 1px solid rgba(0, 0, 0, 0.08); + box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.1); + } + + .mini-player::before { + content: ''; + position: absolute; + top: 0; + left: 0; + height: 2px; + width: var(--mini-progress, 0%); + background: var(--accent); + transition: width 0.1s linear; } .mini-player.hidden { @@ -1026,6 +1272,25 @@ border-radius: 2px; width: 0%; transition: width 0.1s linear; + position: relative; + } + + .mini-progress-fill::after { + content: ''; + position: absolute; + right: -5px; + top: 50%; + transform: translateY(-50%) scale(0); + width: 10px; + height: 10px; + background: var(--accent); + border-radius: 50%; + box-shadow: 0 0 4px rgba(0, 0, 0, 0.3); + transition: transform 0.15s ease; + } + + .mini-progress-bar:hover .mini-progress-fill::after { + transform: translateY(-50%) scale(1); } .mini-controls { @@ -1417,7 +1682,7 @@ gap: 1rem; margin-bottom: 1.5rem; min-height: 200px; - align-items: start; + align-items: stretch; } /* Compact Grid */ @@ -1683,6 +1948,7 @@ .browser-item-info { width: 100%; text-align: center; + margin-top: auto; } .browser-item-name { @@ -1929,4 +2195,73 @@ .browser-list-type { display: none; } + + .album-art-glow { + width: 250px; + height: 250px; + } + + .mini-volume-container { + display: none; + } + + .mini-player { + gap: 0.75rem; + padding: 0.5rem 0.75rem; + padding-top: calc(0.5rem + 2px); + } + + .mini-player-info { + min-width: 120px; + } + + .mini-progress-container { + gap: 0.5rem; + } +} + +/* Tablet breakpoint */ +@media (min-width: 601px) and (max-width: 900px) { + .browser-list-bitrate { + display: none; + } + + .browser-list-item { + grid-template-columns: 40px 1fr auto auto auto; + } +} + +/* Wide screens - horizontal player layout */ +@media (min-width: 900px) { + .container { + max-width: 960px; + } + + .player-layout { + flex-direction: row; + gap: 2.5rem; + align-items: flex-start; + } + + .album-art-container { + margin-bottom: 0; + flex-shrink: 0; + } + + .player-details .track-info { + text-align: left; + } + + .player-details .playback-state { + justify-content: flex-start; + } + + .player-details .controls { + justify-content: flex-start; + } + + .player-details .source-info { + text-align: left; + justify-content: flex-start; + } } diff --git a/media_server/static/index.html b/media_server/static/index.html index 40e8094..80c5114 100644 --- a/media_server/static/index.html +++ b/media_server/static/index.html @@ -63,7 +63,6 @@
-

Media Server

@@ -85,6 +84,7 @@
+
-
- Album Art -
- -
-
No media playing
-
-
-
- - - - Idle +
+
+ + Album Art
-
-
-
- 0:00 - 0:00 +
+
+
No media playing
+
+
+
+ + + + Idle +
+
+ +
+
+ 0:00 + 0:00 +
+
+
+
+
+ +
+ + + +
+ +
+ + +
50%
+
+ +
+ Source: Unknown + +
-
-
-
-
- -
- - - -
- -
- - -
50%
-
- -
- Source: Unknown
@@ -218,7 +226,10 @@
-
Select a folder to browse media files
+
+ +

Select a folder to browse media files

+
@@ -237,7 +248,10 @@

Quick Actions

-
No scripts configured
+
+ +

No scripts configured

+
@@ -259,7 +273,12 @@ - No scripts configured. Click "Add" to create one. + +
+ +

No scripts configured. Click "Add" to create one.

+
+ @@ -285,7 +304,12 @@ - No callbacks configured. Click "Add Callback" to create one. + +
+ +

No callbacks configured. Click "Add" to create one.

+
+ @@ -392,25 +416,25 @@
-

Execution Result

+

Execution Result

@@ -458,11 +482,11 @@ diff --git a/media_server/static/js/app.js b/media_server/static/js/app.js index a905033..0e2b7d2 100644 --- a/media_server/static/js/app.js +++ b/media_server/static/js/app.js @@ -1,3 +1,64 @@ + // SVG path constants (avoid rebuilding innerHTML on every state update) + const SVG_PLAY = ''; + const SVG_PAUSE = ''; + const SVG_STOP = ''; + const SVG_IDLE = ''; + const SVG_MUTED = ''; + const SVG_UNMUTED = ''; + + // Empty state illustration SVGs + const EMPTY_SVG_FOLDER = ''; + const EMPTY_SVG_FILE = ''; + function emptyStateHtml(svgStr, text) { + return `
${svgStr}

${text}

`; + } + + // Cached DOM references (populated once after DOMContentLoaded) + const dom = {}; + function cacheDom() { + dom.trackTitle = document.getElementById('track-title'); + dom.artist = document.getElementById('artist'); + dom.album = document.getElementById('album'); + dom.miniTrackTitle = document.getElementById('mini-track-title'); + dom.miniArtist = document.getElementById('mini-artist'); + dom.albumArt = document.getElementById('album-art'); + dom.albumArtGlow = document.getElementById('album-art-glow'); + dom.miniAlbumArt = document.getElementById('mini-album-art'); + dom.volumeSlider = document.getElementById('volume-slider'); + dom.volumeDisplay = document.getElementById('volume-display'); + dom.miniVolumeSlider = document.getElementById('mini-volume-slider'); + dom.miniVolumeDisplay = document.getElementById('mini-volume-display'); + dom.progressFill = document.getElementById('progress-fill'); + dom.currentTime = document.getElementById('current-time'); + dom.totalTime = document.getElementById('total-time'); + dom.progressBar = document.getElementById('progress-bar'); + dom.miniProgressFill = document.getElementById('mini-progress-fill'); + dom.miniCurrentTime = document.getElementById('mini-current-time'); + dom.miniTotalTime = document.getElementById('mini-total-time'); + dom.playbackState = document.getElementById('playback-state'); + dom.stateIcon = document.getElementById('state-icon'); + dom.playPauseIcon = document.getElementById('play-pause-icon'); + dom.miniPlayPauseIcon = document.getElementById('mini-play-pause-icon'); + dom.muteIcon = document.getElementById('mute-icon'); + dom.miniMuteIcon = document.getElementById('mini-mute-icon'); + dom.statusDot = document.getElementById('status-dot'); + dom.source = document.getElementById('source'); + dom.btnPlayPause = document.getElementById('btn-play-pause'); + dom.btnNext = document.getElementById('btn-next'); + dom.btnPrevious = document.getElementById('btn-previous'); + dom.miniBtnPlayPause = document.getElementById('mini-btn-play-pause'); + dom.miniPlayer = document.getElementById('mini-player'); + } + + // Timing constants + const VOLUME_THROTTLE_MS = 16; + const POSITION_INTERPOLATION_MS = 100; + const SEARCH_DEBOUNCE_MS = 200; + const TOAST_DURATION_MS = 3000; + const WS_RECONNECT_MS = 3000; + const WS_PING_INTERVAL_MS = 30000; + const VOLUME_RELEASE_DELAY_MS = 500; + // Tab management let activeTab = 'player'; @@ -12,6 +73,23 @@ } } + function updateTabIndicator(btn, animate = true) { + const indicator = document.getElementById('tabIndicator'); + if (!indicator || !btn) return; + const tabBar = document.getElementById('tabBar'); + const barRect = tabBar.getBoundingClientRect(); + const btnRect = btn.getBoundingClientRect(); + const offset = btnRect.left - barRect.left - parseFloat(getComputedStyle(tabBar).paddingLeft || 0); + if (!animate) indicator.style.transition = 'none'; + indicator.style.width = btnRect.width + 'px'; + indicator.style.transform = `translateX(${offset}px)`; + if (!animate) { + // Force reflow, then re-enable transition + indicator.offsetHeight; + indicator.style.transition = ''; + } + } + function switchTab(tabName) { activeTab = tabName; @@ -30,7 +108,10 @@ // Update tab buttons document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active')); const activeBtn = document.querySelector(`.tab-btn[data-tab="${tabName}"]`); - if (activeBtn) activeBtn.classList.add('active'); + if (activeBtn) { + activeBtn.classList.add('active'); + updateTabIndicator(activeBtn); + } // Save to localStorage localStorage.setItem('activeTab', tabName); @@ -75,6 +156,41 @@ setTheme(newTheme); } + // Vinyl mode + let vinylMode = localStorage.getItem('vinylMode') === 'true'; + let currentPlayState = 'idle'; + + function toggleVinylMode() { + vinylMode = !vinylMode; + localStorage.setItem('vinylMode', vinylMode); + applyVinylMode(); + } + + function applyVinylMode() { + const container = document.querySelector('.album-art-container'); + const btn = document.getElementById('vinylToggle'); + if (!container) return; + if (vinylMode) { + container.classList.add('vinyl'); + if (btn) btn.classList.add('active'); + updateVinylSpin(); + } else { + container.classList.remove('vinyl', 'spinning', 'paused'); + if (btn) btn.classList.remove('active'); + } + } + + function updateVinylSpin() { + const container = document.querySelector('.album-art-container'); + if (!container || !vinylMode) return; + container.classList.remove('spinning', 'paused'); + if (currentPlayState === 'playing') { + container.classList.add('spinning'); + } else if (currentPlayState === 'paused') { + container.classList.add('paused'); + } + } + // Locale management let currentLocale = 'en'; let translations = {}; @@ -240,6 +356,7 @@ let ws = null; let reconnectTimeout = null; + let pingInterval = null; let currentState = 'idle'; let currentDuration = 0; let currentPosition = 0; @@ -247,6 +364,7 @@ let volumeUpdateTimer = null; // Timer for throttling volume updates let scripts = []; let lastStatus = null; // Store last status for locale switching + let lastArtworkSource = null; // Track artwork source to skip redundant loads // Dialog dirty state tracking let scriptFormDirty = false; @@ -259,9 +377,15 @@ // Initialize on page load window.addEventListener('DOMContentLoaded', async () => { + // Cache DOM references + cacheDom(); + // Initialize theme initTheme(); + // Initialize vinyl mode + applyVinylMode(); + // Initialize locale (async - loads JSON file) await initLocale(); @@ -278,60 +402,61 @@ showAuthForm(); } - // Volume slider event - const volumeSlider = document.getElementById('volume-slider'); - volumeSlider.addEventListener('input', (e) => { - isUserAdjustingVolume = true; - const volume = parseInt(e.target.value); - document.getElementById('volume-display').textContent = `${volume}%`; + // Shared volume slider setup (avoids duplicate handler code) + function setupVolumeSlider(sliderId) { + const slider = document.getElementById(sliderId); + slider.addEventListener('input', (e) => { + isUserAdjustingVolume = true; + const volume = parseInt(e.target.value); + // Sync both sliders and displays + dom.volumeDisplay.textContent = `${volume}%`; + dom.miniVolumeDisplay.textContent = `${volume}%`; + dom.volumeSlider.value = volume; + dom.miniVolumeSlider.value = volume; - // Throttle volume updates while dragging (update every 16ms via WebSocket) - if (volumeUpdateTimer) { - clearTimeout(volumeUpdateTimer); - } - volumeUpdateTimer = setTimeout(() => { + if (volumeUpdateTimer) clearTimeout(volumeUpdateTimer); + volumeUpdateTimer = setTimeout(() => { + setVolume(volume); + volumeUpdateTimer = null; + }, VOLUME_THROTTLE_MS); + }); + + slider.addEventListener('change', (e) => { + if (volumeUpdateTimer) { + clearTimeout(volumeUpdateTimer); + volumeUpdateTimer = null; + } + const volume = parseInt(e.target.value); setVolume(volume); - volumeUpdateTimer = null; - }, 16); - }); + setTimeout(() => { isUserAdjustingVolume = false; }, VOLUME_RELEASE_DELAY_MS); + }); + } - volumeSlider.addEventListener('change', (e) => { - // Clear any pending throttled update - if (volumeUpdateTimer) { - clearTimeout(volumeUpdateTimer); - volumeUpdateTimer = null; - } - - // Send final volume update immediately - const volume = parseInt(e.target.value); - setVolume(volume); - setTimeout(() => { isUserAdjustingVolume = false; }, 500); - }); + setupVolumeSlider('volume-slider'); + setupVolumeSlider('mini-volume-slider'); // Restore saved tab const savedTab = localStorage.getItem('activeTab') || 'player'; switchTab(savedTab); + // Snap indicator to initial position without animation + const initialActiveBtn = document.querySelector('.tab-btn.active'); + if (initialActiveBtn) updateTabIndicator(initialActiveBtn, false); + + // Re-position tab indicator on window resize + window.addEventListener('resize', () => { + const activeBtn = document.querySelector('.tab-btn.active'); + if (activeBtn) updateTabIndicator(activeBtn, false); + }); // Mini Player: Intersection Observer to show/hide when main player scrolls out of view const playerContainer = document.querySelector('.player-container'); - const miniPlayer = document.getElementById('mini-player'); - const observerOptions = { - root: null, // viewport - threshold: 0.1, // trigger when 10% visible - rootMargin: '0px' - }; - - const observerCallback = (entries) => { + const observer = new IntersectionObserver((entries) => { entries.forEach(entry => { - // Only use scroll-based logic when on the player tab if (activeTab !== 'player') return; - setMiniPlayerVisible(!entry.isIntersecting); }); - }; - - const observer = new IntersectionObserver(observerCallback, observerOptions); + }, { threshold: 0.1 }); observer.observe(playerContainer); // Mini player progress bar click to seek @@ -343,38 +468,6 @@ seek(position); }); - // Mini player volume slider - const miniVolumeSlider = document.getElementById('mini-volume-slider'); - miniVolumeSlider.addEventListener('input', (e) => { - isUserAdjustingVolume = true; - const volume = parseInt(e.target.value); - document.getElementById('mini-volume-display').textContent = `${volume}%`; - document.getElementById('volume-display').textContent = `${volume}%`; - document.getElementById('volume-slider').value = volume; - - // Throttle volume updates while dragging (update every 16ms via WebSocket) - if (volumeUpdateTimer) { - clearTimeout(volumeUpdateTimer); - } - volumeUpdateTimer = setTimeout(() => { - setVolume(volume); - volumeUpdateTimer = null; - }, 16); - }); - - miniVolumeSlider.addEventListener('change', (e) => { - // Clear any pending throttled update - if (volumeUpdateTimer) { - clearTimeout(volumeUpdateTimer); - volumeUpdateTimer = null; - } - - // Send final volume update immediately - const volume = parseInt(e.target.value); - setVolume(volume); - setTimeout(() => { isUserAdjustingVolume = false; }, 500); - }); - // Progress bar click to seek const progressBar = document.getElementById('progress-bar'); progressBar.addEventListener('click', (e) => { @@ -468,6 +561,12 @@ } function connectWebSocket(token) { + // Clear previous ping interval to prevent stacking + if (pingInterval) { + clearInterval(pingInterval); + pingInterval = null; + } + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${protocol}//${window.location.host}/api/media/ws?token=${encodeURIComponent(token)}`; @@ -518,25 +617,23 @@ console.log('Attempting to reconnect...'); connectWebSocket(savedToken); } - }, 3000); + }, WS_RECONNECT_MS); } }; - // Send keepalive ping every 30 seconds - setInterval(() => { + // Send keepalive ping + pingInterval = setInterval(() => { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'ping' })); } - }, 30000); + }, WS_PING_INTERVAL_MS); } function updateConnectionStatus(connected) { - const dot = document.getElementById('status-dot'); - if (connected) { - dot.classList.add('connected'); + dom.statusDot.classList.add('connected'); } else { - dot.classList.remove('connected'); + dom.statusDot.classList.remove('connected'); } } @@ -546,28 +643,35 @@ // Update track info const fallbackTitle = status.state === 'idle' ? t('player.no_media') : t('player.title_unavailable'); - document.getElementById('track-title').textContent = status.title || fallbackTitle; - document.getElementById('artist').textContent = status.artist || ''; - document.getElementById('album').textContent = status.album || ''; + dom.trackTitle.textContent = status.title || fallbackTitle; + dom.artist.textContent = status.artist || ''; + dom.album.textContent = status.album || ''; // Update mini player info - document.getElementById('mini-track-title').textContent = status.title || fallbackTitle; - document.getElementById('mini-artist').textContent = status.artist || ''; + dom.miniTrackTitle.textContent = status.title || fallbackTitle; + dom.miniArtist.textContent = status.artist || ''; // Update state const previousState = currentState; currentState = status.state; updatePlaybackState(status.state); - // Update album art - const artImg = document.getElementById('album-art'); - const miniArtImg = document.getElementById('mini-album-art'); - const artworkUrl = status.album_art_url - ? `/api/media/artwork?token=${encodeURIComponent(localStorage.getItem('media_server_token'))}&_=${Date.now()}` - : "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Crect fill='%23282828' width='300' height='300'/%3E%3Cpath fill='%236a6a6a' d='M150 80c-38.66 0-70 31.34-70 70s31.34 70 70 70 70-31.34 70-70-31.34-70-70-70zm0 20c27.614 0 50 22.386 50 50s-22.386 50-50 50-50-22.386-50-50 22.386-50 50-50zm0 30a20 20 0 100 40 20 20 0 000-40z'/%3E%3C/svg%3E"; + // Update album art (skip if same source to avoid redundant network requests) + const artworkSource = status.album_art_url || null; - artImg.src = artworkUrl; - miniArtImg.src = artworkUrl; + if (artworkSource !== lastArtworkSource) { + lastArtworkSource = artworkSource; + const artworkUrl = artworkSource + ? `/api/media/artwork?token=${encodeURIComponent(localStorage.getItem('media_server_token'))}&_=${Date.now()}` + : "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Crect fill='%23282828' width='300' height='300'/%3E%3Cpath fill='%236a6a6a' d='M150 80c-38.66 0-70 31.34-70 70s31.34 70 70 70 70-31.34 70-70-31.34-70-70-70zm0 20c27.614 0 50 22.386 50 50s-22.386 50-50 50-50-22.386-50-50 22.386-50 50-50zm0 30a20 20 0 100 40 20 20 0 000-40z'/%3E%3C/svg%3E"; + dom.albumArt.src = artworkUrl; + dom.miniAlbumArt.src = artworkUrl; + if (dom.albumArtGlow) { + dom.albumArtGlow.src = artworkSource + ? artworkUrl + : "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Crect fill='%23282828' width='300' height='300'/%3E%3C/svg%3E"; + } + } // Update progress if (status.duration && status.position !== null) { @@ -583,24 +687,24 @@ // Update volume if (!isUserAdjustingVolume) { - document.getElementById('volume-slider').value = status.volume; - document.getElementById('volume-display').textContent = `${status.volume}%`; - document.getElementById('mini-volume-slider').value = status.volume; - document.getElementById('mini-volume-display').textContent = `${status.volume}%`; + dom.volumeSlider.value = status.volume; + dom.volumeDisplay.textContent = `${status.volume}%`; + dom.miniVolumeSlider.value = status.volume; + dom.miniVolumeDisplay.textContent = `${status.volume}%`; } // Update mute state updateMuteIcon(status.muted); // Update source - document.getElementById('source').textContent = status.source || t('player.unknown_source'); + dom.source.textContent = status.source || t('player.unknown_source'); // Enable/disable controls based on state const hasMedia = status.state !== 'idle'; - document.getElementById('btn-play-pause').disabled = !hasMedia; - document.getElementById('btn-next').disabled = !hasMedia; - document.getElementById('btn-previous').disabled = !hasMedia; - document.getElementById('mini-btn-play-pause').disabled = !hasMedia; + dom.btnPlayPause.disabled = !hasMedia; + dom.btnNext.disabled = !hasMedia; + dom.btnPrevious.disabled = !hasMedia; + dom.miniBtnPlayPause.disabled = !hasMedia; // Start/stop position interpolation based on playback state if (status.state === 'playing' && previousState !== 'playing') { @@ -611,49 +715,50 @@ } function updatePlaybackState(state) { - const stateText = document.getElementById('playback-state'); - const stateIcon = document.getElementById('state-icon'); - const playPauseIcon = document.getElementById('play-pause-icon'); - const miniPlayPauseIcon = document.getElementById('mini-play-pause-icon'); - + currentPlayState = state; switch(state) { case 'playing': - stateText.textContent = t('state.playing'); - stateIcon.innerHTML = ''; - playPauseIcon.innerHTML = ''; - miniPlayPauseIcon.innerHTML = ''; + dom.playbackState.textContent = t('state.playing'); + dom.stateIcon.innerHTML = SVG_PLAY; + dom.playPauseIcon.innerHTML = SVG_PAUSE; + dom.miniPlayPauseIcon.innerHTML = SVG_PAUSE; break; case 'paused': - stateText.textContent = t('state.paused'); - stateIcon.innerHTML = ''; - playPauseIcon.innerHTML = ''; - miniPlayPauseIcon.innerHTML = ''; + dom.playbackState.textContent = t('state.paused'); + dom.stateIcon.innerHTML = SVG_PAUSE; + dom.playPauseIcon.innerHTML = SVG_PLAY; + dom.miniPlayPauseIcon.innerHTML = SVG_PLAY; break; case 'stopped': - stateText.textContent = t('state.stopped'); - stateIcon.innerHTML = ''; - playPauseIcon.innerHTML = ''; - miniPlayPauseIcon.innerHTML = ''; + dom.playbackState.textContent = t('state.stopped'); + dom.stateIcon.innerHTML = SVG_STOP; + dom.playPauseIcon.innerHTML = SVG_PLAY; + dom.miniPlayPauseIcon.innerHTML = SVG_PLAY; break; default: - stateText.textContent = t('state.idle'); - stateIcon.innerHTML = ''; - playPauseIcon.innerHTML = ''; - miniPlayPauseIcon.innerHTML = ''; + dom.playbackState.textContent = t('state.idle'); + dom.stateIcon.innerHTML = SVG_IDLE; + dom.playPauseIcon.innerHTML = SVG_PLAY; + dom.miniPlayPauseIcon.innerHTML = SVG_PLAY; } + updateVinylSpin(); } function updateProgress(position, duration) { const percent = (position / duration) * 100; - document.getElementById('progress-fill').style.width = `${percent}%`; - document.getElementById('current-time').textContent = formatTime(position); - document.getElementById('total-time').textContent = formatTime(duration); - document.getElementById('progress-bar').dataset.duration = duration; + const widthStr = `${percent}%`; + const currentStr = formatTime(position); + const totalStr = formatTime(duration); - // Update mini player progress - document.getElementById('mini-progress-fill').style.width = `${percent}%`; - document.getElementById('mini-current-time').textContent = formatTime(position); - document.getElementById('mini-total-time').textContent = formatTime(duration); + dom.progressFill.style.width = widthStr; + dom.currentTime.textContent = currentStr; + dom.totalTime.textContent = totalStr; + dom.progressBar.dataset.duration = duration; + + dom.miniProgressFill.style.width = widthStr; + dom.miniCurrentTime.textContent = currentStr; + dom.miniTotalTime.textContent = totalStr; + if (dom.miniPlayer) dom.miniPlayer.style.setProperty('--mini-progress', widthStr); } function startPositionInterpolation() { @@ -665,14 +770,11 @@ // Update position every 100ms for smooth animation interpolationInterval = setInterval(() => { if (currentState === 'playing' && currentDuration > 0 && lastPositionUpdate > 0) { - // Calculate elapsed time since last position update const elapsed = (Date.now() - lastPositionUpdate) / 1000; const interpolatedPosition = Math.min(lastPositionValue + elapsed, currentDuration); - - // Update UI with interpolated position updateProgress(interpolatedPosition, currentDuration); } - }, 100); + }, POSITION_INTERPOLATION_MS); } function stopPositionInterpolation() { @@ -683,18 +785,9 @@ } function updateMuteIcon(muted) { - const muteIcon = document.getElementById('mute-icon'); - const miniMuteIcon = document.getElementById('mini-mute-icon'); - const mutedPath = ''; - const unmutedPath = ''; - - if (muted) { - muteIcon.innerHTML = mutedPath; - miniMuteIcon.innerHTML = mutedPath; - } else { - muteIcon.innerHTML = unmutedPath; - miniMuteIcon.innerHTML = unmutedPath; - } + const path = muted ? SVG_MUTED : SVG_UNMUTED; + dom.muteIcon.innerHTML = path; + dom.miniMuteIcon.innerHTML = path; } function formatTime(seconds) { @@ -723,10 +816,13 @@ try { const response = await fetch(`/api/media/${endpoint}`, options); if (!response.ok) { + const data = await response.json().catch(() => ({})); console.error(`Command ${endpoint} failed:`, response.status); + showToast(data.detail || `Command failed: ${endpoint}`, 'error'); } } catch (error) { console.error(`Error sending command ${endpoint}:`, error); + showToast(`Connection error: ${endpoint}`, 'error'); } } @@ -746,7 +842,10 @@ sendCommand('previous'); } + let lastSentVolume = -1; function setVolume(volume) { + if (volume === lastSentVolume) return; + lastSentVolume = volume; // Use WebSocket for low-latency volume updates if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'volume', volume: volume })); @@ -788,7 +887,7 @@ const grid = document.getElementById('scripts-grid'); if (scripts.length === 0) { - grid.innerHTML = `
${t('scripts.no_scripts')}
`; + grid.innerHTML = `

${t('scripts.no_scripts')}

`; return; } @@ -855,12 +954,20 @@ setTimeout(() => { toast.classList.remove('show'); - }, 3000); + }, TOAST_DURATION_MS); } // Script Management Functions + let _loadScriptsPromise = null; async function loadScriptsTable() { + if (_loadScriptsPromise) return _loadScriptsPromise; + _loadScriptsPromise = _loadScriptsTableImpl(); + _loadScriptsPromise.finally(() => { _loadScriptsPromise = null; }); + return _loadScriptsPromise; + } + + async function _loadScriptsTableImpl() { const token = localStorage.getItem('media_server_token'); const tbody = document.getElementById('scriptsTableBody'); @@ -876,7 +983,7 @@ const scriptsList = await response.json(); if (scriptsList.length === 0) { - tbody.innerHTML = 'No scripts configured. Click "Add Script" to create one.'; + tbody.innerHTML = '

' + t('scripts.empty') + '

'; return; } @@ -997,6 +1104,9 @@ async function saveScript(event) { event.preventDefault(); + const submitBtn = event.target.querySelector('button[type="submit"]'); + if (submitBtn) submitBtn.disabled = true; + const token = localStorage.getItem('media_server_token'); const isEdit = document.getElementById('scriptIsEdit').value === 'true'; const scriptName = isEdit ? @@ -1032,15 +1142,16 @@ if (response.ok && result.success) { showToast(`Script ${isEdit ? 'updated' : 'created'} successfully`, 'success'); - scriptFormDirty = false; // Reset dirty state before closing + scriptFormDirty = false; closeScriptDialog(); - // Don't reload manually - WebSocket will trigger it } else { showToast(result.detail || `Failed to ${isEdit ? 'update' : 'create'} script`, 'error'); } } catch (error) { console.error('Error saving script:', error); showToast(`Error ${isEdit ? 'updating' : 'creating'} script`, 'error'); + } finally { + if (submitBtn) submitBtn.disabled = false; } } @@ -1075,7 +1186,15 @@ // Callback Management Functions + let _loadCallbacksPromise = null; async function loadCallbacksTable() { + if (_loadCallbacksPromise) return _loadCallbacksPromise; + _loadCallbacksPromise = _loadCallbacksTableImpl(); + _loadCallbacksPromise.finally(() => { _loadCallbacksPromise = null; }); + return _loadCallbacksPromise; + } + + async function _loadCallbacksTableImpl() { const token = localStorage.getItem('media_server_token'); const tbody = document.getElementById('callbacksTableBody'); @@ -1091,7 +1210,7 @@ const callbacksList = await response.json(); if (callbacksList.length === 0) { - tbody.innerHTML = 'No callbacks configured. Click "Add Callback" to create one.'; + tbody.innerHTML = '

' + t('callbacks.empty') + '

'; return; } @@ -1201,6 +1320,9 @@ async function saveCallback(event) { event.preventDefault(); + const submitBtn = event.target.querySelector('button[type="submit"]'); + if (submitBtn) submitBtn.disabled = true; + const token = localStorage.getItem('media_server_token'); const isEdit = document.getElementById('callbackIsEdit').value === 'true'; const callbackName = document.getElementById('callbackName').value; @@ -1232,7 +1354,7 @@ if (response.ok && result.success) { showToast(`Callback ${isEdit ? 'updated' : 'created'} successfully`, 'success'); - callbackFormDirty = false; // Reset dirty state before closing + callbackFormDirty = false; closeCallbackDialog(); loadCallbacksTable(); } else { @@ -1241,6 +1363,8 @@ } catch (error) { console.error('Error saving callback:', error); showToast(`Error ${isEdit ? 'updating' : 'creating'} callback`, 'error'); + } finally { + if (submitBtn) submitBtn.disabled = false; } } @@ -1511,6 +1635,7 @@ function showRootFolders() { // Render folders as grid cards const container = document.getElementById('browserGrid'); + revokeBlobUrls(container); if (viewMode === 'list') { container.className = 'browser-list'; } else if (viewMode === 'compact') { @@ -1662,8 +1787,15 @@ function renderBreadcrumbs(currentPath, parentPath) { }); } +function revokeBlobUrls(container) { + container.querySelectorAll('img[src^="blob:"]').forEach(img => { + URL.revokeObjectURL(img.src); + }); +} + function renderBrowserItems(items) { const container = document.getElementById('browserGrid'); + revokeBlobUrls(container); // Switch container class based on view mode if (viewMode === 'list') { container.className = 'browser-list'; @@ -1681,7 +1813,7 @@ function renderBrowserList(items, container) { container.innerHTML = ''; if (!items || items.length === 0) { - container.innerHTML = `
${t('browser.no_items')}
`; + container.innerHTML = `
${emptyStateHtml(EMPTY_SVG_FILE, t('browser.no_items'))}
`; return; } @@ -1774,7 +1906,7 @@ function renderBrowserGrid(items, container) { container.innerHTML = ''; if (!items || items.length === 0) { - container.innerHTML = `
${t('browser.no_items')}
`; + container.innerHTML = `
${emptyStateHtml(EMPTY_SVG_FILE, t('browser.no_items'))}
`; return; } @@ -1943,6 +2075,10 @@ async function loadThumbnail(imgElement, fileName) { imgElement.classList.add('loaded'); }; + // Revoke previous blob URL if any + if (imgElement.src && imgElement.src.startsWith('blob:')) { + URL.revokeObjectURL(imgElement.src); + } imgElement.src = url; } else { // Fallback to icon (204 = no thumbnail available) @@ -1964,7 +2100,11 @@ async function loadThumbnail(imgElement, fileName) { } } +let playInProgress = false; + async function playMediaFile(fileName) { + if (playInProgress) return; + playInProgress = true; try { const token = localStorage.getItem('media_server_token'); if (!token) { @@ -1988,15 +2128,20 @@ async function playMediaFile(fileName) { if (!response.ok) throw new Error('Failed to play file'); - const data = await response.json(); showToast(t('browser.play_success', { filename: fileName }), 'success'); } catch (error) { console.error('Error playing file:', error); showToast(t('browser.play_error'), 'error'); + } finally { + playInProgress = false; } } async function playAllFolder() { + if (playInProgress) return; + playInProgress = true; + const btn = document.getElementById('playAllBtn'); + if (btn) btn.disabled = true; try { const token = localStorage.getItem('media_server_token'); if (!token || !currentFolderId) return; @@ -2020,6 +2165,9 @@ async function playAllFolder() { } catch (error) { console.error('Error playing folder:', error); showToast(t('browser.play_all_error'), 'error'); + } finally { + playInProgress = false; + if (btn) btn.disabled = false; } } @@ -2108,7 +2256,7 @@ function onBrowserSearch() { browserSearchTimer = setTimeout(() => { browserSearchTerm = term.toLowerCase(); applyBrowserSearch(); - }, 200); + }, SEARCH_DEBOUNCE_MS); } function clearBrowserSearch() { @@ -2206,7 +2354,7 @@ function initBrowserToolbar() { function clearBrowserGrid() { const grid = document.getElementById('browserGrid'); - grid.innerHTML = `
${t('browser.no_folder_selected')}
`; + grid.innerHTML = `
${emptyStateHtml(EMPTY_SVG_FOLDER, t('browser.no_folder_selected'))}
`; document.getElementById('breadcrumb').innerHTML = ''; document.getElementById('browserPagination').style.display = 'none'; document.getElementById('playAllBtn').style.display = 'none'; diff --git a/media_server/static/locales/en.json b/media_server/static/locales/en.json index 99cdcff..e341697 100644 --- a/media_server/static/locales/en.json +++ b/media_server/static/locales/en.json @@ -21,6 +21,7 @@ "player.title_unavailable": "Title unavailable", "player.source": "Source:", "player.unknown_source": "Unknown", + "player.vinyl": "Vinyl mode", "state.playing": "Playing", "state.paused": "Paused", "state.stopped": "Stopped", @@ -65,6 +66,10 @@ "scripts.msg.load_failed": "Failed to load script details", "scripts.msg.list_failed": "Failed to load scripts", "scripts.confirm.delete": "Are you sure you want to delete the script \"{name}\"?", + "scripts.execution.title": "Execution Result", + "scripts.execution.output": "Output", + "scripts.execution.error_output": "Error Output", + "scripts.execution.close": "Close", "scripts.confirm.unsaved": "You have unsaved changes. Are you sure you want to discard them?", "callbacks.management": "Callback Management", "callbacks.description": "Callbacks are scripts triggered automatically by media control events (play, pause, stop, etc.)", @@ -148,5 +153,7 @@ "browser.folder_dialog.path_help": "Absolute path to media directory", "browser.folder_dialog.enabled": "Enabled", "browser.folder_dialog.cancel": "Cancel", - "browser.folder_dialog.save": "Save" + "browser.folder_dialog.save": "Save", + "footer.created_by": "Created by", + "footer.source_code": "Source Code" } diff --git a/media_server/static/locales/ru.json b/media_server/static/locales/ru.json index ca9d461..0b545e2 100644 --- a/media_server/static/locales/ru.json +++ b/media_server/static/locales/ru.json @@ -21,6 +21,7 @@ "player.title_unavailable": "Название недоступно", "player.source": "Источник:", "player.unknown_source": "Неизвестно", + "player.vinyl": "Режим винила", "state.playing": "Воспроизведение", "state.paused": "Пауза", "state.stopped": "Остановлено", @@ -65,6 +66,10 @@ "scripts.msg.load_failed": "Не удалось загрузить данные скрипта", "scripts.msg.list_failed": "Не удалось загрузить скрипты", "scripts.confirm.delete": "Вы уверены, что хотите удалить скрипт \"{name}\"?", + "scripts.execution.title": "Результат выполнения", + "scripts.execution.output": "Вывод", + "scripts.execution.error_output": "Вывод ошибок", + "scripts.execution.close": "Закрыть", "scripts.confirm.unsaved": "У вас есть несохраненные изменения. Вы уверены, что хотите отменить их?", "callbacks.management": "Управление Обратными Вызовами", "callbacks.description": "Обратные вызовы - это скрипты, автоматически запускаемые при событиях управления медиа (воспроизведение, пауза, остановка и т.д.)", @@ -148,5 +153,7 @@ "browser.folder_dialog.path_help": "Абсолютный путь к медиа каталогу", "browser.folder_dialog.enabled": "Включено", "browser.folder_dialog.cancel": "Отмена", - "browser.folder_dialog.save": "Сохранить" + "browser.folder_dialog.save": "Сохранить", + "footer.created_by": "Создано", + "footer.source_code": "Исходный код" } diff --git a/scripts/install_task_windows.ps1 b/scripts/install_task_windows.ps1 index 4d9b7d1..c108c59 100644 --- a/scripts/install_task_windows.ps1 +++ b/scripts/install_task_windows.ps1 @@ -2,17 +2,17 @@ Unregister-ScheduledTask -TaskName "MediaServer" -Confirm:$false -ErrorAction Si # Get the media-server directory (parent of scripts folder) $serverRoot = (Get-Item $PSScriptRoot).Parent.FullName +$vbsPath = Join-Path $PSScriptRoot "start-hidden.vbs" -# Find Python executable -$pythonPath = (Get-Command python -ErrorAction SilentlyContinue).Source -if (-not $pythonPath) { - Write-Error "Python not found in PATH. Please ensure Python is installed and accessible." +if (-not (Test-Path $vbsPath)) { + Write-Error "start-hidden.vbs not found in scripts folder." exit 1 } -$action = New-ScheduledTaskAction -Execute $pythonPath -Argument "-m media_server.main" -WorkingDirectory $serverRoot -$trigger = New-ScheduledTaskTrigger -AtStartup -$principal = New-ScheduledTaskPrincipal -UserId "$env:USERNAME" -LogonType S4U -RunLevel Highest +# Launch via wscript + VBS to run python completely hidden (no console window) +$action = New-ScheduledTaskAction -Execute "wscript.exe" -Argument "`"$vbsPath`"" -WorkingDirectory $serverRoot +$trigger = New-ScheduledTaskTrigger -AtLogon -User "$env:USERNAME" +$principal = New-ScheduledTaskPrincipal -UserId "$env:USERNAME" -LogonType Interactive -RunLevel Highest $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable Register-ScheduledTask -TaskName "MediaServer" -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Description "Media Server for Home Assistant" diff --git a/scripts/start-hidden.vbs b/scripts/start-hidden.vbs new file mode 100644 index 0000000..a23d46f --- /dev/null +++ b/scripts/start-hidden.vbs @@ -0,0 +1,7 @@ +Set WshShell = CreateObject("WScript.Shell") +' Get the directory of this script (scripts\), then go up to media-server root +scriptDir = CreateObject("Scripting.FileSystemObject").GetParentFolderName(WScript.ScriptFullName) +serverRoot = CreateObject("Scripting.FileSystemObject").GetParentFolderName(scriptDir) +WshShell.CurrentDirectory = serverRoot +' Run python completely hidden (0 = hidden, False = don't wait) +WshShell.Run "python -m media_server.main", 0, False