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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -18,13 +18,15 @@ 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
|
||||
|
||||
async def _execute():
|
||||
from .scripts import _run_script
|
||||
|
||||
try:
|
||||
callback = settings.callbacks[callback_name]
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await loop.run_in_executor(
|
||||
@@ -43,6 +45,10 @@ async def _run_callback(callback_name: str) -> None:
|
||||
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}
|
||||
|
||||
|
||||
|
||||
@@ -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,23 +49,26 @@ 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:
|
||||
for ws in results:
|
||||
if ws is not None:
|
||||
await self.disconnect(ws)
|
||||
|
||||
async def broadcast_scripts_changed(self) -> None:
|
||||
@@ -156,7 +159,10 @@ class ConnectionManager:
|
||||
async with self._lock:
|
||||
has_clients = len(self._active_connections) > 0
|
||||
|
||||
if has_clients:
|
||||
if not has_clients:
|
||||
await asyncio.sleep(self._poll_interval)
|
||||
continue
|
||||
|
||||
status = await get_status_func()
|
||||
status_dict = status.model_dump()
|
||||
|
||||
@@ -172,10 +178,6 @@ class ConnectionManager:
|
||||
else:
|
||||
# Update cached status even without broadcast
|
||||
self._last_status = status_dict
|
||||
else:
|
||||
# Still update cache for when clients connect
|
||||
status = await get_status_func()
|
||||
self._last_status = status.model_dump()
|
||||
|
||||
await asyncio.sleep(self._poll_interval)
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +63,6 @@
|
||||
<header>
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<span class="status-dot" id="status-dot"></span>
|
||||
<h1 data-i18n="app.title">Media Server</h1>
|
||||
<span class="version-label" id="version-label"></span>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 1rem;">
|
||||
@@ -85,6 +84,7 @@
|
||||
|
||||
<!-- Tab Bar -->
|
||||
<div class="tab-bar" id="tabBar">
|
||||
<div class="tab-indicator" id="tabIndicator"></div>
|
||||
<button class="tab-btn active" data-tab="player" onclick="switchTab('player')">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/></svg>
|
||||
<span data-i18n="tab.player">Player</span>
|
||||
@@ -108,10 +108,13 @@
|
||||
</div>
|
||||
|
||||
<div class="player-container" data-tab-content="player">
|
||||
<div class="player-layout">
|
||||
<div class="album-art-container">
|
||||
<img id="album-art-glow" class="album-art-glow" src="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" alt="" aria-hidden="true">
|
||||
<img id="album-art" src="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" alt="Album Art">
|
||||
</div>
|
||||
|
||||
<div class="player-details">
|
||||
<div class="track-info">
|
||||
<div id="track-title" data-i18n="player.no_media">No media playing</div>
|
||||
<div id="artist"></div>
|
||||
@@ -164,6 +167,11 @@
|
||||
|
||||
<div class="source-info">
|
||||
<span data-i18n="player.source">Source:</span> <span id="source" data-i18n="player.unknown_source">Unknown</span>
|
||||
<button class="vinyl-toggle-btn" onclick="toggleVinylMode()" id="vinylToggle" data-i18n-title="player.vinyl" title="Vinyl mode">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="12" cy="12" r="3" fill="currentColor"/><circle cx="12" cy="12" r="6.5" fill="none" stroke="currentColor" stroke-width="0.5" opacity="0.5"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -218,7 +226,10 @@
|
||||
|
||||
<!-- File/Folder Grid -->
|
||||
<div class="browser-grid" id="browserGrid">
|
||||
<div class="browser-empty" data-i18n="browser.no_folder_selected">Select a folder to browse media files</div>
|
||||
<div class="browser-empty empty-state-illustration">
|
||||
<svg viewBox="0 0 64 64"><path d="M8 16h20l4-6h20a4 4 0 014 4v36a4 4 0 01-4 4H8a4 4 0 01-4-4V20a4 4 0 014-4z"/><path d="M4 24h56" stroke-dasharray="4 3" opacity="0.4"/></svg>
|
||||
<p data-i18n="browser.no_folder_selected">Select a folder to browse media files</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
@@ -237,7 +248,10 @@
|
||||
<div class="scripts-container" id="scripts-container" data-tab-content="quick-actions" >
|
||||
<h2 data-i18n="scripts.quick_actions">Quick Actions</h2>
|
||||
<div class="scripts-grid" id="scripts-grid">
|
||||
<div class="scripts-empty" data-i18n="scripts.no_scripts">No scripts configured</div>
|
||||
<div class="scripts-empty empty-state-illustration">
|
||||
<svg viewBox="0 0 64 64"><path d="M20 8l-8 48"/><path d="M44 8l8 48"/><path d="M10 24h44"/><path d="M8 40h44"/></svg>
|
||||
<p data-i18n="scripts.no_scripts">No scripts configured</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -259,7 +273,12 @@
|
||||
</thead>
|
||||
<tbody id="scriptsTableBody">
|
||||
<tr>
|
||||
<td colspan="5" class="empty-state" data-i18n="scripts.empty">No scripts configured. Click "Add" to create one.</td>
|
||||
<td colspan="5" class="empty-state">
|
||||
<div class="empty-state-illustration">
|
||||
<svg viewBox="0 0 64 64"><rect x="8" y="4" width="48" height="56" rx="4"/><path d="M20 20h24M20 32h16M20 44h20"/></svg>
|
||||
<p data-i18n="scripts.empty">No scripts configured. Click "Add" to create one.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -285,7 +304,12 @@
|
||||
</thead>
|
||||
<tbody id="callbacksTableBody">
|
||||
<tr>
|
||||
<td colspan="4" class="empty-state">No callbacks configured. Click "Add Callback" to create one.</td>
|
||||
<td colspan="4" class="empty-state">
|
||||
<div class="empty-state-illustration">
|
||||
<svg viewBox="0 0 64 64"><circle cx="32" cy="32" r="24"/><path d="M32 20v12l8 8"/></svg>
|
||||
<p>No callbacks configured. Click "Add" to create one.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -392,25 +416,25 @@
|
||||
<!-- Execution Result Dialog -->
|
||||
<dialog id="executionDialog">
|
||||
<div class="dialog-header">
|
||||
<h3 id="executionDialogTitle">Execution Result</h3>
|
||||
<h3 id="executionDialogTitle" data-i18n="scripts.execution.title">Execution Result</h3>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<div class="execution-status" id="executionStatus"></div>
|
||||
<div class="result-section" id="outputSection" style="display: none;">
|
||||
<h4>Output</h4>
|
||||
<h4 data-i18n="scripts.execution.output">Output</h4>
|
||||
<div class="execution-result">
|
||||
<pre id="executionOutput"></pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="result-section" id="errorSection" style="display: none;">
|
||||
<h4>Error Output</h4>
|
||||
<h4 data-i18n="scripts.execution.error_output">Error Output</h4>
|
||||
<div class="execution-result">
|
||||
<pre id="executionError"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button type="button" class="btn-secondary" onclick="closeExecutionDialog()">Close</button>
|
||||
<button type="button" class="btn-secondary" onclick="closeExecutionDialog()" data-i18n="scripts.execution.close">Close</button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
@@ -458,11 +482,11 @@
|
||||
<!-- Footer -->
|
||||
<footer>
|
||||
<div>
|
||||
Created by <strong>Alexei Dolgolyov</strong>
|
||||
<span data-i18n="footer.created_by">Created by</span> <strong>Alexei Dolgolyov</strong>
|
||||
<span class="separator">•</span>
|
||||
<a href="mailto:dolgolyov.alexei@gmail.com">dolgolyov.alexei@gmail.com</a>
|
||||
<span class="separator">•</span>
|
||||
<a href="https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server" target="_blank" rel="noopener noreferrer">Source Code</a>
|
||||
<a href="https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server" target="_blank" rel="noopener noreferrer" data-i18n="footer.source_code">Source Code</a>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
|
||||
@@ -1,3 +1,64 @@
|
||||
// SVG path constants (avoid rebuilding innerHTML on every state update)
|
||||
const SVG_PLAY = '<path d="M8 5v14l11-7z"/>';
|
||||
const SVG_PAUSE = '<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>';
|
||||
const SVG_STOP = '<path d="M6 6h12v12H6z"/>';
|
||||
const SVG_IDLE = '<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"/>';
|
||||
const SVG_MUTED = '<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>';
|
||||
const SVG_UNMUTED = '<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>';
|
||||
|
||||
// Empty state illustration SVGs
|
||||
const EMPTY_SVG_FOLDER = '<svg viewBox="0 0 64 64"><path d="M8 16h20l4-6h20a4 4 0 014 4v36a4 4 0 01-4 4H8a4 4 0 01-4-4V20a4 4 0 014-4z"/><path d="M4 24h56" stroke-dasharray="4 3" opacity="0.4"/></svg>';
|
||||
const EMPTY_SVG_FILE = '<svg viewBox="0 0 64 64"><path d="M16 4h22l14 14v38a4 4 0 01-4 4H16a4 4 0 01-4-4V8a4 4 0 014-4z"/><path d="M38 4v14h14"/><path d="M22 32h20M22 40h14" opacity="0.5"/></svg>';
|
||||
function emptyStateHtml(svgStr, text) {
|
||||
return `<div class="empty-state-illustration">${svgStr}<p>${text}</p></div>`;
|
||||
}
|
||||
|
||||
// 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) => {
|
||||
// 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);
|
||||
document.getElementById('volume-display').textContent = `${volume}%`;
|
||||
// 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);
|
||||
}
|
||||
if (volumeUpdateTimer) clearTimeout(volumeUpdateTimer);
|
||||
volumeUpdateTimer = setTimeout(() => {
|
||||
setVolume(volume);
|
||||
volumeUpdateTimer = null;
|
||||
}, 16);
|
||||
}, VOLUME_THROTTLE_MS);
|
||||
});
|
||||
|
||||
volumeSlider.addEventListener('change', (e) => {
|
||||
// Clear any pending throttled update
|
||||
slider.addEventListener('change', (e) => {
|
||||
if (volumeUpdateTimer) {
|
||||
clearTimeout(volumeUpdateTimer);
|
||||
volumeUpdateTimer = null;
|
||||
}
|
||||
|
||||
// Send final volume update immediately
|
||||
const volume = parseInt(e.target.value);
|
||||
setVolume(volume);
|
||||
setTimeout(() => { isUserAdjustingVolume = false; }, 500);
|
||||
setTimeout(() => { isUserAdjustingVolume = false; }, VOLUME_RELEASE_DELAY_MS);
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
// Update album art (skip if same source to avoid redundant network requests)
|
||||
const artworkSource = status.album_art_url || null;
|
||||
|
||||
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";
|
||||
|
||||
artImg.src = artworkUrl;
|
||||
miniArtImg.src = artworkUrl;
|
||||
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 = '<path d="M8 5v14l11-7z"/>';
|
||||
playPauseIcon.innerHTML = '<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>';
|
||||
miniPlayPauseIcon.innerHTML = '<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>';
|
||||
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 = '<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>';
|
||||
playPauseIcon.innerHTML = '<path d="M8 5v14l11-7z"/>';
|
||||
miniPlayPauseIcon.innerHTML = '<path d="M8 5v14l11-7z"/>';
|
||||
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 = '<path d="M6 6h12v12H6z"/>';
|
||||
playPauseIcon.innerHTML = '<path d="M8 5v14l11-7z"/>';
|
||||
miniPlayPauseIcon.innerHTML = '<path d="M8 5v14l11-7z"/>';
|
||||
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 = '<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"/>';
|
||||
playPauseIcon.innerHTML = '<path d="M8 5v14l11-7z"/>';
|
||||
miniPlayPauseIcon.innerHTML = '<path d="M8 5v14l11-7z"/>';
|
||||
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 = '<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>';
|
||||
const unmutedPath = '<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>';
|
||||
|
||||
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 = `<div class="scripts-empty">${t('scripts.no_scripts')}</div>`;
|
||||
grid.innerHTML = `<div class="scripts-empty empty-state-illustration"><svg viewBox="0 0 64 64"><path d="M20 8l-8 48"/><path d="M44 8l8 48"/><path d="M10 24h44"/><path d="M8 40h44"/></svg><p>${t('scripts.no_scripts')}</p></div>`;
|
||||
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 = '<tr><td colspan="5" class="empty-state">No scripts configured. Click "Add Script" to create one.</td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="empty-state"><div class="empty-state-illustration"><svg viewBox="0 0 64 64"><rect x="8" y="4" width="48" height="56" rx="4"/><path d="M20 20h24M20 32h16M20 44h20"/></svg><p>' + t('scripts.empty') + '</p></div></td></tr>';
|
||||
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 = '<tr><td colspan="4" class="empty-state">No callbacks configured. Click "Add Callback" to create one.</td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="empty-state"><div class="empty-state-illustration"><svg viewBox="0 0 64 64"><circle cx="32" cy="32" r="24"/><path d="M32 20v12l8 8"/></svg><p>' + t('callbacks.empty') + '</p></div></td></tr>';
|
||||
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 = `<div class="browser-empty" data-i18n="browser.no_items">${t('browser.no_items')}</div>`;
|
||||
container.innerHTML = `<div class="browser-empty">${emptyStateHtml(EMPTY_SVG_FILE, t('browser.no_items'))}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1774,7 +1906,7 @@ function renderBrowserGrid(items, container) {
|
||||
container.innerHTML = '';
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
container.innerHTML = `<div class="browser-empty" data-i18n="browser.no_items">${t('browser.no_items')}</div>`;
|
||||
container.innerHTML = `<div class="browser-empty">${emptyStateHtml(EMPTY_SVG_FILE, t('browser.no_items'))}</div>`;
|
||||
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 = `<div class="browser-empty" data-i18n="browser.no_folder_selected">${t('browser.no_folder_selected')}</div>`;
|
||||
grid.innerHTML = `<div class="browser-empty">${emptyStateHtml(EMPTY_SVG_FOLDER, t('browser.no_folder_selected'))}</div>`;
|
||||
document.getElementById('breadcrumb').innerHTML = '';
|
||||
document.getElementById('browserPagination').style.display = 'none';
|
||||
document.getElementById('playAllBtn').style.display = 'none';
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "Исходный код"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
7
scripts/start-hidden.vbs
Normal file
7
scripts/start-hidden.vbs
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user