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:
"""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

View File

@@ -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,

View File

@@ -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,

View File

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

View File

@@ -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"]

View File

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

View File

@@ -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;
}
}

View File

@@ -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,62 +108,70 @@
</div>
<div class="player-container" data-tab-content="player">
<div class="album-art-container">
<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="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 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>
<div class="progress-container">
<div class="time-display">
<span id="current-time">0:00</span>
<span id="total-time">0:00</span>
<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>
<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 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>
@@ -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>

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

View File

@@ -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"
}

View File

@@ -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": "Исходный код"
}

View File

@@ -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
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