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:
2026-02-23 20:38:35 +03:00
parent d1ec27cb7b
commit 84b985e6df
13 changed files with 926 additions and 348 deletions

View File

@@ -36,9 +36,7 @@ async def verify_token(
) -> str: ) -> str:
"""Verify the API token from the Authorization header. """Verify the API token from the Authorization header.
Args: Reuses the label from middleware context when already validated.
request: The incoming request
credentials: The bearer token credentials
Returns: Returns:
The token label The token label
@@ -46,6 +44,11 @@ async def verify_token(
Raises: Raises:
HTTPException: If the token is missing or invalid 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: if credentials is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
@@ -61,7 +64,6 @@ async def verify_token(
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
# Set label in context for logging
token_label_var.set(label) token_label_var.set(label)
return label return label
@@ -120,6 +122,11 @@ async def verify_token_or_query(
Raises: Raises:
HTTPException: If the token is missing or invalid 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 label = None
# Try header first # Try header first
@@ -137,6 +144,5 @@ async def verify_token_or_query(
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
# Set label in context for logging
token_label_var.set(label) token_label_var.set(label)
return label return label

View File

@@ -9,6 +9,7 @@ from pathlib import Path
import uvicorn import uvicorn
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
@@ -74,6 +75,9 @@ def create_app() -> FastAPI:
lifespan=lifespan, lifespan=lifespan,
) )
# Compress responses > 1KB
app.add_middleware(GZipMiddleware, minimum_size=1000)
# Add CORS middleware for cross-origin requests # Add CORS middleware for cross-origin requests
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,

View File

@@ -25,6 +25,28 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/browser", tags=["browser"]) 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 # Request/Response Models
class FolderCreateRequest(BaseModel): class FolderCreateRequest(BaseModel):
"""Request model for creating a media folder.""" """Request model for creating a media folder."""
@@ -412,21 +434,8 @@ async def play_file(
if not success: if not success:
raise HTTPException(status_code=500, detail="Failed to open file") raise HTTPException(status_code=500, detail="Failed to open file")
# Wait for media player to start and register with Windows Media Session API # Poll until player registers with media session API (up to 2s)
# This allows the UI to update immediately with the new playback state asyncio.create_task(_broadcast_after_open(controller, file_path.name))
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}")
return { return {
"success": True, "success": True,
@@ -476,10 +485,11 @@ async def play_folder(
# Generate M3U playlist with absolute paths and EXTINF entries # Generate M3U playlist with absolute paths and EXTINF entries
# Written to local temp dir to avoid extra SMB file handle on network shares # Written to local temp dir to avoid extra SMB file handle on network shares
# Uses utf-8-sig (BOM) so players detect encoding properly # Uses utf-8-sig (BOM) so players detect encoding properly
m3u_content = "#EXTM3U\r\n" lines = ["#EXTM3U"]
for f in media_files: for f in media_files:
m3u_content += f"#EXTINF:-1,{f.stem}\r\n" lines.append(f"#EXTINF:-1,{f.stem}")
m3u_content += f"{f}\r\n" lines.append(str(f))
m3u_content = "\r\n".join(lines) + "\r\n"
playlist_path = Path(tempfile.gettempdir()) / ".media_server_playlist.m3u" playlist_path = Path(tempfile.gettempdir()) / ".media_server_playlist.m3u"
playlist_path.write_text(m3u_content, encoding="utf-8-sig") playlist_path.write_text(m3u_content, encoding="utf-8-sig")
@@ -491,20 +501,8 @@ async def play_folder(
if not success: if not success:
raise HTTPException(status_code=500, detail="Failed to open playlist") raise HTTPException(status_code=500, detail="Failed to open playlist")
# Wait for media player to start # Poll until player registers with media session API (up to 2s)
await asyncio.sleep(1.5) asyncio.create_task(_broadcast_after_open(controller, f"playlist ({len(media_files)} files)"))
# 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}")
return { return {
"success": True, "success": True,

View File

@@ -18,31 +18,37 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/media", tags=["media"]) router = APIRouter(prefix="/api/media", tags=["media"])
async def _run_callback(callback_name: str) -> None: def _run_callback(callback_name: str) -> None:
"""Run a callback if configured. Failures are logged but don't raise.""" """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: if not settings.callbacks or callback_name not in settings.callbacks:
return return
from .scripts import _run_script async def _execute():
from .scripts import _run_script
callback = settings.callbacks[callback_name] try:
loop = asyncio.get_event_loop() callback = settings.callbacks[callback_name]
result = await loop.run_in_executor( loop = asyncio.get_event_loop()
None, result = await loop.run_in_executor(
lambda: _run_script( None,
command=callback.command, lambda: _run_script(
timeout=callback.timeout, command=callback.command,
shell=callback.shell, timeout=callback.timeout,
working_dir=callback.working_dir, shell=callback.shell,
), working_dir=callback.working_dir,
) ),
if result["exit_code"] != 0: )
logger.warning( if result["exit_code"] != 0:
"Callback %s failed with exit code %s: %s", logger.warning(
callback_name, "Callback %s failed with exit code %s: %s",
result["exit_code"], callback_name,
result["stderr"], 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) @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, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Failed to start playback - no active media session", detail="Failed to start playback - no active media session",
) )
await _run_callback("on_play") _run_callback("on_play")
return {"success": True} return {"success": True}
@@ -88,7 +94,7 @@ async def pause(_: str = Depends(verify_token)) -> dict:
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Failed to pause - no active media session", detail="Failed to pause - no active media session",
) )
await _run_callback("on_pause") _run_callback("on_pause")
return {"success": True} return {"success": True}
@@ -106,7 +112,7 @@ async def stop(_: str = Depends(verify_token)) -> dict:
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Failed to stop - no active media session", detail="Failed to stop - no active media session",
) )
await _run_callback("on_stop") _run_callback("on_stop")
return {"success": True} return {"success": True}
@@ -124,7 +130,7 @@ async def next_track(_: str = Depends(verify_token)) -> dict:
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Failed to skip - no active media session", detail="Failed to skip - no active media session",
) )
await _run_callback("on_next") _run_callback("on_next")
return {"success": True} return {"success": True}
@@ -142,7 +148,7 @@ async def previous_track(_: str = Depends(verify_token)) -> dict:
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Failed to go back - no active media session", detail="Failed to go back - no active media session",
) )
await _run_callback("on_previous") _run_callback("on_previous")
return {"success": True} return {"success": True}
@@ -165,7 +171,7 @@ async def set_volume(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Failed to set volume", detail="Failed to set volume",
) )
await _run_callback("on_volume") _run_callback("on_volume")
return {"success": True, "volume": request.volume} return {"success": True, "volume": request.volume}
@@ -178,7 +184,7 @@ async def toggle_mute(_: str = Depends(verify_token)) -> dict:
""" """
controller = get_media_controller() controller = get_media_controller()
muted = await controller.toggle_mute() muted = await controller.toggle_mute()
await _run_callback("on_mute") _run_callback("on_mute")
return {"success": True, "muted": muted} 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, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Failed to seek - no active media session or seek not supported", 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} return {"success": True, "position": request.position}
@@ -210,7 +216,7 @@ async def turn_on(_: str = Depends(verify_token)) -> dict:
Returns: Returns:
Success status Success status
""" """
await _run_callback("on_turn_on") _run_callback("on_turn_on")
return {"success": True} return {"success": True}
@@ -221,7 +227,7 @@ async def turn_off(_: str = Depends(verify_token)) -> dict:
Returns: Returns:
Success status Success status
""" """
await _run_callback("on_turn_off") _run_callback("on_turn_off")
return {"success": True} return {"success": True}
@@ -232,7 +238,7 @@ async def toggle(_: str = Depends(verify_token)) -> dict:
Returns: Returns:
Success status Success status
""" """
await _run_callback("on_toggle") _run_callback("on_toggle")
return {"success": True} return {"success": True}

View File

@@ -2,6 +2,7 @@
import logging import logging
import os import os
import stat as stat_module
import time import time
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -13,6 +14,10 @@ from ..config import settings
_dir_cache: dict[str, tuple[float, list[dict]]] = {} _dir_cache: dict[str, tuple[float, list[dict]]] = {}
DIR_CACHE_TTL = 300 # 5 minutes 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: try:
from mutagen import File as MutagenFile from mutagen import File as MutagenFile
HAS_MUTAGEN = True HAS_MUTAGEN = True
@@ -121,11 +126,14 @@ class BrowserService:
return "other" return "other"
@staticmethod @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). """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: Args:
file_path: Path to the media file. file_path: Path to the media file.
mtime: File modification time (avoids an extra stat call).
Returns: Returns:
Dict with 'duration' (float or None), 'bitrate' (int or None), Dict with 'duration' (float or None), 'bitrate' (int or None),
@@ -134,6 +142,20 @@ class BrowserService:
result = {"duration": None, "bitrate": None, "title": None} result = {"duration": None, "bitrate": None, "title": None}
if not HAS_MUTAGEN: if not HAS_MUTAGEN:
return result 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: try:
audio = MutagenFile(str(file_path), easy=True) audio = MutagenFile(str(file_path), easy=True)
if audio is not None and hasattr(audio, "info"): if audio is not None and hasattr(audio, "info"):
@@ -155,6 +177,16 @@ class BrowserService:
result["title"] = title result["title"] = title
except Exception: except Exception:
pass 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 return result
@staticmethod @staticmethod
@@ -235,8 +267,13 @@ class BrowserService:
if item.name.startswith("."): if item.name.startswith("."):
continue continue
is_dir = item.is_dir() # Single stat() call per item — reuse for type check and metadata
if is_dir: try:
st = item.stat()
except (OSError, PermissionError):
continue
if stat_module.S_ISDIR(st.st_mode):
file_type = "folder" file_type = "folder"
else: else:
suffix = item.suffix.lower() suffix = item.suffix.lower()
@@ -251,6 +288,8 @@ class BrowserService:
"name": item.name, "name": item.name,
"type": file_type, "type": file_type,
"is_media": file_type in ("audio", "video"), "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())) all_items.sort(key=lambda x: (x["type"] != "folder", x["name"].lower()))
@@ -260,19 +299,14 @@ class BrowserService:
total = len(all_items) total = len(all_items)
items = all_items[offset:offset + limit] 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: for item in items:
item_path = full_path / item["name"] item["size"] = item["_size"] if item["type"] != "folder" else None
try: item["modified"] = datetime.fromtimestamp(item["_mtime"]).isoformat()
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
if item["is_media"]: 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["duration"] = info["duration"]
item["bitrate"] = info["bitrate"] item["bitrate"] = info["bitrate"]
item["title"] = info["title"] item["title"] = info["title"]

View File

@@ -49,24 +49,27 @@ class ConnectionManager:
) )
async def broadcast(self, message: dict[str, Any]) -> None: 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: async with self._lock:
connections = list(self._active_connections) connections = list(self._active_connections)
if not connections: if not connections:
return return
disconnected = [] async def _send(ws: WebSocket) -> WebSocket | None:
for websocket in connections:
try: try:
await websocket.send_json(message) await ws.send_json(message)
return None
except Exception as e: except Exception as e:
logger.debug("Failed to send to client: %s", 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 # Clean up disconnected clients
for ws in disconnected: for ws in results:
await self.disconnect(ws) if ws is not None:
await self.disconnect(ws)
async def broadcast_scripts_changed(self) -> None: async def broadcast_scripts_changed(self) -> None:
"""Notify all connected clients that scripts have changed.""" """Notify all connected clients that scripts have changed."""
@@ -156,26 +159,25 @@ class ConnectionManager:
async with self._lock: async with self._lock:
has_clients = len(self._active_connections) > 0 has_clients = len(self._active_connections) > 0
if has_clients: if not has_clients:
status = await get_status_func() await asyncio.sleep(self._poll_interval)
status_dict = status.model_dump() continue
# Only broadcast on actual state changes status = await get_status_func()
# Let HA handle position interpolation during playback status_dict = status.model_dump()
if self.status_changed(self._last_status, status_dict):
self._last_status = status_dict # Only broadcast on actual state changes
self._last_broadcast_time = time.time() # Let HA handle position interpolation during playback
await self.broadcast( if self.status_changed(self._last_status, status_dict):
{"type": "status_update", "data": status_dict} self._last_status = status_dict
) self._last_broadcast_time = time.time()
logger.debug("Broadcast sent: status change") await self.broadcast(
else: {"type": "status_update", "data": status_dict}
# Update cached status even without broadcast )
self._last_status = status_dict logger.debug("Broadcast sent: status change")
else: else:
# Still update cache for when clients connect # Update cached status even without broadcast
status = await get_status_func() self._last_status = status_dict
self._last_status = status.model_dump()
await asyncio.sleep(self._poll_interval) await asyncio.sleep(self._poll_interval)

View File

@@ -51,6 +51,55 @@
opacity: 1; 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 */ /* Prevent scrolling when dialog is open */
body.dialog-open { body.dialog-open {
overflow: hidden; overflow: hidden;
@@ -157,6 +206,20 @@
background: var(--bg-secondary); background: var(--bg-secondary);
border-radius: 10px; border-radius: 10px;
border: 1px solid var(--border); 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 { .tab-btn {
@@ -173,21 +236,21 @@
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: color 0.2s;
width: auto; width: auto;
height: auto; height: auto;
position: relative;
z-index: 1;
} }
.tab-btn:hover { .tab-btn:hover {
color: var(--text-primary); color: var(--text-primary);
background: var(--bg-tertiary) !important;
transform: none !important; transform: none !important;
} }
.tab-btn.active { .tab-btn.active {
color: var(--accent); color: var(--accent);
background: var(--bg-tertiary); background: transparent;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
} }
.tab-btn svg { .tab-btn svg {
@@ -221,10 +284,35 @@
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); 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 { .album-art-container {
display: flex; display: flex;
justify-content: center; justify-content: center;
margin-bottom: 2rem; 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 { #album-art {
@@ -234,6 +322,38 @@
border-radius: 8px; border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
background: var(--bg-tertiary); 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 { .track-info {
@@ -293,7 +413,11 @@
border-radius: 3px; border-radius: 3px;
cursor: pointer; cursor: pointer;
position: relative; position: relative;
overflow: hidden; transition: height 0.15s ease;
}
.progress-bar:hover {
height: 8px;
} }
.progress-fill { .progress-fill {
@@ -302,6 +426,25 @@
border-radius: 3px; border-radius: 3px;
width: 0; width: 0;
transition: width 0.1s linear; 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 { .controls {
@@ -375,6 +518,12 @@
background: var(--accent); background: var(--accent);
border-radius: 50%; border-radius: 50%;
cursor: pointer; 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 { #volume-slider::-moz-range-thumb {
@@ -384,6 +533,12 @@
border-radius: 50%; border-radius: 50%;
cursor: pointer; cursor: pointer;
border: none; 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 { .volume-display {
@@ -404,6 +559,44 @@
color: var(--text-muted); color: var(--text-muted);
padding-top: 1rem; padding-top: 1rem;
border-top: 1px solid var(--border); 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 */ /* Scripts Section */
@@ -477,6 +670,7 @@
border-radius: 12px; border-radius: 12px;
padding: 2rem; padding: 2rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
overflow-x: auto;
} }
.script-management h2 { .script-management h2 {
@@ -511,6 +705,7 @@
.scripts-table { .scripts-table {
width: 100%; width: 100%;
min-width: 500px;
border-collapse: collapse; border-collapse: collapse;
font-size: 0.875rem; font-size: 0.875rem;
} }
@@ -527,6 +722,7 @@
.scripts-table td { .scripts-table td {
padding: 0.75rem; padding: 0.75rem;
word-break: break-word;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
@@ -686,6 +882,7 @@
.dialog-header { .dialog-header {
padding: 1.5rem; padding: 1.5rem;
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
@@ -784,6 +981,31 @@
font-size: 0.875rem; 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 { .toast {
position: fixed; position: fixed;
bottom: 2rem; bottom: 2rem;
@@ -792,12 +1014,14 @@
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 8px;
padding: 1rem 1.5rem; padding: 1rem 1.5rem;
padding-left: 1.25rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
opacity: 0; opacity: 0;
transform: translateY(20px); transform: translateY(20px);
transition: all 0.3s; transition: all 0.3s;
pointer-events: none; pointer-events: none;
z-index: 1000; z-index: 1000;
border-left: 4px solid var(--border);
} }
.toast.show { .toast.show {
@@ -807,10 +1031,12 @@
.toast.success { .toast.success {
border-color: var(--accent); border-color: var(--accent);
border-left-color: var(--accent);
} }
.toast.error { .toast.error {
border-color: var(--error); border-color: var(--error);
border-left-color: var(--error);
} }
/* Auth Modal */ /* Auth Modal */
@@ -933,16 +1159,36 @@
bottom: 0; bottom: 0;
left: 0; left: 0;
right: 0; right: 0;
background: var(--bg-secondary); background: rgba(30, 30, 30, 0.8);
border-top: 1px solid var(--border); -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: 0.75rem 1rem;
padding-top: calc(0.75rem + 2px);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1.5rem; gap: 1.5rem;
z-index: 1000; z-index: 1000;
transform: translateY(0); transform: translateY(0);
transition: transform 0.3s ease-in-out, opacity 0.3s ease-in-out; 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 { .mini-player.hidden {
@@ -1026,6 +1272,25 @@
border-radius: 2px; border-radius: 2px;
width: 0%; width: 0%;
transition: width 0.1s linear; 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 { .mini-controls {
@@ -1417,7 +1682,7 @@
gap: 1rem; gap: 1rem;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
min-height: 200px; min-height: 200px;
align-items: start; align-items: stretch;
} }
/* Compact Grid */ /* Compact Grid */
@@ -1683,6 +1948,7 @@
.browser-item-info { .browser-item-info {
width: 100%; width: 100%;
text-align: center; text-align: center;
margin-top: auto;
} }
.browser-item-name { .browser-item-name {
@@ -1929,4 +2195,73 @@
.browser-list-type { .browser-list-type {
display: none; 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;
}
} }

View File

@@ -63,7 +63,6 @@
<header> <header>
<div style="display: flex; align-items: center; gap: 0.5rem;"> <div style="display: flex; align-items: center; gap: 0.5rem;">
<span class="status-dot" id="status-dot"></span> <span class="status-dot" id="status-dot"></span>
<h1 data-i18n="app.title">Media Server</h1>
<span class="version-label" id="version-label"></span> <span class="version-label" id="version-label"></span>
</div> </div>
<div style="display: flex; align-items: center; gap: 1rem;"> <div style="display: flex; align-items: center; gap: 1rem;">
@@ -85,6 +84,7 @@
<!-- Tab Bar --> <!-- Tab Bar -->
<div class="tab-bar" id="tabBar"> <div class="tab-bar" id="tabBar">
<div class="tab-indicator" id="tabIndicator"></div>
<button class="tab-btn active" data-tab="player" onclick="switchTab('player')"> <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> <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> <span data-i18n="tab.player">Player</span>
@@ -108,62 +108,70 @@
</div> </div>
<div class="player-container" data-tab-content="player"> <div class="player-container" data-tab-content="player">
<div class="album-art-container"> <div class="player-layout">
<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 class="album-art-container">
</div> <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 class="track-info">
<div id="track-title" data-i18n="player.no_media">No media playing</div>
<div id="artist"></div>
<div id="album"></div>
<div class="playback-state">
<svg class="state-icon" id="state-icon" viewBox="0 0 24 24">
<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"/>
</svg>
<span id="playback-state" data-i18n="state.idle">Idle</span>
</div> </div>
</div>
<div class="progress-container"> <div class="player-details">
<div class="time-display"> <div class="track-info">
<span id="current-time">0:00</span> <div id="track-title" data-i18n="player.no_media">No media playing</div>
<span id="total-time">0:00</span> <div id="artist"></div>
<div id="album"></div>
<div class="playback-state">
<svg class="state-icon" id="state-icon" viewBox="0 0 24 24">
<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"/>
</svg>
<span id="playback-state" data-i18n="state.idle">Idle</span>
</div>
</div>
<div class="progress-container">
<div class="time-display">
<span id="current-time">0:00</span>
<span id="total-time">0:00</span>
</div>
<div class="progress-bar" id="progress-bar" data-duration="0">
<div class="progress-fill" id="progress-fill"></div>
</div>
</div>
<div class="controls">
<button onclick="previousTrack()" data-i18n-title="player.previous" title="Previous" id="btn-previous">
<svg viewBox="0 0 24 24">
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/>
</svg>
</button>
<button class="primary" onclick="togglePlayPause()" data-i18n-title="player.play" title="Play/Pause" id="btn-play-pause">
<svg viewBox="0 0 24 24" id="play-pause-icon">
<path d="M8 5v14l11-7z"/>
</svg>
</button>
<button onclick="nextTrack()" data-i18n-title="player.next" title="Next" id="btn-next">
<svg viewBox="0 0 24 24">
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/>
</svg>
</button>
</div>
<div class="volume-container">
<button class="mute-btn" onclick="toggleMute()" data-i18n-title="player.mute" title="Mute" id="btn-mute">
<svg viewBox="0 0 24 24" id="mute-icon">
<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"/>
</svg>
</button>
<input type="range" id="volume-slider" min="0" max="100" value="50">
<div class="volume-display" id="volume-display">50%</div>
</div>
<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 class="progress-bar" id="progress-bar" data-duration="0">
<div class="progress-fill" id="progress-fill"></div>
</div>
</div>
<div class="controls">
<button onclick="previousTrack()" data-i18n-title="player.previous" title="Previous" id="btn-previous">
<svg viewBox="0 0 24 24">
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/>
</svg>
</button>
<button class="primary" onclick="togglePlayPause()" data-i18n-title="player.play" title="Play/Pause" id="btn-play-pause">
<svg viewBox="0 0 24 24" id="play-pause-icon">
<path d="M8 5v14l11-7z"/>
</svg>
</button>
<button onclick="nextTrack()" data-i18n-title="player.next" title="Next" id="btn-next">
<svg viewBox="0 0 24 24">
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/>
</svg>
</button>
</div>
<div class="volume-container">
<button class="mute-btn" onclick="toggleMute()" data-i18n-title="player.mute" title="Mute" id="btn-mute">
<svg viewBox="0 0 24 24" id="mute-icon">
<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"/>
</svg>
</button>
<input type="range" id="volume-slider" min="0" max="100" value="50">
<div class="volume-display" id="volume-display">50%</div>
</div>
<div class="source-info">
<span data-i18n="player.source">Source:</span> <span id="source" data-i18n="player.unknown_source">Unknown</span>
</div> </div>
</div> </div>
@@ -218,7 +226,10 @@
<!-- File/Folder Grid --> <!-- File/Folder Grid -->
<div class="browser-grid" id="browserGrid"> <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> </div>
<!-- Pagination --> <!-- Pagination -->
@@ -237,7 +248,10 @@
<div class="scripts-container" id="scripts-container" data-tab-content="quick-actions" > <div class="scripts-container" id="scripts-container" data-tab-content="quick-actions" >
<h2 data-i18n="scripts.quick_actions">Quick Actions</h2> <h2 data-i18n="scripts.quick_actions">Quick Actions</h2>
<div class="scripts-grid" id="scripts-grid"> <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>
</div> </div>
@@ -259,7 +273,12 @@
</thead> </thead>
<tbody id="scriptsTableBody"> <tbody id="scriptsTableBody">
<tr> <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> </tr>
</tbody> </tbody>
</table> </table>
@@ -285,7 +304,12 @@
</thead> </thead>
<tbody id="callbacksTableBody"> <tbody id="callbacksTableBody">
<tr> <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> </tr>
</tbody> </tbody>
</table> </table>
@@ -392,25 +416,25 @@
<!-- Execution Result Dialog --> <!-- Execution Result Dialog -->
<dialog id="executionDialog"> <dialog id="executionDialog">
<div class="dialog-header"> <div class="dialog-header">
<h3 id="executionDialogTitle">Execution Result</h3> <h3 id="executionDialogTitle" data-i18n="scripts.execution.title">Execution Result</h3>
</div> </div>
<div class="dialog-body"> <div class="dialog-body">
<div class="execution-status" id="executionStatus"></div> <div class="execution-status" id="executionStatus"></div>
<div class="result-section" id="outputSection" style="display: none;"> <div class="result-section" id="outputSection" style="display: none;">
<h4>Output</h4> <h4 data-i18n="scripts.execution.output">Output</h4>
<div class="execution-result"> <div class="execution-result">
<pre id="executionOutput"></pre> <pre id="executionOutput"></pre>
</div> </div>
</div> </div>
<div class="result-section" id="errorSection" style="display: none;"> <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"> <div class="execution-result">
<pre id="executionError"></pre> <pre id="executionError"></pre>
</div> </div>
</div> </div>
</div> </div>
<div class="dialog-footer"> <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> </div>
</dialog> </dialog>
@@ -458,11 +482,11 @@
<!-- Footer --> <!-- Footer -->
<footer> <footer>
<div> <div>
Created by <strong>Alexei Dolgolyov</strong> <span data-i18n="footer.created_by">Created by</span> <strong>Alexei Dolgolyov</strong>
<span class="separator"></span> <span class="separator"></span>
<a href="mailto:dolgolyov.alexei@gmail.com">dolgolyov.alexei@gmail.com</a> <a href="mailto:dolgolyov.alexei@gmail.com">dolgolyov.alexei@gmail.com</a>
<span class="separator"></span> <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> </div>
</footer> </footer>

View File

@@ -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 // Tab management
let activeTab = 'player'; 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) { function switchTab(tabName) {
activeTab = tabName; activeTab = tabName;
@@ -30,7 +108,10 @@
// Update tab buttons // Update tab buttons
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active')); document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
const activeBtn = document.querySelector(`.tab-btn[data-tab="${tabName}"]`); 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 // Save to localStorage
localStorage.setItem('activeTab', tabName); localStorage.setItem('activeTab', tabName);
@@ -75,6 +156,41 @@
setTheme(newTheme); 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 // Locale management
let currentLocale = 'en'; let currentLocale = 'en';
let translations = {}; let translations = {};
@@ -240,6 +356,7 @@
let ws = null; let ws = null;
let reconnectTimeout = null; let reconnectTimeout = null;
let pingInterval = null;
let currentState = 'idle'; let currentState = 'idle';
let currentDuration = 0; let currentDuration = 0;
let currentPosition = 0; let currentPosition = 0;
@@ -247,6 +364,7 @@
let volumeUpdateTimer = null; // Timer for throttling volume updates let volumeUpdateTimer = null; // Timer for throttling volume updates
let scripts = []; let scripts = [];
let lastStatus = null; // Store last status for locale switching let lastStatus = null; // Store last status for locale switching
let lastArtworkSource = null; // Track artwork source to skip redundant loads
// Dialog dirty state tracking // Dialog dirty state tracking
let scriptFormDirty = false; let scriptFormDirty = false;
@@ -259,9 +377,15 @@
// Initialize on page load // Initialize on page load
window.addEventListener('DOMContentLoaded', async () => { window.addEventListener('DOMContentLoaded', async () => {
// Cache DOM references
cacheDom();
// Initialize theme // Initialize theme
initTheme(); initTheme();
// Initialize vinyl mode
applyVinylMode();
// Initialize locale (async - loads JSON file) // Initialize locale (async - loads JSON file)
await initLocale(); await initLocale();
@@ -278,60 +402,61 @@
showAuthForm(); showAuthForm();
} }
// Volume slider event // Shared volume slider setup (avoids duplicate handler code)
const volumeSlider = document.getElementById('volume-slider'); function setupVolumeSlider(sliderId) {
volumeSlider.addEventListener('input', (e) => { const slider = document.getElementById(sliderId);
isUserAdjustingVolume = true; slider.addEventListener('input', (e) => {
const volume = parseInt(e.target.value); isUserAdjustingVolume = true;
document.getElementById('volume-display').textContent = `${volume}%`; 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);
if (volumeUpdateTimer) { volumeUpdateTimer = setTimeout(() => {
clearTimeout(volumeUpdateTimer); setVolume(volume);
} volumeUpdateTimer = null;
volumeUpdateTimer = setTimeout(() => { }, VOLUME_THROTTLE_MS);
});
slider.addEventListener('change', (e) => {
if (volumeUpdateTimer) {
clearTimeout(volumeUpdateTimer);
volumeUpdateTimer = null;
}
const volume = parseInt(e.target.value);
setVolume(volume); setVolume(volume);
volumeUpdateTimer = null; setTimeout(() => { isUserAdjustingVolume = false; }, VOLUME_RELEASE_DELAY_MS);
}, 16); });
}); }
volumeSlider.addEventListener('change', (e) => { setupVolumeSlider('volume-slider');
// Clear any pending throttled update setupVolumeSlider('mini-volume-slider');
if (volumeUpdateTimer) {
clearTimeout(volumeUpdateTimer);
volumeUpdateTimer = null;
}
// Send final volume update immediately
const volume = parseInt(e.target.value);
setVolume(volume);
setTimeout(() => { isUserAdjustingVolume = false; }, 500);
});
// Restore saved tab // Restore saved tab
const savedTab = localStorage.getItem('activeTab') || 'player'; const savedTab = localStorage.getItem('activeTab') || 'player';
switchTab(savedTab); 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 // Mini Player: Intersection Observer to show/hide when main player scrolls out of view
const playerContainer = document.querySelector('.player-container'); const playerContainer = document.querySelector('.player-container');
const miniPlayer = document.getElementById('mini-player');
const observerOptions = { const observer = new IntersectionObserver((entries) => {
root: null, // viewport
threshold: 0.1, // trigger when 10% visible
rootMargin: '0px'
};
const observerCallback = (entries) => {
entries.forEach(entry => { entries.forEach(entry => {
// Only use scroll-based logic when on the player tab
if (activeTab !== 'player') return; if (activeTab !== 'player') return;
setMiniPlayerVisible(!entry.isIntersecting); setMiniPlayerVisible(!entry.isIntersecting);
}); });
}; }, { threshold: 0.1 });
const observer = new IntersectionObserver(observerCallback, observerOptions);
observer.observe(playerContainer); observer.observe(playerContainer);
// Mini player progress bar click to seek // Mini player progress bar click to seek
@@ -343,38 +468,6 @@
seek(position); 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 // Progress bar click to seek
const progressBar = document.getElementById('progress-bar'); const progressBar = document.getElementById('progress-bar');
progressBar.addEventListener('click', (e) => { progressBar.addEventListener('click', (e) => {
@@ -468,6 +561,12 @@
} }
function connectWebSocket(token) { 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 protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/media/ws?token=${encodeURIComponent(token)}`; const wsUrl = `${protocol}//${window.location.host}/api/media/ws?token=${encodeURIComponent(token)}`;
@@ -518,25 +617,23 @@
console.log('Attempting to reconnect...'); console.log('Attempting to reconnect...');
connectWebSocket(savedToken); connectWebSocket(savedToken);
} }
}, 3000); }, WS_RECONNECT_MS);
} }
}; };
// Send keepalive ping every 30 seconds // Send keepalive ping
setInterval(() => { pingInterval = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) { if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' })); ws.send(JSON.stringify({ type: 'ping' }));
} }
}, 30000); }, WS_PING_INTERVAL_MS);
} }
function updateConnectionStatus(connected) { function updateConnectionStatus(connected) {
const dot = document.getElementById('status-dot');
if (connected) { if (connected) {
dot.classList.add('connected'); dom.statusDot.classList.add('connected');
} else { } else {
dot.classList.remove('connected'); dom.statusDot.classList.remove('connected');
} }
} }
@@ -546,28 +643,35 @@
// Update track info // Update track info
const fallbackTitle = status.state === 'idle' ? t('player.no_media') : t('player.title_unavailable'); const fallbackTitle = status.state === 'idle' ? t('player.no_media') : t('player.title_unavailable');
document.getElementById('track-title').textContent = status.title || fallbackTitle; dom.trackTitle.textContent = status.title || fallbackTitle;
document.getElementById('artist').textContent = status.artist || ''; dom.artist.textContent = status.artist || '';
document.getElementById('album').textContent = status.album || ''; dom.album.textContent = status.album || '';
// Update mini player info // Update mini player info
document.getElementById('mini-track-title').textContent = status.title || fallbackTitle; dom.miniTrackTitle.textContent = status.title || fallbackTitle;
document.getElementById('mini-artist').textContent = status.artist || ''; dom.miniArtist.textContent = status.artist || '';
// Update state // Update state
const previousState = currentState; const previousState = currentState;
currentState = status.state; currentState = status.state;
updatePlaybackState(status.state); updatePlaybackState(status.state);
// Update album art // Update album art (skip if same source to avoid redundant network requests)
const artImg = document.getElementById('album-art'); const artworkSource = status.album_art_url || null;
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";
artImg.src = artworkUrl; if (artworkSource !== lastArtworkSource) {
miniArtImg.src = artworkUrl; 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 // Update progress
if (status.duration && status.position !== null) { if (status.duration && status.position !== null) {
@@ -583,24 +687,24 @@
// Update volume // Update volume
if (!isUserAdjustingVolume) { if (!isUserAdjustingVolume) {
document.getElementById('volume-slider').value = status.volume; dom.volumeSlider.value = status.volume;
document.getElementById('volume-display').textContent = `${status.volume}%`; dom.volumeDisplay.textContent = `${status.volume}%`;
document.getElementById('mini-volume-slider').value = status.volume; dom.miniVolumeSlider.value = status.volume;
document.getElementById('mini-volume-display').textContent = `${status.volume}%`; dom.miniVolumeDisplay.textContent = `${status.volume}%`;
} }
// Update mute state // Update mute state
updateMuteIcon(status.muted); updateMuteIcon(status.muted);
// Update source // 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 // Enable/disable controls based on state
const hasMedia = status.state !== 'idle'; const hasMedia = status.state !== 'idle';
document.getElementById('btn-play-pause').disabled = !hasMedia; dom.btnPlayPause.disabled = !hasMedia;
document.getElementById('btn-next').disabled = !hasMedia; dom.btnNext.disabled = !hasMedia;
document.getElementById('btn-previous').disabled = !hasMedia; dom.btnPrevious.disabled = !hasMedia;
document.getElementById('mini-btn-play-pause').disabled = !hasMedia; dom.miniBtnPlayPause.disabled = !hasMedia;
// Start/stop position interpolation based on playback state // Start/stop position interpolation based on playback state
if (status.state === 'playing' && previousState !== 'playing') { if (status.state === 'playing' && previousState !== 'playing') {
@@ -611,49 +715,50 @@
} }
function updatePlaybackState(state) { function updatePlaybackState(state) {
const stateText = document.getElementById('playback-state'); currentPlayState = state;
const stateIcon = document.getElementById('state-icon');
const playPauseIcon = document.getElementById('play-pause-icon');
const miniPlayPauseIcon = document.getElementById('mini-play-pause-icon');
switch(state) { switch(state) {
case 'playing': case 'playing':
stateText.textContent = t('state.playing'); dom.playbackState.textContent = t('state.playing');
stateIcon.innerHTML = '<path d="M8 5v14l11-7z"/>'; dom.stateIcon.innerHTML = SVG_PLAY;
playPauseIcon.innerHTML = '<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>'; dom.playPauseIcon.innerHTML = SVG_PAUSE;
miniPlayPauseIcon.innerHTML = '<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>'; dom.miniPlayPauseIcon.innerHTML = SVG_PAUSE;
break; break;
case 'paused': case 'paused':
stateText.textContent = t('state.paused'); dom.playbackState.textContent = t('state.paused');
stateIcon.innerHTML = '<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>'; dom.stateIcon.innerHTML = SVG_PAUSE;
playPauseIcon.innerHTML = '<path d="M8 5v14l11-7z"/>'; dom.playPauseIcon.innerHTML = SVG_PLAY;
miniPlayPauseIcon.innerHTML = '<path d="M8 5v14l11-7z"/>'; dom.miniPlayPauseIcon.innerHTML = SVG_PLAY;
break; break;
case 'stopped': case 'stopped':
stateText.textContent = t('state.stopped'); dom.playbackState.textContent = t('state.stopped');
stateIcon.innerHTML = '<path d="M6 6h12v12H6z"/>'; dom.stateIcon.innerHTML = SVG_STOP;
playPauseIcon.innerHTML = '<path d="M8 5v14l11-7z"/>'; dom.playPauseIcon.innerHTML = SVG_PLAY;
miniPlayPauseIcon.innerHTML = '<path d="M8 5v14l11-7z"/>'; dom.miniPlayPauseIcon.innerHTML = SVG_PLAY;
break; break;
default: default:
stateText.textContent = t('state.idle'); dom.playbackState.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"/>'; dom.stateIcon.innerHTML = SVG_IDLE;
playPauseIcon.innerHTML = '<path d="M8 5v14l11-7z"/>'; dom.playPauseIcon.innerHTML = SVG_PLAY;
miniPlayPauseIcon.innerHTML = '<path d="M8 5v14l11-7z"/>'; dom.miniPlayPauseIcon.innerHTML = SVG_PLAY;
} }
updateVinylSpin();
} }
function updateProgress(position, duration) { function updateProgress(position, duration) {
const percent = (position / duration) * 100; const percent = (position / duration) * 100;
document.getElementById('progress-fill').style.width = `${percent}%`; const widthStr = `${percent}%`;
document.getElementById('current-time').textContent = formatTime(position); const currentStr = formatTime(position);
document.getElementById('total-time').textContent = formatTime(duration); const totalStr = formatTime(duration);
document.getElementById('progress-bar').dataset.duration = duration;
// Update mini player progress dom.progressFill.style.width = widthStr;
document.getElementById('mini-progress-fill').style.width = `${percent}%`; dom.currentTime.textContent = currentStr;
document.getElementById('mini-current-time').textContent = formatTime(position); dom.totalTime.textContent = totalStr;
document.getElementById('mini-total-time').textContent = formatTime(duration); 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() { function startPositionInterpolation() {
@@ -665,14 +770,11 @@
// Update position every 100ms for smooth animation // Update position every 100ms for smooth animation
interpolationInterval = setInterval(() => { interpolationInterval = setInterval(() => {
if (currentState === 'playing' && currentDuration > 0 && lastPositionUpdate > 0) { if (currentState === 'playing' && currentDuration > 0 && lastPositionUpdate > 0) {
// Calculate elapsed time since last position update
const elapsed = (Date.now() - lastPositionUpdate) / 1000; const elapsed = (Date.now() - lastPositionUpdate) / 1000;
const interpolatedPosition = Math.min(lastPositionValue + elapsed, currentDuration); const interpolatedPosition = Math.min(lastPositionValue + elapsed, currentDuration);
// Update UI with interpolated position
updateProgress(interpolatedPosition, currentDuration); updateProgress(interpolatedPosition, currentDuration);
} }
}, 100); }, POSITION_INTERPOLATION_MS);
} }
function stopPositionInterpolation() { function stopPositionInterpolation() {
@@ -683,18 +785,9 @@
} }
function updateMuteIcon(muted) { function updateMuteIcon(muted) {
const muteIcon = document.getElementById('mute-icon'); const path = muted ? SVG_MUTED : SVG_UNMUTED;
const miniMuteIcon = document.getElementById('mini-mute-icon'); dom.muteIcon.innerHTML = path;
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"/>'; dom.miniMuteIcon.innerHTML = path;
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;
}
} }
function formatTime(seconds) { function formatTime(seconds) {
@@ -723,10 +816,13 @@
try { try {
const response = await fetch(`/api/media/${endpoint}`, options); const response = await fetch(`/api/media/${endpoint}`, options);
if (!response.ok) { if (!response.ok) {
const data = await response.json().catch(() => ({}));
console.error(`Command ${endpoint} failed:`, response.status); console.error(`Command ${endpoint} failed:`, response.status);
showToast(data.detail || `Command failed: ${endpoint}`, 'error');
} }
} catch (error) { } catch (error) {
console.error(`Error sending command ${endpoint}:`, error); console.error(`Error sending command ${endpoint}:`, error);
showToast(`Connection error: ${endpoint}`, 'error');
} }
} }
@@ -746,7 +842,10 @@
sendCommand('previous'); sendCommand('previous');
} }
let lastSentVolume = -1;
function setVolume(volume) { function setVolume(volume) {
if (volume === lastSentVolume) return;
lastSentVolume = volume;
// Use WebSocket for low-latency volume updates // Use WebSocket for low-latency volume updates
if (ws && ws.readyState === WebSocket.OPEN) { if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'volume', volume: volume })); ws.send(JSON.stringify({ type: 'volume', volume: volume }));
@@ -788,7 +887,7 @@
const grid = document.getElementById('scripts-grid'); const grid = document.getElementById('scripts-grid');
if (scripts.length === 0) { 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; return;
} }
@@ -855,12 +954,20 @@
setTimeout(() => { setTimeout(() => {
toast.classList.remove('show'); toast.classList.remove('show');
}, 3000); }, TOAST_DURATION_MS);
} }
// Script Management Functions // Script Management Functions
let _loadScriptsPromise = null;
async function loadScriptsTable() { 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 token = localStorage.getItem('media_server_token');
const tbody = document.getElementById('scriptsTableBody'); const tbody = document.getElementById('scriptsTableBody');
@@ -876,7 +983,7 @@
const scriptsList = await response.json(); const scriptsList = await response.json();
if (scriptsList.length === 0) { 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; return;
} }
@@ -997,6 +1104,9 @@
async function saveScript(event) { async function saveScript(event) {
event.preventDefault(); event.preventDefault();
const submitBtn = event.target.querySelector('button[type="submit"]');
if (submitBtn) submitBtn.disabled = true;
const token = localStorage.getItem('media_server_token'); const token = localStorage.getItem('media_server_token');
const isEdit = document.getElementById('scriptIsEdit').value === 'true'; const isEdit = document.getElementById('scriptIsEdit').value === 'true';
const scriptName = isEdit ? const scriptName = isEdit ?
@@ -1032,15 +1142,16 @@
if (response.ok && result.success) { if (response.ok && result.success) {
showToast(`Script ${isEdit ? 'updated' : 'created'} successfully`, 'success'); showToast(`Script ${isEdit ? 'updated' : 'created'} successfully`, 'success');
scriptFormDirty = false; // Reset dirty state before closing scriptFormDirty = false;
closeScriptDialog(); closeScriptDialog();
// Don't reload manually - WebSocket will trigger it
} else { } else {
showToast(result.detail || `Failed to ${isEdit ? 'update' : 'create'} script`, 'error'); showToast(result.detail || `Failed to ${isEdit ? 'update' : 'create'} script`, 'error');
} }
} catch (error) { } catch (error) {
console.error('Error saving script:', error); console.error('Error saving script:', error);
showToast(`Error ${isEdit ? 'updating' : 'creating'} script`, 'error'); showToast(`Error ${isEdit ? 'updating' : 'creating'} script`, 'error');
} finally {
if (submitBtn) submitBtn.disabled = false;
} }
} }
@@ -1075,7 +1186,15 @@
// Callback Management Functions // Callback Management Functions
let _loadCallbacksPromise = null;
async function loadCallbacksTable() { 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 token = localStorage.getItem('media_server_token');
const tbody = document.getElementById('callbacksTableBody'); const tbody = document.getElementById('callbacksTableBody');
@@ -1091,7 +1210,7 @@
const callbacksList = await response.json(); const callbacksList = await response.json();
if (callbacksList.length === 0) { 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; return;
} }
@@ -1201,6 +1320,9 @@
async function saveCallback(event) { async function saveCallback(event) {
event.preventDefault(); event.preventDefault();
const submitBtn = event.target.querySelector('button[type="submit"]');
if (submitBtn) submitBtn.disabled = true;
const token = localStorage.getItem('media_server_token'); const token = localStorage.getItem('media_server_token');
const isEdit = document.getElementById('callbackIsEdit').value === 'true'; const isEdit = document.getElementById('callbackIsEdit').value === 'true';
const callbackName = document.getElementById('callbackName').value; const callbackName = document.getElementById('callbackName').value;
@@ -1232,7 +1354,7 @@
if (response.ok && result.success) { if (response.ok && result.success) {
showToast(`Callback ${isEdit ? 'updated' : 'created'} successfully`, 'success'); showToast(`Callback ${isEdit ? 'updated' : 'created'} successfully`, 'success');
callbackFormDirty = false; // Reset dirty state before closing callbackFormDirty = false;
closeCallbackDialog(); closeCallbackDialog();
loadCallbacksTable(); loadCallbacksTable();
} else { } else {
@@ -1241,6 +1363,8 @@
} catch (error) { } catch (error) {
console.error('Error saving callback:', error); console.error('Error saving callback:', error);
showToast(`Error ${isEdit ? 'updating' : 'creating'} 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 // Render folders as grid cards
const container = document.getElementById('browserGrid'); const container = document.getElementById('browserGrid');
revokeBlobUrls(container);
if (viewMode === 'list') { if (viewMode === 'list') {
container.className = 'browser-list'; container.className = 'browser-list';
} else if (viewMode === 'compact') { } 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) { function renderBrowserItems(items) {
const container = document.getElementById('browserGrid'); const container = document.getElementById('browserGrid');
revokeBlobUrls(container);
// Switch container class based on view mode // Switch container class based on view mode
if (viewMode === 'list') { if (viewMode === 'list') {
container.className = 'browser-list'; container.className = 'browser-list';
@@ -1681,7 +1813,7 @@ function renderBrowserList(items, container) {
container.innerHTML = ''; container.innerHTML = '';
if (!items || items.length === 0) { 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; return;
} }
@@ -1774,7 +1906,7 @@ function renderBrowserGrid(items, container) {
container.innerHTML = ''; container.innerHTML = '';
if (!items || items.length === 0) { 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; return;
} }
@@ -1943,6 +2075,10 @@ async function loadThumbnail(imgElement, fileName) {
imgElement.classList.add('loaded'); imgElement.classList.add('loaded');
}; };
// Revoke previous blob URL if any
if (imgElement.src && imgElement.src.startsWith('blob:')) {
URL.revokeObjectURL(imgElement.src);
}
imgElement.src = url; imgElement.src = url;
} else { } else {
// Fallback to icon (204 = no thumbnail available) // Fallback to icon (204 = no thumbnail available)
@@ -1964,7 +2100,11 @@ async function loadThumbnail(imgElement, fileName) {
} }
} }
let playInProgress = false;
async function playMediaFile(fileName) { async function playMediaFile(fileName) {
if (playInProgress) return;
playInProgress = true;
try { try {
const token = localStorage.getItem('media_server_token'); const token = localStorage.getItem('media_server_token');
if (!token) { if (!token) {
@@ -1988,15 +2128,20 @@ async function playMediaFile(fileName) {
if (!response.ok) throw new Error('Failed to play file'); if (!response.ok) throw new Error('Failed to play file');
const data = await response.json();
showToast(t('browser.play_success', { filename: fileName }), 'success'); showToast(t('browser.play_success', { filename: fileName }), 'success');
} catch (error) { } catch (error) {
console.error('Error playing file:', error); console.error('Error playing file:', error);
showToast(t('browser.play_error'), 'error'); showToast(t('browser.play_error'), 'error');
} finally {
playInProgress = false;
} }
} }
async function playAllFolder() { async function playAllFolder() {
if (playInProgress) return;
playInProgress = true;
const btn = document.getElementById('playAllBtn');
if (btn) btn.disabled = true;
try { try {
const token = localStorage.getItem('media_server_token'); const token = localStorage.getItem('media_server_token');
if (!token || !currentFolderId) return; if (!token || !currentFolderId) return;
@@ -2020,6 +2165,9 @@ async function playAllFolder() {
} catch (error) { } catch (error) {
console.error('Error playing folder:', error); console.error('Error playing folder:', error);
showToast(t('browser.play_all_error'), 'error'); showToast(t('browser.play_all_error'), 'error');
} finally {
playInProgress = false;
if (btn) btn.disabled = false;
} }
} }
@@ -2108,7 +2256,7 @@ function onBrowserSearch() {
browserSearchTimer = setTimeout(() => { browserSearchTimer = setTimeout(() => {
browserSearchTerm = term.toLowerCase(); browserSearchTerm = term.toLowerCase();
applyBrowserSearch(); applyBrowserSearch();
}, 200); }, SEARCH_DEBOUNCE_MS);
} }
function clearBrowserSearch() { function clearBrowserSearch() {
@@ -2206,7 +2354,7 @@ function initBrowserToolbar() {
function clearBrowserGrid() { function clearBrowserGrid() {
const grid = document.getElementById('browserGrid'); 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('breadcrumb').innerHTML = '';
document.getElementById('browserPagination').style.display = 'none'; document.getElementById('browserPagination').style.display = 'none';
document.getElementById('playAllBtn').style.display = 'none'; document.getElementById('playAllBtn').style.display = 'none';

View File

@@ -21,6 +21,7 @@
"player.title_unavailable": "Title unavailable", "player.title_unavailable": "Title unavailable",
"player.source": "Source:", "player.source": "Source:",
"player.unknown_source": "Unknown", "player.unknown_source": "Unknown",
"player.vinyl": "Vinyl mode",
"state.playing": "Playing", "state.playing": "Playing",
"state.paused": "Paused", "state.paused": "Paused",
"state.stopped": "Stopped", "state.stopped": "Stopped",
@@ -65,6 +66,10 @@
"scripts.msg.load_failed": "Failed to load script details", "scripts.msg.load_failed": "Failed to load script details",
"scripts.msg.list_failed": "Failed to load scripts", "scripts.msg.list_failed": "Failed to load scripts",
"scripts.confirm.delete": "Are you sure you want to delete the script \"{name}\"?", "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?", "scripts.confirm.unsaved": "You have unsaved changes. Are you sure you want to discard them?",
"callbacks.management": "Callback Management", "callbacks.management": "Callback Management",
"callbacks.description": "Callbacks are scripts triggered automatically by media control events (play, pause, stop, etc.)", "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.path_help": "Absolute path to media directory",
"browser.folder_dialog.enabled": "Enabled", "browser.folder_dialog.enabled": "Enabled",
"browser.folder_dialog.cancel": "Cancel", "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"
} }

View File

@@ -21,6 +21,7 @@
"player.title_unavailable": "Название недоступно", "player.title_unavailable": "Название недоступно",
"player.source": "Источник:", "player.source": "Источник:",
"player.unknown_source": "Неизвестно", "player.unknown_source": "Неизвестно",
"player.vinyl": "Режим винила",
"state.playing": "Воспроизведение", "state.playing": "Воспроизведение",
"state.paused": "Пауза", "state.paused": "Пауза",
"state.stopped": "Остановлено", "state.stopped": "Остановлено",
@@ -65,6 +66,10 @@
"scripts.msg.load_failed": "Не удалось загрузить данные скрипта", "scripts.msg.load_failed": "Не удалось загрузить данные скрипта",
"scripts.msg.list_failed": "Не удалось загрузить скрипты", "scripts.msg.list_failed": "Не удалось загрузить скрипты",
"scripts.confirm.delete": "Вы уверены, что хотите удалить скрипт \"{name}\"?", "scripts.confirm.delete": "Вы уверены, что хотите удалить скрипт \"{name}\"?",
"scripts.execution.title": "Результат выполнения",
"scripts.execution.output": "Вывод",
"scripts.execution.error_output": "Вывод ошибок",
"scripts.execution.close": "Закрыть",
"scripts.confirm.unsaved": "У вас есть несохраненные изменения. Вы уверены, что хотите отменить их?", "scripts.confirm.unsaved": "У вас есть несохраненные изменения. Вы уверены, что хотите отменить их?",
"callbacks.management": "Управление Обратными Вызовами", "callbacks.management": "Управление Обратными Вызовами",
"callbacks.description": "Обратные вызовы - это скрипты, автоматически запускаемые при событиях управления медиа (воспроизведение, пауза, остановка и т.д.)", "callbacks.description": "Обратные вызовы - это скрипты, автоматически запускаемые при событиях управления медиа (воспроизведение, пауза, остановка и т.д.)",
@@ -148,5 +153,7 @@
"browser.folder_dialog.path_help": "Абсолютный путь к медиа каталогу", "browser.folder_dialog.path_help": "Абсолютный путь к медиа каталогу",
"browser.folder_dialog.enabled": "Включено", "browser.folder_dialog.enabled": "Включено",
"browser.folder_dialog.cancel": "Отмена", "browser.folder_dialog.cancel": "Отмена",
"browser.folder_dialog.save": "Сохранить" "browser.folder_dialog.save": "Сохранить",
"footer.created_by": "Создано",
"footer.source_code": "Исходный код"
} }

View File

@@ -2,17 +2,17 @@ Unregister-ScheduledTask -TaskName "MediaServer" -Confirm:$false -ErrorAction Si
# Get the media-server directory (parent of scripts folder) # Get the media-server directory (parent of scripts folder)
$serverRoot = (Get-Item $PSScriptRoot).Parent.FullName $serverRoot = (Get-Item $PSScriptRoot).Parent.FullName
$vbsPath = Join-Path $PSScriptRoot "start-hidden.vbs"
# Find Python executable if (-not (Test-Path $vbsPath)) {
$pythonPath = (Get-Command python -ErrorAction SilentlyContinue).Source Write-Error "start-hidden.vbs not found in scripts folder."
if (-not $pythonPath) {
Write-Error "Python not found in PATH. Please ensure Python is installed and accessible."
exit 1 exit 1
} }
$action = New-ScheduledTaskAction -Execute $pythonPath -Argument "-m media_server.main" -WorkingDirectory $serverRoot # Launch via wscript + VBS to run python completely hidden (no console window)
$trigger = New-ScheduledTaskTrigger -AtStartup $action = New-ScheduledTaskAction -Execute "wscript.exe" -Argument "`"$vbsPath`"" -WorkingDirectory $serverRoot
$principal = New-ScheduledTaskPrincipal -UserId "$env:USERNAME" -LogonType S4U -RunLevel Highest $trigger = New-ScheduledTaskTrigger -AtLogon -User "$env:USERNAME"
$principal = New-ScheduledTaskPrincipal -UserId "$env:USERNAME" -LogonType Interactive -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable
Register-ScheduledTask -TaskName "MediaServer" -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Description "Media Server for Home Assistant" 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
View 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