Compare commits

..

11 Commits

Author SHA1 Message Date
be48318212 Add dynamic WebGL background with audio reactivity
- WebGL shader background with flowing waves, radial pulse, and frequency ring arcs
- Reacts to captured audio data (frequency bands + bass) when visualizer is active
- Uses page accent color; adapts to dark/light theme via bg-primary blending
- Toggle button in header toolbar, state persisted in localStorage
- Cached uniform locations and color values to avoid per-frame getComputedStyle calls
- i18n support for EN/RU locales

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 01:07:46 +03:00
0eca8292cb Fix loopback device status showing 'Unavailable' after change
The POST /visualizer/device response has 'success' but no 'available'
field, causing updateAudioDeviceStatus to always fall to 'Unavailable'.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:17:13 +03:00
3cfc437599 Add UI animations: dialogs, tabs, settings, browser stagger, banner pulse
- Dialog modals: scale+fade entrance/exit with animated backdrop
- Tab panels: fade-in with subtle slide on switch
- Settings sections: content slide-down on expand
- Browser grid/list items: staggered cascade entrance animation
- Connection banner: slide-in + attention pulse on disconnect
- Accessibility: prefers-reduced-motion disables all animations

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:45:02 +03:00
a20812ec29 Add PWA support: installable standalone app with safe area handling
- Service worker, manifest, and SVG icon for PWA installability
- Root /sw.js route for full-scope service worker registration
- Meta tags: theme-color, apple-mobile-web-app, viewport-fit=cover
- Safe area insets for notched phones (container, mini-player, footer, banner)
- Dynamic theme-color sync on light/dark toggle
- Overscroll prevention and touch-action optimization
- Hide mini-player prev/next buttons on small screens
- Updated README with PWA and new feature documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 13:17:56 +03:00
652f10fc4c Reduce visualizer latency, tighten UI paddings, fix mobile browser toolbar
- Visualizer: FPS 25→30, chunk_size 2048→1024, smoothing 0.65→0.15
- Beat effect: scale 0.03→0.04, glow range 0.5-0.8→0.4-0.8
- UI: reduce container/section paddings from 2rem to 1rem
- Source name: add ellipsis overflow for long names
- Mobile browser toolbar: use flex-wrap instead of column stack,
  hide "Items per page" label text on small screens

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 12:35:23 +03:00
3846610042 On-demand audio visualizer capture + UI fixes
- Audio capture starts only when first client subscribes,
  stops when last client unsubscribes (saves CPU/battery)
- Add lifecycle lock to AudioAnalyzer for thread-safe start/stop
- Status badge uses local visualizer state instead of server flag
- Fix script name vertical text break on narrow screens
- Fix script grid minimum column width on small viewports

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:34:17 +03:00
92d6709d58 Refactor monolithic app.js into 8 modular files
Split 3803-line app.js into focused modules:
- core.js: shared state, utilities, i18n, API commands, MDI icons
- player.js: tabs, theme, accent, vinyl, visualizer, UI updates
- websocket.js: connection, auth, reconnection
- scripts.js: scripts CRUD, quick access, execution dialog
- callbacks.js: callbacks CRUD
- browser.js: media file browser, thumbnails, pagination, search
- links.js: links CRUD, header links, display controls
- main.js: DOMContentLoaded init orchestrator

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:34:01 +03:00
9404b37f05 Codebase audit fixes: stability, performance, accessibility
- Fix CORS: set allow_credentials=False (token auth, not cookies)
- Add threading.Lock for position cache thread safety
- Add shutdown_executor() for clean ThreadPoolExecutor cleanup
- Dedicated ThreadPoolExecutors for script/callback execution
- Fix Mutagen file handle leaks with try/finally close
- Reduce idle WebSocket polling (0.5s → 2.0s when no clients)
- Add :focus-visible styles for playback control buttons
- Add aria-label to icon-only header buttons
- Dynamic album art alt text for screen readers
- Persist MDI icon cache to localStorage

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 12:10:24 +03:00
73a6f387e1 Add friendly media source names with brand icons
- Registry of 17 popular media apps (browsers, players, streaming)
- Substring matching resolves raw process names to friendly names
- Brand-colored SVG icons displayed inline next to source name
- Russian locale support for Yandex Music (Яндекс Музыка)
- Unknown sources fall back to .exe-stripped name

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 11:03:46 +03:00
b11edc25b9 Redesign header as pill-shaped toolbar group
- Unified header-toolbar container with border and rounded corners
- Consistent header-btn styling for all action buttons
- Compact locale select, separator before logout icon
- Header links integrate as part of the toolbar with divider

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 00:01:55 +03:00
3d01d98da0 Style audio device select, hide mini player volume on tablet
- Native select with explicit font stack and focus glow
- Hide mini player volume section below 900px

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 22:10:28 +03:00
27 changed files with 4810 additions and 4052 deletions

View File

@@ -5,7 +5,10 @@ A REST API server for controlling system media playback on Windows, Linux, macOS
## Features ## Features
- **Built-in Web UI** for real-time media control and monitoring - **Built-in Web UI** for real-time media control and monitoring
- **Installable PWA** - Add to home screen on mobile for a native app experience
- **Audio Visualizer** - Real-time spectrum analyzer with beat-reactive album art effects
- **Media Browser** - Browse and play media files from configured folders - **Media Browser** - Browse and play media files from configured folders
- **Display Control** - Monitor brightness and power management
- **Quick Actions & Scripts** - Execute custom scripts with one click - **Quick Actions & Scripts** - Execute custom scripts with one click
- **Callbacks** - Trigger commands on media events (play, pause, volume, etc.) - **Callbacks** - Trigger commands on media events (play, pause, volume, etc.)
- Control any media player via system-wide media transport controls - Control any media player via system-wide media transport controls
@@ -36,10 +39,13 @@ The media server includes a built-in web interface for controlling and monitorin
- **Mini player** - Sticky compact player that appears when scrolling away from the main player - **Mini player** - Sticky compact player that appears when scrolling away from the main player
- **Connection status indicator** - Know when you're connected - **Connection status indicator** - Know when you're connected
- **Token authentication** - Saved in browser localStorage - **Token authentication** - Saved in browser localStorage
- **Responsive design** - Works on desktop and mobile - **Audio spectrum visualizer** - Real-time frequency bars with beat-reactive album art scaling and glow (on-demand WASAPI loopback capture)
- **Dark and light themes** - Toggle between dark and light modes - **Display control** - Monitor brightness adjustment and power on/off
- **Accent color picker** - Choose from 9 preset accent colors (green, blue, purple, pink, orange, red, teal, cyan, yellow) - **Installable PWA** - Add to home screen on mobile/desktop for standalone app experience with safe area support for notched phones
- **Tab-based navigation** - Player, Browser, Quick Actions, Scripts, and Callbacks tabs - **Responsive design** - Works on desktop, tablet, and mobile
- **Dark and light themes** - Toggle between dark and light modes with dynamic status bar theming
- **Accent color picker** - Choose from 9 preset accent colors or pick a custom color
- **Tab-based navigation** - Player, Display, Browser, Quick Actions, and Settings tabs
- **Multi-language support** - English and Russian locales with automatic detection - **Multi-language support** - English and Russian locales with automatic detection
### Accessing the Web UI ### Accessing the Web UI
@@ -58,6 +64,29 @@ The media server includes a built-in web interface for controlling and monitorin
4. Start playing media in any supported player and watch the UI update in real-time! 4. Start playing media in any supported player and watch the UI update in real-time!
### Installing as a PWA
The Web UI can be installed as a Progressive Web App for a native app-like experience:
1. Open the Web UI in Chrome/Edge on your phone or desktop
2. Tap the **Install** icon in the address bar (or "Add to Home Screen" on mobile)
3. The app launches in standalone mode — no browser chrome, with proper safe area handling for notched phones
### Audio Visualizer
The Web UI includes a real-time audio spectrum visualizer that captures system audio output:
- **On-demand capture** - Audio capture starts only when a client enables the visualizer, and stops when the last client disconnects
- **Beat-reactive effects** - Album art pulses and glows in response to bass frequencies
- **Configurable device** - Select which audio output device to capture in Settings
Requires `soundcard` and `numpy` Python packages. Enable in `config.yaml`:
```yaml
visualizer_enabled: true
# visualizer_device: "Speakers" # optional: specific device name
```
### Remote Access ### Remote Access
To access the Web UI from other devices on your network: To access the Web UI from other devices on your network:

View File

@@ -118,7 +118,7 @@ class Settings(BaseSettings):
description="Enable audio spectrum visualizer (requires soundcard + numpy)", description="Enable audio spectrum visualizer (requires soundcard + numpy)",
) )
visualizer_fps: int = Field( visualizer_fps: int = Field(
default=25, default=30,
description="Visualizer update rate in frames per second", description="Visualizer update rate in frames per second",
ge=10, ge=10,
le=60, le=60,

View File

@@ -59,7 +59,7 @@ async def lifespan(app: FastAPI):
await ws_manager.start_status_monitor(controller.get_status) await ws_manager.start_status_monitor(controller.get_status)
logger.info("WebSocket status monitor started") logger.info("WebSocket status monitor started")
# Start audio visualizer (if enabled and dependencies available) # Register audio visualizer (capture starts on-demand when clients subscribe)
analyzer = None analyzer = None
if settings.visualizer_enabled: if settings.visualizer_enabled:
from .services.audio_analyzer import get_audio_analyzer from .services.audio_analyzer import get_audio_analyzer
@@ -70,11 +70,8 @@ async def lifespan(app: FastAPI):
device_name=settings.visualizer_device, device_name=settings.visualizer_device,
) )
if analyzer.available: if analyzer.available:
if analyzer.start():
await ws_manager.start_audio_monitor(analyzer) await ws_manager.start_audio_monitor(analyzer)
logger.info("Audio visualizer started") logger.info("Audio visualizer available (capture on-demand)")
else:
logger.warning("Audio visualizer failed to start (no loopback device?)")
else: else:
logger.info("Audio visualizer unavailable (install soundcard + numpy)") logger.info("Audio visualizer unavailable (install soundcard + numpy)")
@@ -87,6 +84,13 @@ async def lifespan(app: FastAPI):
# Stop WebSocket status monitor # Stop WebSocket status monitor
await ws_manager.stop_status_monitor() await ws_manager.stop_status_monitor()
# Clean up platform-specific resources
import platform as _platform
if _platform.system() == "Windows":
from .services.windows_media import shutdown_executor
shutdown_executor()
logger.info("Media Server shutting down") logger.info("Media Server shutting down")
@@ -103,10 +107,11 @@ def create_app() -> FastAPI:
app.add_middleware(GZipMiddleware, minimum_size=1000) app.add_middleware(GZipMiddleware, minimum_size=1000)
# Add CORS middleware for cross-origin requests # Add CORS middleware for cross-origin requests
# Token auth is via Authorization header, not cookies, so credentials are not needed
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=["*"],
allow_credentials=True, allow_credentials=False,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )
@@ -149,6 +154,15 @@ def create_app() -> FastAPI:
# Mount static files and serve UI at root # Mount static files and serve UI at root
static_dir = Path(__file__).parent / "static" static_dir = Path(__file__).parent / "static"
if static_dir.exists(): if static_dir.exists():
@app.get("/sw.js", include_in_schema=False)
async def serve_service_worker():
"""Serve service worker from root scope for PWA installability."""
return FileResponse(
static_dir / "sw.js",
media_type="application/javascript",
headers={"Cache-Control": "no-cache"},
)
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static") app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
@app.get("/", include_in_schema=False) @app.get("/", include_in_schema=False)

View File

@@ -5,6 +5,7 @@ import logging
import re import re
import subprocess import subprocess
import time import time
from concurrent.futures import ThreadPoolExecutor
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
@@ -17,6 +18,9 @@ from ..config_manager import config_manager
router = APIRouter(prefix="/api/callbacks", tags=["callbacks"]) router = APIRouter(prefix="/api/callbacks", tags=["callbacks"])
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Dedicated executor for callback/subprocess execution
_callback_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="callback")
class CallbackInfo(BaseModel): class CallbackInfo(BaseModel):
"""Information about a configured callback.""" """Information about a configured callback."""
@@ -127,10 +131,10 @@ async def execute_callback(
logger.info(f"Executing callback for debugging: {callback_name}") logger.info(f"Executing callback for debugging: {callback_name}")
try: try:
# Execute in thread pool to not block # Execute in dedicated thread pool to not block the default executor
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
result = await loop.run_in_executor( result = await loop.run_in_executor(
None, _callback_executor,
lambda: _run_callback( lambda: _run_callback(
command=callback_config.command, command=callback_config.command,
timeout=callback_config.timeout, timeout=callback_config.timeout,

View File

@@ -305,15 +305,9 @@ async def set_visualizer_device(
device_name = request.get("device_name") device_name = request.get("device_name")
analyzer = get_audio_analyzer() analyzer = get_audio_analyzer()
# Restart with new device # set_device() handles stop/start internally if capture was running
was_running = analyzer.running
success = analyzer.set_device(device_name) success = analyzer.set_device(device_name)
# Restart audio broadcast if needed
if was_running and success and analyzer.running:
await ws_manager.stop_audio_monitor()
await ws_manager.start_audio_monitor(analyzer)
return { return {
"success": success, "success": success,
"current_device": analyzer.current_device, "current_device": analyzer.current_device,

View File

@@ -5,6 +5,7 @@ import logging
import re import re
import subprocess import subprocess
import time import time
from concurrent.futures import ThreadPoolExecutor
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
@@ -16,6 +17,9 @@ from ..config_manager import config_manager
from ..services.websocket_manager import ws_manager from ..services.websocket_manager import ws_manager
router = APIRouter(prefix="/api/scripts", tags=["scripts"]) router = APIRouter(prefix="/api/scripts", tags=["scripts"])
# Dedicated executor for script/subprocess execution (avoids blocking the default pool)
_script_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="script")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -101,10 +105,10 @@ async def execute_script(
# Append arguments to command # Append arguments to command
command = f"{command} {' '.join(args)}" command = f"{command} {' '.join(args)}"
# Execute in thread pool to not block # Execute in dedicated thread pool to not block the default executor
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
result = await loop.run_in_executor( result = await loop.run_in_executor(
None, _script_executor,
lambda: _run_script( lambda: _run_script(
command=command, command=command,
timeout=script_config.timeout, timeout=script_config.timeout,

View File

@@ -40,8 +40,8 @@ class AudioAnalyzer:
self, self,
num_bins: int = 32, num_bins: int = 32,
sample_rate: int = 44100, sample_rate: int = 44100,
chunk_size: int = 2048, chunk_size: int = 1024,
target_fps: int = 25, target_fps: int = 30,
device_name: str | None = None, device_name: str | None = None,
): ):
self.num_bins = num_bins self.num_bins = num_bins
@@ -53,6 +53,7 @@ class AudioAnalyzer:
self._running = False self._running = False
self._thread: threading.Thread | None = None self._thread: threading.Thread | None = None
self._lock = threading.Lock() self._lock = threading.Lock()
self._lifecycle_lock = threading.Lock()
self._data: dict | None = None self._data: dict | None = None
self._current_device_name: str | None = None self._current_device_name: str | None = None
@@ -88,6 +89,7 @@ class AudioAnalyzer:
def start(self) -> bool: def start(self) -> bool:
"""Start audio capture in a background thread. Returns False if unavailable.""" """Start audio capture in a background thread. Returns False if unavailable."""
with self._lifecycle_lock:
if self._running: if self._running:
return True return True
if not self.available: if not self.available:
@@ -100,6 +102,7 @@ class AudioAnalyzer:
def stop(self) -> None: def stop(self) -> None:
"""Stop audio capture and cleanup.""" """Stop audio capture and cleanup."""
with self._lifecycle_lock:
self._running = False self._running = False
if self._thread: if self._thread:
self._thread.join(timeout=3.0) self._thread.join(timeout=3.0)

View File

@@ -28,6 +28,7 @@ class MetadataService:
if audio is None: if audio is None:
return {"error": "Unable to read audio file"} return {"error": "Unable to read audio file"}
try:
metadata = { metadata = {
"type": "audio", "type": "audio",
"filename": file_path.name, "filename": file_path.name,
@@ -83,6 +84,9 @@ class MetadataService:
metadata["title"] = file_path.stem metadata["title"] = file_path.stem
return metadata return metadata
finally:
if hasattr(audio, 'close'):
audio.close()
except ImportError: except ImportError:
logger.error("mutagen library not installed, cannot extract metadata") logger.error("mutagen library not installed, cannot extract metadata")
@@ -117,6 +121,7 @@ class MetadataService:
"title": file_path.stem, "title": file_path.stem,
} }
try:
metadata = { metadata = {
"type": "video", "type": "video",
"filename": file_path.name, "filename": file_path.name,
@@ -151,6 +156,9 @@ class MetadataService:
metadata["title"] = file_path.stem metadata["title"] = file_path.stem
return metadata return metadata
finally:
if hasattr(video, 'close'):
video.close()
except ImportError: except ImportError:
logger.error("mutagen library not installed, cannot extract metadata") logger.error("mutagen library not installed, cannot extract metadata")

View File

@@ -46,10 +46,16 @@ class ConnectionManager:
logger.debug("Failed to send initial status: %s", e) logger.debug("Failed to send initial status: %s", e)
async def disconnect(self, websocket: WebSocket) -> None: async def disconnect(self, websocket: WebSocket) -> None:
"""Remove a WebSocket connection.""" """Remove a WebSocket connection. Stops audio capture if last visualizer subscriber."""
should_stop = False
async with self._lock: async with self._lock:
self._active_connections.discard(websocket) self._active_connections.discard(websocket)
was_subscriber = websocket in self._visualizer_subscribers
self._visualizer_subscribers.discard(websocket) self._visualizer_subscribers.discard(websocket)
if was_subscriber and len(self._visualizer_subscribers) == 0:
should_stop = True
if should_stop:
await self._maybe_stop_capture()
logger.info( logger.info(
"WebSocket client disconnected. Total: %d", len(self._active_connections) "WebSocket client disconnected. Total: %d", len(self._active_connections)
) )
@@ -90,23 +96,50 @@ class ConnectionManager:
logger.info("Broadcast sent: links_changed") logger.info("Broadcast sent: links_changed")
async def subscribe_visualizer(self, websocket: WebSocket) -> None: async def subscribe_visualizer(self, websocket: WebSocket) -> None:
"""Subscribe a client to audio visualizer data.""" """Subscribe a client to audio visualizer data. Starts capture on first subscriber."""
should_start = False
async with self._lock: async with self._lock:
self._visualizer_subscribers.add(websocket) self._visualizer_subscribers.add(websocket)
if len(self._visualizer_subscribers) == 1 and self._audio_analyzer:
should_start = True
if should_start:
await self._maybe_start_capture()
logger.debug("Visualizer subscriber added. Total: %d", len(self._visualizer_subscribers)) logger.debug("Visualizer subscriber added. Total: %d", len(self._visualizer_subscribers))
async def unsubscribe_visualizer(self, websocket: WebSocket) -> None: async def unsubscribe_visualizer(self, websocket: WebSocket) -> None:
"""Unsubscribe a client from audio visualizer data.""" """Unsubscribe a client from audio visualizer data. Stops capture on last subscriber."""
should_stop = False
async with self._lock: async with self._lock:
self._visualizer_subscribers.discard(websocket) self._visualizer_subscribers.discard(websocket)
if len(self._visualizer_subscribers) == 0:
should_stop = True
if should_stop:
await self._maybe_stop_capture()
logger.debug("Visualizer subscriber removed. Total: %d", len(self._visualizer_subscribers)) logger.debug("Visualizer subscriber removed. Total: %d", len(self._visualizer_subscribers))
async def _maybe_start_capture(self) -> None:
"""Start audio capture if not already running (called on first subscriber)."""
if self._audio_analyzer and not self._audio_analyzer.running:
loop = asyncio.get_event_loop()
started = await loop.run_in_executor(None, self._audio_analyzer.start)
if started:
logger.info("Audio capture started (first subscriber)")
else:
logger.warning("Audio capture failed to start")
async def _maybe_stop_capture(self) -> None:
"""Stop audio capture if running (called when last subscriber leaves)."""
if self._audio_analyzer and self._audio_analyzer.running:
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, self._audio_analyzer.stop)
logger.info("Audio capture stopped (no subscribers)")
async def start_audio_monitor(self, analyzer) -> None: async def start_audio_monitor(self, analyzer) -> None:
"""Start audio frequency broadcasting if analyzer is available.""" """Register the audio analyzer. Capture starts on-demand when clients subscribe."""
self._audio_analyzer = analyzer self._audio_analyzer = analyzer
if analyzer and analyzer.running: if analyzer and analyzer.available:
self._audio_task = asyncio.create_task(self._audio_broadcast_loop()) self._audio_task = asyncio.create_task(self._audio_broadcast_loop())
logger.info("Audio visualizer broadcast started") logger.info("Audio visualizer broadcast loop started (capture on-demand)")
async def stop_audio_monitor(self) -> None: async def stop_audio_monitor(self) -> None:
"""Stop audio frequency broadcasting.""" """Stop audio frequency broadcasting."""
@@ -153,10 +186,6 @@ class ConnectionManager:
results = await asyncio.gather(*(_send(ws) for ws in subscribers)) results = await asyncio.gather(*(_send(ws) for ws in subscribers))
failed = [ws for ws in results if ws is not None] failed = [ws for ws in results if ws is not None]
if failed:
async with self._lock:
for ws in failed:
self._visualizer_subscribers.discard(ws)
for ws in failed: for ws in failed:
await self.disconnect(ws) await self.disconnect(ws)
@@ -251,7 +280,7 @@ class ConnectionManager:
has_clients = len(self._active_connections) > 0 has_clients = len(self._active_connections) > 0
if not has_clients: if not has_clients:
await asyncio.sleep(self._poll_interval) await asyncio.sleep(2.0) # Sleep longer when no clients connected
continue continue
status = await get_status_func() status = await get_status_func()

View File

@@ -2,6 +2,8 @@
import asyncio import asyncio
import logging import logging
import threading
import time as _time
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from typing import Optional, Any from typing import Optional, Any
@@ -16,8 +18,10 @@ _executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="winrt")
# Global storage for current album art (as bytes) # Global storage for current album art (as bytes)
_current_album_art_bytes: bytes | None = None _current_album_art_bytes: bytes | None = None
# Lock protecting _position_cache and _track_skip_pending from concurrent access
_position_lock = threading.Lock()
# Global storage for position tracking # Global storage for position tracking
import time as _time
_position_cache = { _position_cache = {
"track_id": "", "track_id": "",
"base_position": 0.0, "base_position": 0.0,
@@ -224,6 +228,7 @@ def _sync_get_media_status() -> dict[str, Any]:
is_playing = result["state"] == "playing" is_playing = result["state"] == "playing"
current_title = result.get('title', '') current_title = result.get('title', '')
with _position_lock:
# Check if track skip is pending and title changed # Check if track skip is pending and title changed
skip_just_completed = False skip_just_completed = False
if _track_skip_pending["active"]: if _track_skip_pending["active"]:
@@ -483,6 +488,11 @@ def _sync_seek(position: float) -> bool:
return False return False
def shutdown_executor() -> None:
"""Shut down the WinRT thread pool executor."""
_executor.shutdown(wait=False)
class WindowsMediaController(MediaController): class WindowsMediaController(MediaController):
"""Media controller for Windows using WinRT and pycaw.""" """Media controller for Windows using WinRT and pycaw."""
@@ -602,7 +612,7 @@ class WindowsMediaController(MediaController):
result = await self._run_command("next") result = await self._run_command("next")
if result: if result:
# Set flag to force position to 0 until title changes with _position_lock:
_track_skip_pending["active"] = True _track_skip_pending["active"] = True
_track_skip_pending["old_title"] = old_title _track_skip_pending["old_title"] = old_title
_track_skip_pending["skip_time"] = _time.time() _track_skip_pending["skip_time"] = _time.time()
@@ -620,7 +630,7 @@ class WindowsMediaController(MediaController):
result = await self._run_command("previous") result = await self._run_command("previous")
if result: if result:
# Set flag to force position to 0 until title changes with _position_lock:
_track_skip_pending["active"] = True _track_skip_pending["active"] = True
_track_skip_pending["old_title"] = old_title _track_skip_pending["old_title"] = old_title
_track_skip_pending["skip_time"] = _time.time() _track_skip_pending["skip_time"] = _time.time()

View File

@@ -63,6 +63,27 @@
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.08); box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.08);
} }
/* Dynamic Background Canvas */
.bg-shader-canvas {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: -1;
pointer-events: none;
opacity: 0;
transition: opacity 0.6s ease;
}
.bg-shader-canvas.visible {
opacity: 1;
}
body.dynamic-bg-active {
background: transparent;
}
* { * {
margin: 0; margin: 0;
padding: 0; padding: 0;
@@ -144,7 +165,7 @@ body.dialog-open {
.container { .container {
max-width: 800px; max-width: 800px;
margin: 0 auto; margin: 0 auto;
padding: 2rem 2rem 0.5rem; padding: 0.75rem 0.75rem 0.5rem;
} }
header { header {
@@ -182,51 +203,109 @@ h1 {
background: var(--accent); background: var(--accent);
} }
.theme-toggle { .header-toolbar {
background: transparent;
border: none;
padding: 0.3rem;
cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; gap: 2px;
transition: opacity 0.3s; background: var(--bg-secondary);
width: 32px; border: 1px solid var(--border);
height: 32px; border-radius: 8px;
opacity: 0.7; padding: 3px 4px;
} }
.theme-toggle:hover { .header-toolbar-sep {
opacity: 1; width: 1px;
height: 18px;
background: var(--border);
margin: 0 3px;
flex-shrink: 0;
} }
.theme-toggle svg { .header-btn {
background: transparent;
border: none;
padding: 4px 6px;
border-radius: 5px;
cursor: pointer;
color: var(--text-secondary);
transition: color 0.2s, background 0.2s;
display: inline-flex;
align-items: center;
line-height: 1;
}
.header-btn:hover {
color: var(--text-primary);
background: var(--bg-tertiary);
}
.header-btn.active {
color: var(--accent);
}
.header-btn svg {
width: 16px; width: 16px;
height: 16px; height: 16px;
fill: currentColor; fill: currentColor;
} }
.header-btn-logout:hover {
color: var(--error);
}
.header-locale {
background: transparent;
border: none;
color: var(--text-secondary);
font-size: 0.75rem;
font-weight: 600;
padding: 4px 4px 4px 8px;
border-radius: 5px;
cursor: pointer;
transition: color 0.2s, background 0.2s;
}
.header-locale:hover {
color: var(--text-primary);
background: var(--bg-tertiary);
}
.header-locale:focus {
outline: none;
}
.header-locale option {
background: var(--bg-secondary);
color: var(--text-primary);
}
/* Header Quick Links */ /* Header Quick Links */
.header-links { .header-links {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.125rem; gap: 2px;
}
.header-links:not(:empty) {
padding-right: 3px;
margin-right: 3px;
border-right: 1px solid var(--border);
} }
.header-link { .header-link {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 32px; padding: 4px 6px;
height: 32px; border-radius: 5px;
color: var(--text-primary); color: var(--text-secondary);
opacity: 0.7; transition: color 0.2s, background 0.2s;
transition: opacity 0.2s;
text-decoration: none; text-decoration: none;
} }
.header-link:hover { .header-link:hover {
opacity: 1; color: var(--text-primary);
background: var(--bg-tertiary);
} }
.header-link svg { .header-link svg {
@@ -271,25 +350,9 @@ h1 {
position: relative; position: relative;
} }
.accent-picker-btn {
background: transparent;
border: none;
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
opacity: 0.8;
transition: opacity 0.2s;
}
.accent-picker-btn:hover {
opacity: 1;
transform: none;
}
.accent-dot { .accent-dot {
width: 16px; width: 14px;
height: 16px; height: 14px;
border-radius: 50%; border-radius: 50%;
background: var(--accent); background: var(--accent);
border: 2px solid var(--border); border: 2px solid var(--border);
@@ -376,31 +439,6 @@ h1 {
pointer-events: none; pointer-events: none;
} }
#locale-select {
background: var(--bg-tertiary);
border: 1px solid var(--border);
color: var(--text-primary);
border-radius: 6px;
padding: 4px 8px;
cursor: pointer;
font-size: 12px;
font-weight: 500;
transition: all 0.3s ease;
}
#locale-select:hover {
border-color: var(--accent);
}
#locale-select:focus {
outline: none;
border-color: var(--accent);
}
#locale-select option {
background: var(--bg-secondary);
color: var(--text-primary);
}
/* Tab Bar */ /* Tab Bar */
.tab-bar { .tab-bar {
@@ -466,10 +504,17 @@ h1 {
[data-tab-content] { [data-tab-content] {
display: none; display: none;
opacity: 0;
} }
[data-tab-content].active { [data-tab-content].active {
display: block; display: block;
animation: tabFadeIn 0.25s ease-out forwards;
}
@keyframes tabFadeIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
} }
@media (max-width: 600px) { @media (max-width: 600px) {
@@ -485,7 +530,7 @@ h1 {
.player-container { .player-container {
background: var(--bg-secondary); background: var(--bg-secondary);
border-radius: 12px; border-radius: 12px;
padding: 2rem; padding: 1rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
} }
@@ -811,6 +856,20 @@ button:disabled {
transform: scale(1.05); transform: scale(1.05);
} }
.controls button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
box-shadow: 0 0 0 4px rgba(29, 185, 84, 0.25);
}
.mute-btn:focus-visible,
.mini-control-btn:focus-visible,
.vinyl-toggle-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
box-shadow: 0 0 0 4px rgba(29, 185, 84, 0.25);
}
.controls button.primary { .controls button.primary {
width: 56px; width: 56px;
height: 56px; height: 56px;
@@ -909,6 +968,28 @@ button:disabled {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 0.75rem; gap: 0.75rem;
min-width: 0;
}
.source-label {
display: inline-flex;
align-items: center;
gap: 0.35rem;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 200px;
}
.source-icon {
display: inline-flex;
align-items: center;
}
.source-icon svg {
width: 14px;
height: 14px;
} }
.player-toggles { .player-toggles {
@@ -955,7 +1036,7 @@ button:disabled {
.scripts-container { .scripts-container {
background: var(--bg-secondary); background: var(--bg-secondary);
border-radius: 12px; border-radius: 12px;
padding: 2rem; padding: 1rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
} }
@@ -967,7 +1048,7 @@ button:disabled {
.scripts-grid { .scripts-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(min(180px, 100%), 1fr));
gap: 1rem; gap: 1rem;
} }
@@ -1004,6 +1085,10 @@ button:disabled {
.script-btn .script-label { .script-btn .script-label {
font-weight: 600; font-weight: 600;
font-size: 0.875rem; font-size: 0.875rem;
text-align: center;
overflow-wrap: break-word;
word-break: break-word;
max-width: 100%;
} }
.script-btn .script-description { .script-btn .script-description {
@@ -1084,7 +1169,7 @@ button:disabled {
} }
.settings-section summary { .settings-section summary {
padding: 1rem 1.5rem; padding: 1rem;
font-size: 1rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
@@ -1109,7 +1194,7 @@ button:disabled {
border-right: 2px solid var(--text-muted); border-right: 2px solid var(--text-muted);
border-bottom: 2px solid var(--text-muted); border-bottom: 2px solid var(--text-muted);
transform: rotate(-45deg); transform: rotate(-45deg);
transition: transform 0.2s; transition: transform 0.3s ease;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -1122,8 +1207,14 @@ button:disabled {
} }
.settings-section-content { .settings-section-content {
padding: 0 1.5rem 1.5rem; padding: 0 1rem 1rem;
overflow-x: auto; overflow-x: auto;
animation: settingsExpand 0.3s ease-out;
}
@keyframes settingsExpand {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
} }
.settings-section-description { .settings-section-description {
@@ -1151,13 +1242,22 @@ button:disabled {
} }
.audio-device-selector select { .audio-device-selector select {
padding: 0.5rem 0.625rem; width: 100%;
border-radius: 8px; padding: 10px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg-tertiary); background: var(--bg-tertiary);
color: var(--text-primary); color: var(--text-primary);
font-size: 0.875rem; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
font-size: 1rem;
cursor: pointer; cursor: pointer;
transition: border-color 0.25s ease, box-shadow 0.25s ease;
}
.audio-device-selector select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(29, 185, 84, 0.15);
} }
.audio-device-status { .audio-device-status {
@@ -1226,7 +1326,7 @@ button:disabled {
.display-container { .display-container {
background: var(--bg-secondary); background: var(--bg-secondary);
border-radius: 12px; border-radius: 12px;
padding: 2rem; padding: 1rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
} }
@@ -1460,6 +1560,7 @@ button:disabled {
border-radius: 4px; border-radius: 4px;
font-size: 0.75rem; font-size: 0.75rem;
color: var(--accent); color: var(--accent);
white-space: nowrap;
} }
.action-btn { .action-btn {
@@ -1597,6 +1698,11 @@ dialog {
width: 90%; width: 90%;
margin: auto; margin: auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.8); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.8);
animation: dialogIn 0.25s ease-out;
}
dialog.dialog-closing {
animation: dialogOut 0.2s ease-in forwards;
} }
/* Ensure dialogs are hidden until explicitly opened */ /* Ensure dialogs are hidden until explicitly opened */
@@ -1606,6 +1712,31 @@ dialog:not([open]) {
dialog::backdrop { dialog::backdrop {
background: rgba(0, 0, 0, 0.8); background: rgba(0, 0, 0, 0.8);
animation: backdropIn 0.25s ease-out;
}
dialog.dialog-closing::backdrop {
animation: backdropOut 0.2s ease-in forwards;
}
@keyframes dialogIn {
from { opacity: 0; transform: scale(0.9) translateY(10px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
@keyframes dialogOut {
from { opacity: 1; transform: scale(1) translateY(0); }
to { opacity: 0; transform: scale(0.9) translateY(10px); }
}
@keyframes backdropIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes backdropOut {
from { opacity: 1; }
to { opacity: 0; }
} }
.confirm-dialog { .confirm-dialog {
@@ -1913,26 +2044,6 @@ dialog::backdrop {
display: block; display: block;
} }
.clear-token-btn {
width: auto;
height: auto;
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
background: var(--bg-tertiary);
color: var(--text-secondary);
border: 1px solid var(--border);
cursor: pointer;
opacity: 0.7;
}
.clear-token-btn:hover {
opacity: 1;
background: var(--error);
color: var(--text-primary);
border-color: var(--error);
}
/* Mini Player (Sticky) */ /* Mini Player (Sticky) */
.mini-player { .mini-player {
@@ -2181,7 +2292,7 @@ button.primary svg {
@media (max-width: 600px) { @media (max-width: 600px) {
.container { .container {
padding: 1rem; padding: 0.5rem;
} }
#album-art { #album-art {
@@ -2252,7 +2363,7 @@ footer .separator {
.browser-container { .browser-container {
background: var(--bg-secondary); background: var(--bg-secondary);
border-radius: 12px; border-radius: 12px;
padding: 2rem; padding: 1rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
} }
@@ -2539,6 +2650,8 @@ footer .separator {
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
transition: all 0.15s; transition: all 0.15s;
animation: itemFadeIn 0.3s ease-out backwards;
animation-delay: calc(var(--item-index, 0) * 20ms);
} }
.browser-list-item:hover { .browser-list-item:hover {
@@ -2666,6 +2779,13 @@ footer .separator {
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
position: relative; position: relative;
animation: itemFadeIn 0.3s ease-out backwards;
animation-delay: calc(var(--item-index, 0) * 30ms);
}
@keyframes itemFadeIn {
from { opacity: 0; transform: translateY(8px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
} }
.browser-item:hover { .browser-item:hover {
@@ -2969,16 +3089,26 @@ footer .separator {
} }
.browser-toolbar { .browser-toolbar {
flex-direction: column; flex-wrap: wrap;
align-items: stretch; gap: 0.5rem;
}
.browser-toolbar-left {
gap: 0.35rem;
} }
.browser-search-wrapper { .browser-search-wrapper {
max-width: none; max-width: none;
flex-basis: 100%;
order: 10;
} }
.browser-toolbar-right { .browser-toolbar-right {
justify-content: flex-end; margin-left: auto;
}
.items-per-page-label span {
display: none;
} }
.browser-list-item { .browser-list-item {
@@ -3014,6 +3144,10 @@ footer .separator {
padding-top: calc(0.5rem + 2px); padding-top: calc(0.5rem + 2px);
} }
.mini-nav-btn {
display: none;
}
.mini-player-info { .mini-player-info {
min-width: 120px; min-width: 120px;
} }
@@ -3025,6 +3159,10 @@ footer .separator {
/* Tablet breakpoint */ /* Tablet breakpoint */
@media (min-width: 601px) and (max-width: 900px) { @media (min-width: 601px) and (max-width: 900px) {
.mini-volume-container {
display: none;
}
.browser-list-bitrate { .browser-list-bitrate {
display: none; display: none;
} }
@@ -3054,11 +3192,25 @@ footer .separator {
transition: transform 0.3s ease; transition: transform 0.3s ease;
} }
.connection-banner:not(.hidden) {
animation: bannerSlideIn 0.4s ease-out, bannerPulse 2s ease-in-out 0.4s 2;
}
.connection-banner.hidden { .connection-banner.hidden {
transform: translateY(-100%); transform: translateY(-100%);
pointer-events: none; pointer-events: none;
} }
@keyframes bannerSlideIn {
from { transform: translateY(-100%); }
to { transform: translateY(0); }
}
@keyframes bannerPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.connection-banner-btn { .connection-banner-btn {
padding: 4px 14px; padding: 4px 14px;
background: rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.2);
@@ -3107,3 +3259,81 @@ footer .separator {
justify-content: flex-start; justify-content: flex-start;
} }
} }
/* ========================================
PWA Standalone & Mobile Polish
======================================== */
html {
overscroll-behavior: none;
}
/* Safe area insets for notched phones (viewport-fit=cover) */
.container {
padding-left: max(0.75rem, env(safe-area-inset-left));
padding-right: max(0.75rem, env(safe-area-inset-right));
}
.mini-player {
padding-bottom: max(0.75rem, env(safe-area-inset-bottom));
padding-left: max(1rem, env(safe-area-inset-left));
padding-right: max(1rem, env(safe-area-inset-right));
}
footer {
padding-bottom: max(0.75rem, env(safe-area-inset-bottom));
}
body.mini-player-visible footer {
padding-bottom: calc(70px + env(safe-area-inset-bottom, 0px));
}
.connection-banner {
padding-top: max(10px, env(safe-area-inset-top));
}
/* Touch optimization: eliminate 300ms tap delay */
.controls button,
.mini-controls button,
.mini-control-btn,
.tab-btn,
.header-btn,
.header-link,
.mute-btn,
.vinyl-toggle-btn,
.view-toggle-btn,
.browser-item,
.browser-list-item,
.script-btn,
.action-btn {
touch-action: manipulation;
}
@media (max-width: 600px) {
.container {
padding-left: max(0.5rem, env(safe-area-inset-left));
padding-right: max(0.5rem, env(safe-area-inset-right));
}
.mini-player {
padding-bottom: max(0.5rem, env(safe-area-inset-bottom));
padding-left: max(0.75rem, env(safe-area-inset-left));
padding-right: max(0.75rem, env(safe-area-inset-right));
}
}
@media (display-mode: standalone) {
body {
overscroll-behavior-y: none;
-webkit-overflow-scrolling: touch;
}
}
/* Accessibility: reduce motion for users who prefer it */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#1db954;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1ed760;stop-opacity:1" />
</linearGradient>
</defs>
<circle cx="50" cy="50" r="45" fill="url(#grad)"/>
<path fill="white" d="M35 25 L35 75 L75 50 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 421 B

View File

@@ -2,9 +2,16 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Media Server</title> <title>Media Server</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Cdefs%3E%3ClinearGradient id='grad' x1='0%25' y1='0%25' x2='100%25' y2='100%25'%3E%3Cstop offset='0%25' style='stop-color:%231db954;stop-opacity:1' /%3E%3Cstop offset='100%25' style='stop-color:%231ed760;stop-opacity:1' /%3E%3C/linearGradient%3E%3C/defs%3E%3Ccircle cx='50' cy='50' r='45' fill='url(%23grad)'/%3E%3Cpath fill='white' d='M35 25 L35 75 L75 50 Z'/%3E%3C/svg%3E"> <meta name="description" content="Remote media player control and file browser">
<meta name="theme-color" content="#121212">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Media Server">
<link rel="manifest" href="/static/manifest.json">
<link rel="icon" type="image/svg+xml" href="/static/icons/icon.svg">
<link rel="apple-touch-icon" href="/static/icons/icon.svg">
<link rel="stylesheet" href="/static/css/styles.css"> <link rel="stylesheet" href="/static/css/styles.css">
</head> </head>
<body class="loading-translations"> <body class="loading-translations">
@@ -50,6 +57,9 @@
</div> </div>
</div> </div>
<!-- Dynamic Background -->
<canvas id="bg-shader-canvas" class="bg-shader-canvas"></canvas>
<!-- Auth Modal --> <!-- Auth Modal -->
<div id="auth-overlay" class="hidden"> <div id="auth-overlay" class="hidden">
<div class="auth-modal"> <div class="auth-modal">
@@ -71,15 +81,18 @@
<span class="status-dot" id="status-dot" aria-live="polite"></span> <span class="status-dot" id="status-dot" aria-live="polite"></span>
<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: 0.5rem;"> <div class="header-toolbar">
<div id="headerLinks" class="header-links"></div> <div id="headerLinks" class="header-links"></div>
<div class="accent-picker"> <div class="accent-picker">
<button class="accent-picker-btn" onclick="toggleAccentPicker()" title="Accent color"> <button class="header-btn" onclick="toggleAccentPicker()" title="Accent color" aria-label="Accent color">
<span class="accent-dot" id="accentDot"></span> <span class="accent-dot" id="accentDot"></span>
</button> </button>
<div class="accent-picker-dropdown" id="accentDropdown"></div> <div class="accent-picker-dropdown" id="accentDropdown"></div>
</div> </div>
<button class="theme-toggle" onclick="toggleTheme()" data-i18n-title="player.theme" title="Toggle theme" id="theme-toggle"> <button class="header-btn" onclick="toggleDynamicBackground()" data-i18n-title="player.background" title="Dynamic background" aria-label="Dynamic background" id="bgToggle">
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M12 3v10.55A4 4 0 1 0 14 17V7h4V3h-6z"/></svg>
</button>
<button class="header-btn" onclick="toggleTheme()" data-i18n-title="player.theme" title="Toggle theme" aria-label="Toggle theme" id="theme-toggle">
<svg id="theme-icon-sun" viewBox="0 0 24 24" style="display: none;"> <svg id="theme-icon-sun" viewBox="0 0 24 24" style="display: none;">
<path d="M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zM2 13h2c.55 0 1-.45 1-1s-.45-1-1-1H2c-.55 0-1 .45-1 1s.45 1 1 1zm18 0h2c.55 0 1-.45 1-1s-.45-1-1-1h-2c-.55 0-1 .45-1 1s.45 1 1 1zM11 2v2c0 .55.45 1 1 1s1-.45 1-1V2c0-.55-.45-1-1-1s-1 .45-1 1zm0 18v2c0 .55.45 1 1 1s1-.45 1-1v-2c0-.55-.45-1-1-1s-1 .45-1 1zM5.99 4.58c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0s.39-1.03 0-1.41L5.99 4.58zm12.37 12.37c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0 .39-.39.39-1.03 0-1.41l-1.06-1.06zm1.06-10.96c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06zM7.05 18.36c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06z"/> <path d="M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zM2 13h2c.55 0 1-.45 1-1s-.45-1-1-1H2c-.55 0-1 .45-1 1s.45 1 1 1zm18 0h2c.55 0 1-.45 1-1s-.45-1-1-1h-2c-.55 0-1 .45-1 1s.45 1 1 1zM11 2v2c0 .55.45 1 1 1s1-.45 1-1V2c0-.55-.45-1-1-1s-1 .45-1 1zm0 18v2c0 .55.45 1 1 1s1-.45 1-1v-2c0-.55-.45-1-1-1s-1 .45-1 1zM5.99 4.58c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0s.39-1.03 0-1.41L5.99 4.58zm12.37 12.37c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0 .39-.39.39-1.03 0-1.41l-1.06-1.06zm1.06-10.96c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06zM7.05 18.36c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06z"/>
</svg> </svg>
@@ -87,11 +100,14 @@
<path d="M9 2c-1.05 0-2.05.16-3 .46 4.06 1.27 7 5.06 7 9.54 0 4.48-2.94 8.27-7 9.54.95.3 1.95.46 3 .46 5.52 0 10-4.48 10-10S14.52 2 9 2z"/> <path d="M9 2c-1.05 0-2.05.16-3 .46 4.06 1.27 7 5.06 7 9.54 0 4.48-2.94 8.27-7 9.54.95.3 1.95.46 3 .46 5.52 0 10-4.48 10-10S14.52 2 9 2z"/>
</svg> </svg>
</button> </button>
<select id="locale-select" onchange="changeLocale()" title="Change language"> <select id="locale-select" class="header-locale" onchange="changeLocale()" title="Change language">
<option value="en">English</option> <option value="en">EN</option>
<option value="ru">Русский</option> <option value="ru">RU</option>
</select> </select>
<button class="clear-token-btn" onclick="clearToken()" data-i18n-title="auth.logout.title" data-i18n="auth.logout" title="Clear saved token">Logout</button> <span class="header-toolbar-sep"></span>
<button class="header-btn header-btn-logout" onclick="clearToken()" data-i18n-title="auth.logout.title" title="Clear saved token" aria-label="Logout">
<svg viewBox="0 0 24 24"><path d="M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5zM4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4V5z"/></svg>
</button>
</div> </div>
</header> </header>
@@ -186,7 +202,7 @@
</div> </div>
<div class="source-info"> <div class="source-info">
<span data-i18n="player.source">Source:</span> <span id="source" data-i18n="player.unknown_source">Unknown</span> <span class="source-label"><span class="source-icon" id="sourceIcon"></span><span id="source" data-i18n="player.unknown_source">Unknown</span></span>
<div class="player-toggles"> <div class="player-toggles">
<button class="vinyl-toggle-btn" onclick="toggleVinylMode()" id="vinylToggle" data-i18n-title="player.vinyl" title="Vinyl mode"> <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> <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>
@@ -635,6 +651,14 @@
</div> </div>
</footer> </footer>
<script src="/static/js/app.js"></script> <script src="/static/js/core.js"></script>
<script src="/static/js/player.js"></script>
<script src="/static/js/websocket.js"></script>
<script src="/static/js/scripts.js"></script>
<script src="/static/js/callbacks.js"></script>
<script src="/static/js/browser.js"></script>
<script src="/static/js/links.js"></script>
<script src="/static/js/background.js"></script>
<script src="/static/js/main.js"></script>
</body> </body>
</html> </html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,314 @@
// ============================================================
// Background: WebGL shader-based dynamic background
// ============================================================
let bgCanvas = null;
let bgGL = null;
let bgProgram = null;
let bgUniforms = null; // Cached uniform locations
let bgAnimFrame = null;
let bgEnabled = localStorage.getItem('dynamicBackground') === 'true';
let bgStartTime = 0;
let bgSmoothedBands = new Float32Array(16);
let bgSmoothedBass = 0;
let bgAccentRGB = [0.114, 0.725, 0.329]; // Cached accent color (default green)
let bgBgColorRGB = [0.071, 0.071, 0.071]; // Cached page background (#121212)
const BG_BAND_COUNT = 16;
const BG_SMOOTHING = 0.12;
// ---- Shaders ----
const BG_VERT_SRC = `
attribute vec2 a_position;
void main() {
gl_Position = vec4(a_position, 0.0, 1.0);
}
`;
const BG_FRAG_SRC = `
precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;
uniform float u_bass;
uniform float u_bands[16];
uniform vec3 u_accent;
uniform vec3 u_bgColor;
// Smooth noise
float hash(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}
float noise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
f = f * f * (3.0 - 2.0 * f);
float a = hash(i);
float b = hash(i + vec2(1.0, 0.0));
float c = hash(i + vec2(0.0, 1.0));
float d = hash(i + vec2(1.0, 1.0));
return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
}
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution;
float aspect = u_resolution.x / u_resolution.y;
// Center coordinates for radial effects
vec2 center = (uv - 0.5) * vec2(aspect, 1.0);
float dist = length(center);
float angle = atan(center.y, center.x);
// Slow base animation
float t = u_time * 0.15;
// === Layer 1: Flowing wave field ===
float waves = 0.0;
for (int i = 0; i < 5; i++) {
float fi = float(i);
float freq = 1.5 + fi * 0.8;
float speed = t * (0.6 + fi * 0.15);
// Sample a band for this wave layer
int bandIdx = i * 3;
float bandVal = 0.0;
// Manual indexing (GLSL ES doesn't allow variable array index in some drivers)
for (int j = 0; j < 16; j++) {
if (j == bandIdx) bandVal = u_bands[j];
}
float amp = 0.015 + bandVal * 0.06;
waves += amp * sin(uv.x * freq * 6.2832 + speed + sin(uv.y * 3.0 + t) * 2.0);
waves += amp * 0.5 * sin(uv.y * freq * 4.0 - speed * 0.7 + cos(uv.x * 2.5 + t) * 1.5);
}
// === Layer 2: Radial pulse (bass-driven) ===
float pulse = smoothstep(0.6 + u_bass * 0.3, 0.0, dist) * (0.08 + u_bass * 0.15);
// === Layer 3: Frequency ring arcs ===
float rings = 0.0;
for (int i = 0; i < 8; i++) {
float fi = float(i);
float bandVal = 0.0;
for (int j = 0; j < 16; j++) {
if (j == i * 2) bandVal = u_bands[j];
}
float radius = 0.15 + fi * 0.1;
float ringWidth = 0.008 + bandVal * 0.025;
float ring = smoothstep(ringWidth, 0.0, abs(dist - radius - bandVal * 0.05));
// Fade ring by angle sector for variety
float angleFade = 0.5 + 0.5 * sin(angle * (2.0 + fi) + t * (1.0 + fi * 0.3));
rings += ring * angleFade * (0.3 + bandVal * 0.7);
}
// === Layer 4: Subtle noise texture ===
float n = noise(uv * 4.0 + t * 0.5) * 0.03;
// Combine layers
float intensity = waves + pulse + rings * 0.5 + n;
// Color: accent color with varying brightness
vec3 col = u_accent * intensity;
// Subtle secondary hue shift for depth
vec3 shifted = u_accent.gbr; // Rotated accent
col += shifted * rings * 0.15;
// Vignette
float vignette = 1.0 - smoothstep(0.3, 1.2, dist);
col *= vignette;
// Blend over page background
col = clamp(col, 0.0, 1.0);
float colBright = (col.r + col.g + col.b) / 3.0;
float bgLum = dot(u_bgColor, vec3(0.299, 0.587, 0.114));
// Dark bg: add accent light. Light bg: tint white toward accent via multiply.
vec3 darkResult = u_bgColor + col;
vec3 lightResult = u_bgColor * mix(vec3(1.0), u_accent, colBright * 2.0);
vec3 finalColor = clamp(mix(darkResult, lightResult, bgLum), 0.0, 1.0);
gl_FragColor = vec4(finalColor, 1.0);
}
`;
// ---- WebGL setup ----
function initBackgroundGL() {
bgCanvas = document.getElementById('bg-shader-canvas');
if (!bgCanvas) return false;
bgGL = bgCanvas.getContext('webgl', { alpha: false, antialias: false, depth: false, stencil: false });
if (!bgGL) {
console.warn('WebGL not available for background shader');
return false;
}
const gl = bgGL;
// Compile shaders
const vs = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(vs, BG_VERT_SRC);
gl.compileShader(vs);
if (!gl.getShaderParameter(vs, gl.COMPILE_STATUS)) {
console.error('BG vertex shader:', gl.getShaderInfoLog(vs));
return false;
}
const fs = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fs, BG_FRAG_SRC);
gl.compileShader(fs);
if (!gl.getShaderParameter(fs, gl.COMPILE_STATUS)) {
console.error('BG fragment shader:', gl.getShaderInfoLog(fs));
return false;
}
bgProgram = gl.createProgram();
gl.attachShader(bgProgram, vs);
gl.attachShader(bgProgram, fs);
gl.linkProgram(bgProgram);
if (!gl.getProgramParameter(bgProgram, gl.LINK_STATUS)) {
console.error('BG program link:', gl.getProgramInfoLog(bgProgram));
return false;
}
// Fullscreen quad
const buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
-1, -1, 1, -1, -1, 1,
-1, 1, 1, -1, 1, 1
]), gl.STATIC_DRAW);
const aPos = gl.getAttribLocation(bgProgram, 'a_position');
gl.enableVertexAttribArray(aPos);
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
gl.useProgram(bgProgram);
// Cache uniform locations once (avoids per-frame lookups)
bgUniforms = {
resolution: gl.getUniformLocation(bgProgram, 'u_resolution'),
time: gl.getUniformLocation(bgProgram, 'u_time'),
bass: gl.getUniformLocation(bgProgram, 'u_bass'),
bands: gl.getUniformLocation(bgProgram, 'u_bands'),
accent: gl.getUniformLocation(bgProgram, 'u_accent'),
bgColor: gl.getUniformLocation(bgProgram, 'u_bgColor'),
};
bgStartTime = performance.now() / 1000;
updateBackgroundColors();
resizeBackgroundCanvas();
window.addEventListener('resize', resizeBackgroundCanvas);
return true;
}
function resizeBackgroundCanvas() {
if (!bgCanvas) return;
const dpr = Math.min(window.devicePixelRatio || 1, 1.5); // Cap DPR for performance
const w = Math.floor(window.innerWidth * dpr);
const h = Math.floor(window.innerHeight * dpr);
if (bgCanvas.width !== w || bgCanvas.height !== h) {
bgCanvas.width = w;
bgCanvas.height = h;
}
}
// ---- Cached color/theme updates (called on accent or theme change, not per-frame) ----
function updateBackgroundColors() {
const style = getComputedStyle(document.documentElement);
const accentHex = style.getPropertyValue('--accent').trim();
if (accentHex && accentHex.length >= 7) {
bgAccentRGB[0] = parseInt(accentHex.slice(1, 3), 16) / 255;
bgAccentRGB[1] = parseInt(accentHex.slice(3, 5), 16) / 255;
bgAccentRGB[2] = parseInt(accentHex.slice(5, 7), 16) / 255;
}
const bgHex = style.getPropertyValue('--bg-primary').trim();
if (bgHex && bgHex.length >= 7) {
bgBgColorRGB[0] = parseInt(bgHex.slice(1, 3), 16) / 255;
bgBgColorRGB[1] = parseInt(bgHex.slice(3, 5), 16) / 255;
bgBgColorRGB[2] = parseInt(bgHex.slice(5, 7), 16) / 255;
}
}
// ---- Render loop ----
function renderBackgroundFrame() {
bgAnimFrame = requestAnimationFrame(renderBackgroundFrame);
const gl = bgGL;
if (!gl || !bgUniforms) return;
resizeBackgroundCanvas();
gl.viewport(0, 0, bgCanvas.width, bgCanvas.height);
const time = performance.now() / 1000 - bgStartTime;
// Smooth audio data from the global frequencyData (shared with visualizer)
if (typeof frequencyData !== 'undefined' && frequencyData && frequencyData.frequencies) {
const bins = frequencyData.frequencies;
const step = Math.max(1, Math.floor(bins.length / BG_BAND_COUNT));
for (let i = 0; i < BG_BAND_COUNT; i++) {
const idx = Math.min(i * step, bins.length - 1);
const target = bins[idx] || 0;
bgSmoothedBands[i] += (target - bgSmoothedBands[i]) * (1 - BG_SMOOTHING);
}
const targetBass = frequencyData.bass || 0;
bgSmoothedBass += (targetBass - bgSmoothedBass) * (1 - BG_SMOOTHING);
} else {
// Gentle decay when no audio
for (let i = 0; i < BG_BAND_COUNT; i++) {
bgSmoothedBands[i] *= 0.95;
}
bgSmoothedBass *= 0.95;
}
// Set uniforms (locations cached at init, colors cached on change)
gl.uniform2f(bgUniforms.resolution, bgCanvas.width, bgCanvas.height);
gl.uniform1f(bgUniforms.time, time);
gl.uniform1f(bgUniforms.bass, bgSmoothedBass);
gl.uniform1fv(bgUniforms.bands, bgSmoothedBands);
gl.uniform3f(bgUniforms.accent, bgAccentRGB[0], bgAccentRGB[1], bgAccentRGB[2]);
gl.uniform3f(bgUniforms.bgColor, bgBgColorRGB[0], bgBgColorRGB[1], bgBgColorRGB[2]);
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
function startBackground() {
if (bgAnimFrame) return;
if (!bgGL && !initBackgroundGL()) return;
bgCanvas.classList.add('visible');
document.body.classList.add('dynamic-bg-active');
renderBackgroundFrame();
}
function stopBackground() {
if (bgAnimFrame) {
cancelAnimationFrame(bgAnimFrame);
bgAnimFrame = null;
}
if (bgCanvas) {
bgCanvas.classList.remove('visible');
}
document.body.classList.remove('dynamic-bg-active');
}
// ---- Public API ----
function toggleDynamicBackground() {
bgEnabled = !bgEnabled;
localStorage.setItem('dynamicBackground', bgEnabled);
applyDynamicBackground();
}
function applyDynamicBackground() {
const btn = document.getElementById('bgToggle');
if (bgEnabled) {
startBackground();
if (btn) btn.classList.add('active');
} else {
stopBackground();
if (btn) btn.classList.remove('active');
}
}

View File

@@ -0,0 +1,882 @@
// ============================================================
// Media Browser: Navigation, rendering, search, pagination
// ============================================================
// Browser state
let currentFolderId = null;
let currentPath = '';
let currentOffset = 0;
let itemsPerPage = parseInt(localStorage.getItem('mediaBrowser.itemsPerPage')) || 100;
let totalItems = 0;
let mediaFolders = {};
let viewMode = localStorage.getItem('mediaBrowser.viewMode') || 'grid';
let cachedItems = null;
let browserSearchTerm = '';
let browserSearchTimer = null;
const thumbnailCache = new Map();
const THUMBNAIL_CACHE_MAX = 200;
// Load media folders on page load
async function loadMediaFolders() {
try {
const token = localStorage.getItem('media_server_token');
if (!token) {
console.error('No API token found');
return;
}
const response = await fetch('/api/browser/folders', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) throw new Error('Failed to load folders');
mediaFolders = await response.json();
// Load last browsed path or show root folder list
loadLastBrowserPath();
} catch (error) {
console.error('Error loading media folders:', error);
showToast(t('browser.error_loading_folders'), 'error');
}
}
function showRootFolders() {
currentFolderId = '';
currentPath = '';
currentOffset = 0;
cachedItems = null;
// Hide search at root level
showBrowserSearch(false);
// Render breadcrumb with just "Home" (not clickable at root)
const breadcrumb = document.getElementById('breadcrumb');
breadcrumb.innerHTML = '';
const root = document.createElement('span');
root.className = 'breadcrumb-item breadcrumb-home';
root.innerHTML = '<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>';
breadcrumb.appendChild(root);
// Hide play all button and pagination
document.getElementById('playAllBtn').style.display = 'none';
document.getElementById('browserPagination').style.display = 'none';
// Render folders as grid cards
const container = document.getElementById('browserGrid');
revokeBlobUrls(container);
if (viewMode === 'list') {
container.className = 'browser-list';
} else if (viewMode === 'compact') {
container.className = 'browser-grid browser-grid-compact';
} else {
container.className = 'browser-grid';
}
container.innerHTML = '';
Object.entries(mediaFolders).forEach(([id, folder]) => {
if (!folder.enabled) return;
if (viewMode === 'list') {
const row = document.createElement('div');
row.className = 'browser-list-item';
row.onclick = () => {
currentFolderId = id;
browsePath(id, '');
};
row.innerHTML = `
<div class="browser-list-icon">\u{1F4C1}</div>
<div class="browser-list-name">${folder.label}</div>
`;
container.appendChild(row);
} else {
const card = document.createElement('div');
card.className = 'browser-item';
card.onclick = () => {
currentFolderId = id;
browsePath(id, '');
};
card.innerHTML = `
<div class="browser-thumb-wrapper">
<div class="browser-icon">\u{1F4C1}</div>
</div>
<div class="browser-item-info">
<div class="browser-item-name">${folder.label}</div>
</div>
`;
container.appendChild(card);
}
});
}
async function browsePath(folderId, path, offset = 0, nocache = false) {
// Clear search when navigating
showBrowserSearch(false);
try {
const token = localStorage.getItem('media_server_token');
if (!token) {
console.error('No API token found');
return;
}
// Show loading spinner
const container = document.getElementById('browserGrid');
container.className = 'browser-grid';
container.innerHTML = '<div class="browser-loading"><div class="loading-spinner"></div></div>';
const encodedPath = encodeURIComponent(path);
let url = `/api/browser/browse?folder_id=${folderId}&path=${encodedPath}&offset=${offset}&limit=${itemsPerPage}`;
if (nocache) url += '&nocache=true';
const response = await fetch(
url,
{ headers: { 'Authorization': `Bearer ${token}` } }
);
if (!response.ok) {
let errorMsg = 'Failed to browse path';
if (response.status === 503) {
const errorData = await response.json().catch(() => ({}));
errorMsg = errorData.detail || 'Folder is temporarily unavailable (network share not accessible)';
}
throw new Error(errorMsg);
}
const data = await response.json();
currentPath = data.current_path;
currentOffset = offset;
totalItems = data.total;
cachedItems = data.items;
renderBreadcrumbs(data.current_path, data.parent_path);
renderBrowserItems(cachedItems);
renderPagination();
// Show search bar when inside a folder
showBrowserSearch(true);
// Show/hide Play All button based on whether media items exist
const hasMedia = data.items.some(item => item.is_media);
document.getElementById('playAllBtn').style.display = hasMedia ? '' : 'none';
// Save last path
saveLastBrowserPath(folderId, currentPath);
} catch (error) {
console.error('Error browsing path:', error);
const errorMsg = error.message || t('browser.error_loading');
showToast(errorMsg, 'error');
clearBrowserGrid();
}
}
function renderBreadcrumbs(currentPath, parentPath) {
const breadcrumb = document.getElementById('breadcrumb');
breadcrumb.innerHTML = '';
const parts = (currentPath || '').split('/').filter(p => p);
let path = '/';
// Home link (back to folder list)
const home = document.createElement('span');
home.className = 'breadcrumb-item breadcrumb-home';
home.innerHTML = '<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>';
home.onclick = () => showRootFolders();
breadcrumb.appendChild(home);
// Separator + Folder name
const sep = document.createElement('span');
sep.className = 'breadcrumb-separator';
sep.textContent = '\u203A';
breadcrumb.appendChild(sep);
const folderItem = document.createElement('span');
folderItem.className = 'breadcrumb-item';
folderItem.textContent = mediaFolders[currentFolderId]?.label || 'Root';
if (parts.length > 0) {
folderItem.onclick = () => browsePath(currentFolderId, '');
}
breadcrumb.appendChild(folderItem);
// Path parts
parts.forEach((part, index) => {
// Separator
const separator = document.createElement('span');
separator.className = 'breadcrumb-separator';
separator.textContent = '\u203A';
breadcrumb.appendChild(separator);
// Part
path += (path === '/' ? '' : '/') + part;
const item = document.createElement('span');
item.className = 'breadcrumb-item';
item.textContent = part;
const itemPath = path;
item.onclick = () => browsePath(currentFolderId, itemPath);
breadcrumb.appendChild(item);
});
}
function revokeBlobUrls(container) {
const cachedUrls = new Set(thumbnailCache.values());
container.querySelectorAll('img[src^="blob:"]').forEach(img => {
// Don't revoke URLs managed by the thumbnail cache
if (!cachedUrls.has(img.src)) {
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';
renderBrowserList(items, container);
} else if (viewMode === 'compact') {
container.className = 'browser-grid browser-grid-compact';
renderBrowserGrid(items, container);
} else {
container.className = 'browser-grid';
renderBrowserGrid(items, container);
}
}
function renderBrowserList(items, container) {
container.innerHTML = '';
if (!items || items.length === 0) {
container.innerHTML = `<div class="browser-empty">${emptyStateHtml(EMPTY_SVG_FILE, t('browser.no_items'))}</div>`;
return;
}
items.forEach((item, idx) => {
const row = document.createElement('div');
row.className = 'browser-list-item';
row.style.setProperty('--item-index', Math.min(idx, 20));
row.dataset.name = item.name;
row.dataset.type = item.type;
// Icon (small) with play overlay
const icon = document.createElement('div');
icon.className = 'browser-list-icon';
if (item.is_media && item.type === 'audio') {
const thumbnail = document.createElement('img');
thumbnail.className = 'browser-list-thumbnail loading';
thumbnail.alt = item.name;
icon.appendChild(thumbnail);
loadThumbnail(thumbnail, item.name);
} else {
icon.textContent = getFileIcon(item.type);
}
if (item.is_media) {
const overlay = document.createElement('div');
overlay.className = 'browser-list-play-overlay';
overlay.innerHTML = '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M8 5v14l11-7z"/></svg>';
icon.appendChild(overlay);
}
row.appendChild(icon);
// Name (show media title if available)
const name = document.createElement('div');
name.className = 'browser-list-name';
name.textContent = item.title || item.name;
row.appendChild(name);
// Bitrate
const br = document.createElement('div');
br.className = 'browser-list-bitrate';
br.textContent = formatBitrate(item.bitrate) || '';
row.appendChild(br);
// Duration
const dur = document.createElement('div');
dur.className = 'browser-list-duration';
dur.textContent = formatDuration(item.duration) || '';
row.appendChild(dur);
// Size
const size = document.createElement('div');
size.className = 'browser-list-size';
size.textContent = (item.size !== null && item.type !== 'folder') ? formatFileSize(item.size) : '';
row.appendChild(size);
// Download button
if (item.is_media) {
row.appendChild(createDownloadBtn(item.name, 'browser-list-download'));
} else {
row.appendChild(document.createElement('div'));
}
// Tooltip: show filename when title is displayed, or when name is ellipsed
row.addEventListener('mouseenter', () => {
if (item.title || name.scrollWidth > name.clientWidth) {
row.title = item.name;
} else {
row.title = '';
}
});
// Single click: play media or navigate folder
row.onclick = () => {
if (item.type === 'folder') {
const newPath = currentPath === '/'
? '/' + item.name
: currentPath + '/' + item.name;
browsePath(currentFolderId, newPath);
} else if (item.is_media) {
playMediaFile(item.name);
}
};
container.appendChild(row);
});
}
function renderBrowserGrid(items, container) {
container = container || document.getElementById('browserGrid');
container.innerHTML = '';
if (!items || items.length === 0) {
container.innerHTML = `<div class="browser-empty">${emptyStateHtml(EMPTY_SVG_FILE, t('browser.no_items'))}</div>`;
return;
}
items.forEach((item, idx) => {
const div = document.createElement('div');
div.className = 'browser-item';
div.style.setProperty('--item-index', Math.min(idx, 20));
div.dataset.name = item.name;
div.dataset.type = item.type;
// Type badge
if (item.type !== 'folder') {
const typeBadge = document.createElement('div');
typeBadge.className = `browser-item-type ${item.type}`;
typeBadge.innerHTML = getTypeBadgeIcon(item.type);
div.appendChild(typeBadge);
}
// Thumbnail wrapper (for play overlay)
const thumbWrapper = document.createElement('div');
thumbWrapper.className = 'browser-thumb-wrapper';
// Thumbnail or icon
if (item.is_media && item.type === 'audio') {
const thumbnail = document.createElement('img');
thumbnail.className = 'browser-thumbnail loading';
thumbnail.alt = item.name;
thumbWrapper.appendChild(thumbnail);
// Lazy load thumbnail
loadThumbnail(thumbnail, item.name);
} else {
const icon = document.createElement('div');
icon.className = 'browser-icon';
icon.textContent = getFileIcon(item.type);
thumbWrapper.appendChild(icon);
}
// Play overlay for media files
if (item.is_media) {
const overlay = document.createElement('div');
overlay.className = 'browser-play-overlay';
overlay.innerHTML = '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M8 5v14l11-7z"/></svg>';
thumbWrapper.appendChild(overlay);
}
div.appendChild(thumbWrapper);
// Info
const info = document.createElement('div');
info.className = 'browser-item-info';
const name = document.createElement('div');
name.className = 'browser-item-name';
name.textContent = item.title || item.name;
info.appendChild(name);
if (item.type !== 'folder') {
const meta = document.createElement('div');
meta.className = 'browser-item-meta';
const parts = [];
const duration = formatDuration(item.duration);
if (duration) parts.push(duration);
const bitrate = formatBitrate(item.bitrate);
if (bitrate) parts.push(bitrate);
if (item.size !== null) parts.push(formatFileSize(item.size));
meta.textContent = parts.join(' \u00B7 ');
if (parts.length) info.appendChild(meta);
}
div.appendChild(info);
// Tooltip: show filename when title is displayed, or when name is ellipsed
div.addEventListener('mouseenter', () => {
if (item.title || name.scrollWidth > name.clientWidth || name.scrollHeight > name.clientHeight) {
div.title = item.name;
} else {
div.title = '';
}
});
// Single click: play media or navigate folder
div.onclick = () => {
if (item.type === 'folder') {
const newPath = currentPath === '/'
? '/' + item.name
: currentPath + '/' + item.name;
browsePath(currentFolderId, newPath);
} else if (item.is_media) {
playMediaFile(item.name);
}
};
container.appendChild(div);
});
}
function getTypeBadgeIcon(type) {
const svgs = {
'audio': '<svg viewBox="0 0 24 24" width="10" height="10"><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>',
'video': '<svg viewBox="0 0 24 24" width="10" height="10"><path fill="currentColor" d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg>',
};
return svgs[type] || '';
}
function getFileIcon(type) {
const icons = {
'folder': '\u{1F4C1}',
'audio': '\u{1F3B5}',
'video': '\u{1F3AC}',
'other': '\u{1F4C4}'
};
return icons[type] || icons.other;
}
function formatFileSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return (bytes / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i];
}
function formatDuration(seconds) {
if (seconds == null || seconds <= 0) return null;
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) {
return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
return `${m}:${String(s).padStart(2, '0')}`;
}
function formatBitrate(bps) {
if (bps == null || bps <= 0) return null;
return Math.round(bps / 1000) + ' kbps';
}
async function loadThumbnail(imgElement, fileName) {
try {
const token = localStorage.getItem('media_server_token');
if (!token) {
console.error('No API token found');
return;
}
const absolutePath = buildAbsolutePath(currentFolderId, currentPath, fileName);
// Check cache first
if (thumbnailCache.has(absolutePath)) {
const cachedUrl = thumbnailCache.get(absolutePath);
imgElement.onload = () => {
imgElement.classList.remove('loading');
imgElement.classList.add('loaded');
};
imgElement.src = cachedUrl;
return;
}
const encodedPath = encodeURIComponent(absolutePath);
const response = await fetch(
`/api/browser/thumbnail?path=${encodedPath}&size=medium`,
{ headers: { 'Authorization': `Bearer ${token}` } }
);
if (response.status === 200) {
const blob = await response.blob();
const url = URL.createObjectURL(blob);
thumbnailCache.set(absolutePath, url);
// Evict oldest entries when cache exceeds limit
if (thumbnailCache.size > THUMBNAIL_CACHE_MAX) {
const oldest = thumbnailCache.keys().next().value;
URL.revokeObjectURL(thumbnailCache.get(oldest));
thumbnailCache.delete(oldest);
}
// Wait for image to actually load before showing it
imgElement.onload = () => {
imgElement.classList.remove('loading');
imgElement.classList.add('loaded');
};
// Revoke previous blob URL if not managed by cache
// (Cache is keyed by path, so check values)
if (imgElement.src && imgElement.src.startsWith('blob:')) {
let isCached = false;
for (const url of thumbnailCache.values()) {
if (url === imgElement.src) { isCached = true; break; }
}
if (!isCached) URL.revokeObjectURL(imgElement.src);
}
imgElement.src = url;
} else {
// Fallback to icon (204 = no thumbnail available)
const parent = imgElement.parentElement;
const isList = parent.classList.contains('browser-list-icon');
imgElement.remove();
if (isList) {
parent.textContent = '\u{1F3B5}';
} else {
const icon = document.createElement('div');
icon.className = 'browser-icon';
icon.textContent = '\u{1F3B5}';
parent.insertBefore(icon, parent.firstChild);
}
}
} catch (error) {
console.error('Error loading thumbnail:', error);
imgElement.classList.remove('loading');
}
}
function buildAbsolutePath(folderId, relativePath, fileName) {
const folderPath = mediaFolders[folderId].path;
// Detect separator from folder path
const sep = folderPath.includes('/') ? '/' : '\\';
const fullRelative = relativePath === '/'
? sep + fileName
: relativePath.replace(/[/\\]/g, sep) + sep + fileName;
return folderPath + fullRelative;
}
let playInProgress = false;
async function playMediaFile(fileName) {
if (playInProgress) return;
playInProgress = true;
try {
const token = localStorage.getItem('media_server_token');
if (!token) {
console.error('No API token found');
return;
}
const absolutePath = buildAbsolutePath(currentFolderId, currentPath, fileName);
const response = await fetch('/api/browser/play', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ path: absolutePath })
});
if (!response.ok) throw new Error('Failed to play file');
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;
const response = await fetch('/api/browser/play-folder', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ folder_id: currentFolderId, path: currentPath })
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.detail || 'Failed to play folder');
}
const data = await response.json();
showToast(t('browser.play_all_success', { count: data.count }), 'success');
} catch (error) {
console.error('Error playing folder:', error);
showToast(t('browser.play_all_error'), 'error');
} finally {
playInProgress = false;
if (btn) btn.disabled = false;
}
}
async function downloadFile(fileName, event) {
if (event) event.stopPropagation();
const token = localStorage.getItem('media_server_token');
if (!token) return;
const fullPath = currentPath === '/'
? '/' + fileName
: currentPath + '/' + fileName;
const encodedPath = encodeURIComponent(fullPath);
try {
const response = await fetch(
`/api/browser/download?folder_id=${currentFolderId}&path=${encodedPath}`,
{ headers: { 'Authorization': `Bearer ${token}` } }
);
if (!response.ok) throw new Error('Download failed');
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (error) {
console.error('Download error:', error);
showToast(t('browser.download_error'), 'error');
}
}
function createDownloadBtn(fileName, cssClass) {
const btn = document.createElement('button');
btn.className = cssClass;
btn.innerHTML = '<svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/></svg>';
btn.title = t('browser.download');
btn.onclick = (e) => downloadFile(fileName, e);
return btn;
}
function renderPagination() {
const pagination = document.getElementById('browserPagination');
const prevBtn = document.getElementById('prevPage');
const nextBtn = document.getElementById('nextPage');
const pageInput = document.getElementById('pageInput');
const pageTotal = document.getElementById('pageTotal');
const totalPages = Math.ceil(totalItems / itemsPerPage);
const currentPage = Math.floor(currentOffset / itemsPerPage) + 1;
if (totalPages <= 1) {
pagination.style.display = 'none';
return;
}
pagination.style.display = 'flex';
pageInput.value = currentPage;
pageInput.max = totalPages;
pageTotal.textContent = `/ ${totalPages}`;
prevBtn.disabled = currentPage === 1;
nextBtn.disabled = currentPage === totalPages;
}
function previousPage() {
if (currentOffset >= itemsPerPage) {
browsePath(currentFolderId, currentPath, currentOffset - itemsPerPage);
}
}
function nextPage() {
if (currentOffset + itemsPerPage < totalItems) {
browsePath(currentFolderId, currentPath, currentOffset + itemsPerPage);
}
}
function refreshBrowser() {
if (currentFolderId) {
browsePath(currentFolderId, currentPath, currentOffset, true);
} else {
loadMediaFolders();
}
}
// Browser search
function onBrowserSearch() {
const input = document.getElementById('browserSearchInput');
const clearBtn = document.getElementById('browserSearchClear');
const term = input.value.trim();
clearBtn.style.display = term ? 'flex' : 'none';
// Debounce: wait 200ms after typing stops
if (browserSearchTimer) clearTimeout(browserSearchTimer);
browserSearchTimer = setTimeout(() => {
browserSearchTerm = term.toLowerCase();
applyBrowserSearch();
}, SEARCH_DEBOUNCE_MS);
}
function clearBrowserSearch() {
const input = document.getElementById('browserSearchInput');
input.value = '';
document.getElementById('browserSearchClear').style.display = 'none';
browserSearchTerm = '';
applyBrowserSearch();
input.focus();
}
function applyBrowserSearch() {
if (!cachedItems) return;
if (!browserSearchTerm) {
renderBrowserItems(cachedItems);
return;
}
const filtered = cachedItems.filter(item =>
item.name.toLowerCase().includes(browserSearchTerm) ||
(item.title && item.title.toLowerCase().includes(browserSearchTerm))
);
renderBrowserItems(filtered);
}
function showBrowserSearch(visible) {
document.getElementById('browserSearchWrapper').style.display = visible ? '' : 'none';
if (!visible) {
document.getElementById('browserSearchInput').value = '';
document.getElementById('browserSearchClear').style.display = 'none';
browserSearchTerm = '';
}
}
function setViewMode(mode) {
if (mode === viewMode) return;
viewMode = mode;
localStorage.setItem('mediaBrowser.viewMode', mode);
// Update toggle buttons
document.querySelectorAll('.view-toggle-btn').forEach(btn => btn.classList.remove('active'));
const btnId = mode === 'list' ? 'viewListBtn' : mode === 'compact' ? 'viewCompactBtn' : 'viewGridBtn';
document.getElementById(btnId).classList.add('active');
// Re-render current view from cache (no network request)
if (currentFolderId && cachedItems) {
applyBrowserSearch();
} else {
showRootFolders();
}
}
function onItemsPerPageChanged() {
const select = document.getElementById('itemsPerPageSelect');
itemsPerPage = parseInt(select.value);
localStorage.setItem('mediaBrowser.itemsPerPage', itemsPerPage);
// Reset to first page and reload
if (currentFolderId) {
currentOffset = 0;
browsePath(currentFolderId, currentPath, 0);
}
}
function goToPage() {
const pageInput = document.getElementById('pageInput');
const totalPages = Math.ceil(totalItems / itemsPerPage);
let page = parseInt(pageInput.value);
if (isNaN(page) || page < 1) page = 1;
if (page > totalPages) page = totalPages;
pageInput.value = page;
const newOffset = (page - 1) * itemsPerPage;
if (newOffset !== currentOffset) {
browsePath(currentFolderId, currentPath, newOffset);
}
}
function initBrowserToolbar() {
// Restore view mode
const savedViewMode = localStorage.getItem('mediaBrowser.viewMode') || 'grid';
viewMode = savedViewMode;
document.querySelectorAll('.view-toggle-btn').forEach(btn => btn.classList.remove('active'));
const btnId = savedViewMode === 'list' ? 'viewListBtn' : savedViewMode === 'compact' ? 'viewCompactBtn' : 'viewGridBtn';
document.getElementById(btnId).classList.add('active');
// Restore items per page
const savedItemsPerPage = localStorage.getItem('mediaBrowser.itemsPerPage');
if (savedItemsPerPage) {
itemsPerPage = parseInt(savedItemsPerPage);
document.getElementById('itemsPerPageSelect').value = savedItemsPerPage;
}
}
function clearBrowserGrid() {
const grid = document.getElementById('browserGrid');
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';
}
// LocalStorage for last path
function saveLastBrowserPath(folderId, path) {
try {
localStorage.setItem('mediaBrowser.lastFolderId', folderId);
localStorage.setItem('mediaBrowser.lastPath', path);
} catch (e) {
console.error('Failed to save last browser path:', e);
}
}
function loadLastBrowserPath() {
try {
const lastFolderId = localStorage.getItem('mediaBrowser.lastFolderId');
const lastPath = localStorage.getItem('mediaBrowser.lastPath');
if (lastFolderId && mediaFolders[lastFolderId]) {
currentFolderId = lastFolderId;
browsePath(lastFolderId, lastPath || '');
} else {
showRootFolders();
}
} catch (e) {
console.error('Failed to load last browser path:', e);
showRootFolders();
}
}
// Folder Management
function showManageFoldersDialog() {
// TODO: Implement folder management UI
// For now, show a simple alert
showToast(t('browser.manage_folders_hint'), 'info');
}
function closeFolderDialog() {
closeDialog(document.getElementById('folderDialog'));
}
async function saveFolder(event) {
event.preventDefault();
// TODO: Implement folder save functionality
closeFolderDialog();
}

View File

@@ -0,0 +1,209 @@
// ============================================================
// Callbacks: CRUD management
// ============================================================
let callbackFormDirty = false;
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');
try {
const response = await fetch('/api/callbacks/list', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) {
throw new Error('Failed to fetch callbacks');
}
const callbacksList = await response.json();
if (callbacksList.length === 0) {
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;
}
tbody.innerHTML = callbacksList.map(callback => `
<tr>
<td><code>${escapeHtml(callback.name)}</code></td>
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"
title="${escapeHtml(callback.command)}">${escapeHtml(callback.command)}</td>
<td>${callback.timeout}s</td>
<td>
<div class="action-buttons">
<button class="action-btn execute" data-action="execute" data-callback-name="${escapeHtml(callback.name)}" title="Execute callback">
<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
</button>
<button class="action-btn" data-action="edit" data-callback-name="${escapeHtml(callback.name)}" title="Edit callback">
<svg viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
</button>
<button class="action-btn delete" data-action="delete" data-callback-name="${escapeHtml(callback.name)}" title="Delete callback">
<svg viewBox="0 0 24 24"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
</button>
</div>
</td>
</tr>
`).join('');
} catch (error) {
console.error('Error loading callbacks:', error);
tbody.innerHTML = '<tr><td colspan="4" class="empty-state" style="color: var(--error);">Failed to load callbacks</td></tr>';
}
}
function showAddCallbackDialog() {
const dialog = document.getElementById('callbackDialog');
const form = document.getElementById('callbackForm');
const title = document.getElementById('callbackDialogTitle');
form.reset();
document.getElementById('callbackIsEdit').value = 'false';
document.getElementById('callbackName').disabled = false;
title.textContent = t('callbacks.dialog.add');
callbackFormDirty = false;
document.body.classList.add('dialog-open');
dialog.showModal();
}
async function showEditCallbackDialog(callbackName) {
const token = localStorage.getItem('media_server_token');
const dialog = document.getElementById('callbackDialog');
const title = document.getElementById('callbackDialogTitle');
try {
const response = await fetch('/api/callbacks/list', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) {
throw new Error('Failed to fetch callback details');
}
const callbacksList = await response.json();
const callback = callbacksList.find(c => c.name === callbackName);
if (!callback) {
showToast('Callback not found', 'error');
return;
}
document.getElementById('callbackIsEdit').value = 'true';
document.getElementById('callbackName').value = callbackName;
document.getElementById('callbackName').disabled = true;
document.getElementById('callbackCommand').value = callback.command;
document.getElementById('callbackTimeout').value = callback.timeout;
document.getElementById('callbackWorkingDir').value = callback.working_dir || '';
title.textContent = t('callbacks.dialog.edit');
callbackFormDirty = false;
document.body.classList.add('dialog-open');
dialog.showModal();
} catch (error) {
console.error('Error loading callback for edit:', error);
showToast('Failed to load callback details', 'error');
}
}
async function closeCallbackDialog() {
if (callbackFormDirty) {
if (!await showConfirm(t('callbacks.confirm.unsaved'))) {
return;
}
}
const dialog = document.getElementById('callbackDialog');
callbackFormDirty = false;
closeDialog(dialog);
document.body.classList.remove('dialog-open');
}
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;
const data = {
command: document.getElementById('callbackCommand').value,
timeout: parseInt(document.getElementById('callbackTimeout').value) || 30,
working_dir: document.getElementById('callbackWorkingDir').value || null,
shell: true
};
const endpoint = isEdit ?
`/api/callbacks/update/${callbackName}` :
`/api/callbacks/create/${callbackName}`;
const method = isEdit ? 'PUT' : 'POST';
try {
const response = await fetch(endpoint, {
method,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
const result = await response.json();
if (response.ok && result.success) {
showToast(`Callback ${isEdit ? 'updated' : 'created'} successfully`, 'success');
callbackFormDirty = false;
closeCallbackDialog();
loadCallbacksTable();
} else {
showToast(result.detail || `Failed to ${isEdit ? 'update' : 'create'} callback`, 'error');
}
} catch (error) {
console.error('Error saving callback:', error);
showToast(`Error ${isEdit ? 'updating' : 'creating'} callback`, 'error');
} finally {
if (submitBtn) submitBtn.disabled = false;
}
}
async function deleteCallbackConfirm(callbackName) {
if (!await showConfirm(t('callbacks.confirm.delete').replace('{name}', callbackName))) {
return;
}
const token = localStorage.getItem('media_server_token');
try {
const response = await fetch(`/api/callbacks/delete/${callbackName}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
});
const result = await response.json();
if (response.ok && result.success) {
showToast('Callback deleted successfully', 'success');
loadCallbacksTable();
} else {
showToast(result.detail || 'Failed to delete callback', 'error');
}
} catch (error) {
console.error('Error deleting callback:', error);
showToast('Error deleting callback', 'error');
}
}

View File

@@ -0,0 +1,503 @@
// ============================================================
// Core: Shared state, constants, utilities, i18n, API commands
// ============================================================
// 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>`;
}
// Media source registry: substring key → { name, icon }
const MEDIA_SOURCES = {
'spotify': {
name: 'Spotify',
icon: '<svg viewBox="0 0 24 24"><path fill="#1DB954" d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z"/></svg>'
},
'yandex music': {
name: 'Yandex Music',
icon: '<svg viewBox="0 0 24 24"><path fill="#FFCC00" d="M12 0C5.376 0 0 5.376 0 12s5.376 12 12 12 12-5.376 12-12S18.624 0 12 0zm0 2.4a9.6 9.6 0 110 19.2 9.6 9.6 0 010-19.2z"/><path fill="#FFCC00" d="M13.2 6h-2.4v7.2L7.2 6H4.8l5.4 12h1.2l.6-1.35V6z"/></svg>'
},
'яндекс музыка': {
name: 'Яндекс Музыка',
icon: '<svg viewBox="0 0 24 24"><path fill="#FFCC00" d="M12 0C5.376 0 0 5.376 0 12s5.376 12 12 12 12-5.376 12-12S18.624 0 12 0zm0 2.4a9.6 9.6 0 110 19.2 9.6 9.6 0 010-19.2z"/><path fill="#FFCC00" d="M13.2 6h-2.4v7.2L7.2 6H4.8l5.4 12h1.2l.6-1.35V6z"/></svg>'
},
'chrome': {
name: 'Google Chrome',
icon: '<svg viewBox="0 0 24 24"><circle fill="#4587F3" cx="12" cy="12" r="11"/><path fill="#DB4437" d="M12 1C7.2 1 3.1 3.8 1.3 7.9L7.7 12l1.8-3.1c.7-1.1 1.9-1.9 3.3-1.9h9.7C21 3.5 16.9 1 12 1z"/><path fill="#0F9D58" d="M7.7 12L1.3 7.9C.5 9.2 0 10.6 0 12c0 4.5 2.8 8.4 6.8 10l3.8-6.6L7.7 12z"/><path fill="#FFCD40" d="M6.8 22c2.7 1.5 6.4 1.7 9.4.2 2.8-1.4 4.9-3.9 5.8-6.8l-6.5-3.4-1.8 3.1c-.7 1.1-1.9 1.9-3.3 1.9-.9 0-1.7-.3-2.4-.7L6.8 22z"/><circle fill="#F1F1F1" cx="12" cy="12" r="4.8"/><circle fill="#4587F3" cx="12" cy="12" r="3.8"/></svg>'
},
'msedge': {
name: 'Microsoft Edge',
icon: '<svg viewBox="0 0 24 24"><path fill="#0078D4" d="M21.86 17.86q.14 0 .25-.12.1-.13.1-.25 0-.06 0-.13-.12-.76-.39-1.49-.26-.72-.65-1.39-.4-.66-.92-1.25-.53-.58-1.15-1.06-.61-.48-1.3-.85-.69-.37-1.44-.6-.75-.22-1.53-.3-.8-.07-1.6 0h-.04q-.51.03-1.03.14-.5.12-1 .31-.49.2-.95.46-.46.27-.89.6-.42.32-.8.7-.37.4-.69.83-.31.44-.57.92-.25.49-.44 1 .09-.14.21-.28.12-.14.26-.27.14-.12.3-.23.16-.1.33-.18.18-.08.37-.14.18-.06.38-.08.2-.02.4-.01.21.01.41.06.28.07.53.2.25.12.47.3.21.18.39.4.18.21.32.45.14.25.23.52.1.26.14.54.04.28.02.56-.02.36-.12.72-.1.35-.27.68-.17.33-.4.62-.24.3-.52.56-.28.25-.6.46-.32.2-.67.35.44.1.9.14.44.03.89-.02.45-.05.88-.17.44-.12.85-.3.41-.2.79-.44.37-.25.71-.55.34-.3.63-.65.3-.35.54-.73.24-.39.42-.8.18-.42.3-.86.12-.43.18-.88.06-.45.06-.9 0-.48-.07-.95-.07-.47-.22-.93z"/><path fill="#50E6FF" d="M11.89.03Q10.03.17 8.3.88 6.57 1.59 5.1 2.77 3.65 3.94 2.55 5.5 1.44 7.06.79 8.88.14 10.7 0 12.65q.01.22.02.45 0 .22.03.44.04.42.12.83.08.42.2.83.12.4.28.79.16.39.36.76.2.37.43.72.24.34.51.66.27.32.57.6.3.29.63.54.33.25.68.46.35.21.72.38.38.17.77.28.39.12.79.18.41.06.82.05.41 0 .82-.07.41-.08.79-.22.39-.14.74-.34.36-.2.68-.44.33-.25.6-.54.28-.3.5-.63.23-.33.4-.7.17-.36.27-.75-1.1.9-2.44 1.36-1.33.46-2.77.46-1.26 0-2.44-.39-1.18-.39-2.17-1.08-1-1.08-1.6-2.02-.6-.94-.87-2-.27-1.07-.25-2.2.02-.55.12-1.08.1-.54.29-1.05.18-.52.44-1 .27-.49.6-.94.34-.44.74-.83.4-.38.85-.71.45-.32.94-.57.49-.25 1.02-.42.52-.16 1.07-.24.55-.07 1.1-.05.81.04 1.57.25.77.2 1.46.56.7.36 1.29.85.6.5 1.07 1.1.48.6.82 1.29.34.69.54 1.44.2.76.24 1.55.04.79-.08 1.57-.11.78-.37 1.52-.26.74-.66 1.4-.39.67-.91 1.24-.52.57-1.14 1.02-.62.44-1.32.76-.7.32-1.45.49-.75.16-1.52.18z"/></svg>'
},
'firefox': {
name: 'Firefox',
icon: '<svg viewBox="0 0 24 24"><path fill="#FF7139" d="M12 0C5.37 0 0 5.37 0 12s5.37 12 12 12 12-5.37 12-12S18.63 0 12 0zm6.73 7.27c-.47-.77-1.22-1.6-1.7-1.87.54.97.86 2.07.93 3.15 0 0-.02.03-.02.05-.38-1.34-1.14-2.15-1.78-3.05-.03-.05-.06-.1-.1-.15-.03-.05-.05-.1-.06-.15 0-.02-.01-.04-.02-.05l-.01.02c-.02.03-.03.05-.04.08 0 0 0 .01-.01.02l.01-.02c-.64 1.07-1.72 2.2-2.1 3.56-.46.01-.9.09-1.32.23l-.06.03c-.03-.2-.04-.4-.04-.6 0-.67.15-1.3.4-1.87-1.08.4-1.93 1.12-2.53 1.72-.33-.36-.36-1.56-.34-1.8-.01 0-.03.02-.04.02-.27.2-.52.42-.75.66-.28.3-.53.62-.76.96-.12.2-.24.4-.34.6-.15.32-.27.66-.36 1-.02.07-.03.14-.05.21v.03c-.06.3-.1.6-.12.9v.1c0 .07 0 .14-.01.21C7.3 13.8 7.52 16.37 9 18.26l.04.05c-1.55-1-2.57-2.64-2.87-4.42-.04.2-.06.4-.07.6-.01.2-.02.4-.01.6.02.6.13 1.2.3 1.77.2.57.46 1.12.8 1.62.17.25.36.48.56.7.2.22.42.43.66.62 1.83 1.47 4.17 1.87 6.34 1.21.26-.08.5-.17.74-.28 1.1-.5 2.06-1.27 2.78-2.23.03-.03.05-.07.07-.1.08-.1.15-.2.22-.32.5-.77.84-1.62 1.02-2.5.02-.1.04-.2.05-.3.1-.57.14-1.15.12-1.73 0-.1-.01-.19-.02-.29.06-1.2-.15-2.42-.63-3.53-.1-.23-.2-.45-.32-.67z"/></svg>'
},
'opera': {
name: 'Opera',
icon: '<svg viewBox="0 0 24 24"><path fill="#FF1B2D" d="M12 0C5.37 0 0 5.37 0 12s5.37 12 12 12c2.75 0 5.28-.93 7.3-2.49-1.24.77-2.68 1.22-4.22 1.22-2.2 0-4.17-1.1-5.55-2.83C8.1 18.1 7.2 15.22 7.2 12s.9-6.1 2.33-7.9C10.91 2.37 12.88 1.27 15.08 1.27c1.54 0 2.98.45 4.22 1.22C17.28.93 14.75 0 12 0z"/><path fill="#FF1B2D" d="M15.08 1.27c-2.2 0-4.17 1.1-5.55 2.83C8.1 5.9 7.2 8.78 7.2 12s.9 6.1 2.33 7.9c1.38 1.73 3.35 2.83 5.55 2.83 2.2 0 4.17-1.1 5.55-2.83C22.06 18.1 22.96 15.22 22.96 12s-.9-6.1-2.33-7.9c-1.38-1.73-3.35-2.83-5.55-2.83z" opacity=".75"/></svg>'
},
'brave': {
name: 'Brave',
icon: '<svg viewBox="0 0 24 24"><path fill="#FB542B" d="M12 0L3.6 4.8v9.6L12 24l8.4-9.6V4.8L12 0zm5.7 14.1l-1.2 1.8c-.3.3-.6.6-.9.9l-2.1 1.5-1.5.9-1.5-.9-2.1-1.5c-.3-.3-.6-.6-.9-.9l-1.2-1.8c-.3-.6-.3-1.2 0-1.5l.6-1.5.6-1.2.6-1.2.3-.6c.15-.3.45-.3.6 0l.6.9c.15.3.45.3.6 0l.6-.9.6-.9c.15-.3.45-.3.6 0l.6.9.6.9c.15.3.45.3.6 0l.6-.9c.15-.3.45-.3.6 0l.3.6.6 1.2.6 1.2.6 1.5c.3.3.3.9 0 1.5z"/></svg>'
},
'yandex': {
name: 'Yandex Browser',
icon: '<svg viewBox="0 0 24 24"><path fill="#FF0000" d="M12 0C5.37 0 0 5.37 0 12s5.37 12 12 12 12-5.37 12-12S18.63 0 12 0z"/><path fill="#FFF" d="M13.5 5h-2.1l-3.9 8.1V5H5.4v14h2.1l4.05-8.55V19h2.1V5z"/></svg>'
},
'vlc': {
name: 'VLC',
icon: '<svg viewBox="0 0 24 24"><path fill="#FF8800" d="M12 1.5L7.5 16h9L12 1.5z"/><path fill="#FF5722" d="M6 18.5c-1.5 0-2.5.5-2.5 1.5s2.5 2.5 8.5 2.5 8.5-1.5 8.5-2.5-1-1.5-2.5-1.5H6z"/><path fill="#FF8800" d="M6 18.5h12l-1.5-2.5h-9L6 18.5z"/></svg>'
},
'aimp': {
name: 'AIMP',
icon: '<svg viewBox="0 0 24 24"><path fill="#F7A600" d="M12 0C5.37 0 0 5.37 0 12s5.37 12 12 12 12-5.37 12-12S18.63 0 12 0z"/><path fill="#FFF" d="M12 4l-7 14h3l1.5-3h5l1.5 3h3L12 4zm0 5l1.75 3.5h-3.5L12 9z"/></svg>'
},
'foobar': {
name: 'foobar2000',
icon: '<svg viewBox="0 0 24 24"><rect fill="#1F1A17" width="24" height="24" rx="4"/><path fill="#D89B2B" d="M6 6h3v12H6V6zm4.5 0H13v12h-2.5V6zm4 0H17v12h-2.5V6z"/></svg>'
},
'music.ui': {
name: 'Groove Music',
icon: '<svg viewBox="0 0 24 24"><circle fill="#7B83EB" cx="12" cy="12" r="11"/><path fill="#FFF" d="M15 7v7a3 3 0 11-2-2.83V7h2z"/></svg>'
},
'itunes': {
name: 'iTunes',
icon: '<svg viewBox="0 0 24 24"><path fill="#EA4CC0" d="M12 0C5.37 0 0 5.37 0 12s5.37 12 12 12 12-5.37 12-12S18.63 0 12 0z"/><path fill="#FFF" d="M16.5 6.5l-7 1.75v7.25a2.5 2.5 0 11-1.5-2.29V9.5l7-1.75v4.75a2.5 2.5 0 11-1.5-2.29V6.5z" opacity=".9"/></svg>'
},
'apple music': {
name: 'Apple Music',
icon: '<svg viewBox="0 0 24 24"><path fill="#FC3C44" d="M12 0C5.37 0 0 5.37 0 12s5.37 12 12 12 12-5.37 12-12S18.63 0 12 0z"/><path fill="#FFF" d="M16.5 6.5l-7 1.75v7.25a2.5 2.5 0 11-1.5-2.29V9.5l7-1.75v4.75a2.5 2.5 0 11-1.5-2.29V6.5z" opacity=".9"/></svg>'
},
'deezer': {
name: 'Deezer',
icon: '<svg viewBox="0 0 24 24"><rect fill="#000" width="24" height="24" rx="4"/><g fill="#A238FF"><rect x="2" y="16" width="3" height="2" rx=".5"/><rect x="6.5" y="14" width="3" height="4" rx=".5"/><rect x="11" y="10" width="3" height="8" rx=".5"/><rect x="15.5" y="12" width="3" height="6" rx=".5"/><rect x="19" y="8" width="3" height="10" rx=".5"/></g></svg>'
},
'tidal': {
name: 'TIDAL',
icon: '<svg viewBox="0 0 24 24"><path fill="#000" d="M12 4.8L8 8.8l4 4-4 4-4-4 4-4-4-4 4-4 4 4zm4 0l4 4-4 4-4-4 4-4z"/></svg>'
},
};
function resolveMediaSource(raw) {
if (!raw) return null;
const lower = raw.toLowerCase();
for (const [key, info] of Object.entries(MEDIA_SOURCES)) {
if (lower.includes(key)) return info;
}
return { name: raw.replace(/\.exe$/i, ''), icon: null };
}
// 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.sourceIcon = document.getElementById('sourceIcon');
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_BACKOFF_BASE_MS = 3000;
const WS_BACKOFF_MAX_MS = 30000;
const WS_MAX_RECONNECT_ATTEMPTS = 20;
const WS_PING_INTERVAL_MS = 30000;
const VOLUME_RELEASE_DELAY_MS = 500;
// Shared state (accessed across multiple modules)
let ws = null;
let currentState = 'idle';
let currentDuration = 0;
let currentPosition = 0;
let isUserAdjustingVolume = false;
let volumeUpdateTimer = null;
let scripts = [];
let lastStatus = null;
let currentPlayState = 'idle';
// ============================================================
// Internationalization (i18n)
// ============================================================
let currentLocale = 'en';
let translations = {};
const supportedLocales = {
'en': 'English',
'ru': 'Русский'
};
// Minimal inline fallback for critical UI elements
const fallbackTranslations = {
'app.title': 'Media Server',
'auth.connect': 'Connect',
'auth.placeholder': 'Enter API Token',
'player.status.connected': 'Connected',
'player.status.disconnected': 'Disconnected'
};
function t(key, params = {}) {
let text = translations[key] || fallbackTranslations[key] || key;
Object.keys(params).forEach(param => {
text = text.replace(new RegExp(`\\{${param}\\}`, 'g'), params[param]);
});
return text;
}
async function loadTranslations(locale) {
try {
const response = await fetch(`/static/locales/${locale}.json`);
if (!response.ok) {
throw new Error(`Failed to load ${locale}.json`);
}
return await response.json();
} catch (error) {
console.error(`Error loading translations for ${locale}:`, error);
if (locale !== 'en') {
return await loadTranslations('en');
}
return {};
}
}
function detectBrowserLocale() {
const browserLang = navigator.language || navigator.languages?.[0] || 'en';
const langCode = browserLang.split('-')[0];
return supportedLocales[langCode] ? langCode : 'en';
}
async function initLocale() {
const savedLocale = localStorage.getItem('locale') || detectBrowserLocale();
await setLocale(savedLocale);
}
async function setLocale(locale) {
if (!supportedLocales[locale]) {
locale = 'en';
}
translations = await loadTranslations(locale);
currentLocale = locale;
document.documentElement.setAttribute('data-locale', locale);
document.documentElement.setAttribute('lang', locale);
localStorage.setItem('locale', locale);
updateAllText();
updateLocaleSelect();
document.body.classList.remove('loading-translations');
document.body.classList.add('translations-loaded');
}
function changeLocale() {
const select = document.getElementById('locale-select');
const newLocale = select.value;
if (newLocale && newLocale !== currentLocale) {
localStorage.setItem('locale', newLocale);
setLocale(newLocale);
}
}
function updateLocaleSelect() {
const select = document.getElementById('locale-select');
if (select) {
select.value = currentLocale;
}
}
function updateAllText() {
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
el.textContent = t(key);
});
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const key = el.getAttribute('data-i18n-placeholder');
el.placeholder = t(key);
});
document.querySelectorAll('[data-i18n-title]').forEach(el => {
const key = el.getAttribute('data-i18n-title');
el.title = t(key);
});
// Re-apply dynamic content with new translations
updatePlaybackState(currentState);
const connected = ws && ws.readyState === WebSocket.OPEN;
updateConnectionStatus(connected);
if (lastStatus) {
const fallbackTitle = lastStatus.state === 'idle' ? t('player.no_media') : t('player.title_unavailable');
document.getElementById('track-title').textContent = lastStatus.title || fallbackTitle;
const initSrc = resolveMediaSource(lastStatus.source);
document.getElementById('source').textContent = initSrc ? initSrc.name : t('player.unknown_source');
document.getElementById('sourceIcon').innerHTML = initSrc?.icon || '';
}
const token = localStorage.getItem('media_server_token');
if (token) {
loadScriptsTable();
loadCallbacksTable();
loadLinksTable();
displayQuickAccess();
}
renderAccentSwatches();
}
async function fetchVersion() {
try {
const response = await fetch('/api/health');
if (response.ok) {
const data = await response.json();
const label = document.getElementById('version-label');
if (data.version) {
label.textContent = `v${data.version}`;
}
}
} catch (error) {
console.error('Error fetching version:', error);
}
}
// ============================================================
// Shared Utilities
// ============================================================
function formatTime(seconds) {
if (!seconds || seconds < 0) return '0:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function showToast(message, type = 'success') {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
container.appendChild(toast);
requestAnimationFrame(() => {
toast.classList.add('show');
});
setTimeout(() => {
toast.classList.remove('show');
toast.addEventListener('transitionend', () => toast.remove(), { once: true });
setTimeout(() => { if (toast.parentNode) toast.remove(); }, 500);
}, TOAST_DURATION_MS);
}
function closeDialog(dialog) {
dialog.classList.add('dialog-closing');
dialog.addEventListener('animationend', () => {
dialog.classList.remove('dialog-closing');
dialog.close();
}, { once: true });
}
function showConfirm(message) {
return new Promise((resolve) => {
const dialog = document.getElementById('confirmDialog');
const msg = document.getElementById('confirmDialogMessage');
const btnCancel = document.getElementById('confirmDialogCancel');
const btnConfirm = document.getElementById('confirmDialogConfirm');
msg.textContent = message;
function cleanup() {
btnCancel.removeEventListener('click', onCancel);
btnConfirm.removeEventListener('click', onConfirm);
dialog.removeEventListener('close', onClose);
closeDialog(dialog);
}
function onCancel() { cleanup(); resolve(false); }
function onConfirm() { cleanup(); resolve(true); }
function onClose() { cleanup(); resolve(false); }
btnCancel.addEventListener('click', onCancel);
btnConfirm.addEventListener('click', onConfirm);
dialog.addEventListener('close', onClose);
dialog.showModal();
});
}
// ============================================================
// API Commands
// ============================================================
async function sendCommand(endpoint, body = null) {
const token = localStorage.getItem('media_server_token');
const options = {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
};
if (body) {
options.body = JSON.stringify(body);
}
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');
}
}
function togglePlayPause() {
if (currentState === 'playing') {
sendCommand('pause');
} else {
sendCommand('play');
}
}
function nextTrack() {
sendCommand('next');
}
function previousTrack() {
sendCommand('previous');
}
let lastSentVolume = -1;
function setVolume(volume) {
if (volume === lastSentVolume) return;
lastSentVolume = volume;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'volume', volume: volume }));
} else {
sendCommand('volume', { volume: volume });
}
}
function toggleMute() {
sendCommand('mute');
}
function seek(position) {
sendCommand('seek', { position: position });
}
// ============================================================
// MDI Icon System
// ============================================================
const mdiIconCache = (() => {
try {
return JSON.parse(localStorage.getItem('mdiIconCache') || '{}');
} catch { return {}; }
})();
function _persistMdiCache() {
try { localStorage.setItem('mdiIconCache', JSON.stringify(mdiIconCache)); } catch {}
}
async function fetchMdiIcon(iconName) {
const name = iconName.replace(/^mdi:/, '');
if (mdiIconCache[name]) return mdiIconCache[name];
try {
const response = await fetch(`https://api.iconify.design/mdi/${name}.svg?width=16&height=16`);
if (response.ok) {
const svg = await response.text();
mdiIconCache[name] = svg;
_persistMdiCache();
return svg;
}
} catch (e) {
console.warn('Failed to fetch MDI icon:', name, e);
}
return '<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>';
}
async function resolveMdiIcons(container) {
const els = container.querySelectorAll('[data-mdi-icon]');
await Promise.all(Array.from(els).map(async (el) => {
const icon = el.dataset.mdiIcon;
if (icon) {
el.innerHTML = await fetchMdiIcon(icon);
}
}));
}
function setupIconPreview(inputId, previewId) {
const input = document.getElementById(inputId);
const preview = document.getElementById(previewId);
if (!input || !preview) return;
let debounceTimer = null;
input.addEventListener('input', () => {
clearTimeout(debounceTimer);
const value = input.value.trim();
if (!value) {
preview.innerHTML = '';
return;
}
debounceTimer = setTimeout(async () => {
const svg = await fetchMdiIcon(value);
if (input.value.trim() === value) {
preview.innerHTML = svg;
}
}, 400);
});
}

View File

@@ -0,0 +1,414 @@
// ============================================================
// Display Brightness & Power Control
// ============================================================
let displayBrightnessTimers = {};
const DISPLAY_THROTTLE_MS = 50;
async function loadDisplayMonitors() {
const token = localStorage.getItem('media_server_token');
if (!token) return;
const container = document.getElementById('displayMonitors');
if (!container) return;
try {
const response = await fetch('/api/display/monitors?refresh=true', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) {
container.innerHTML = `<div class="empty-state-illustration">
<svg viewBox="0 0 64 64"><rect x="8" y="10" width="48" height="32" rx="3" fill="none" stroke="currentColor" stroke-width="2"/><line x1="32" y1="42" x2="32" y2="50" stroke="currentColor" stroke-width="2"/><line x1="22" y1="50" x2="42" y2="50" stroke="currentColor" stroke-width="2"/></svg>
<p data-i18n="display.error">Failed to load monitors</p>
</div>`;
return;
}
const monitors = await response.json();
if (monitors.length === 0) {
container.innerHTML = `<div class="empty-state-illustration">
<svg viewBox="0 0 64 64"><rect x="8" y="10" width="48" height="32" rx="3" fill="none" stroke="currentColor" stroke-width="2"/><line x1="32" y1="42" x2="32" y2="50" stroke="currentColor" stroke-width="2"/><line x1="22" y1="50" x2="42" y2="50" stroke="currentColor" stroke-width="2"/></svg>
<p data-i18n="display.no_monitors">No monitors detected</p>
</div>`;
return;
}
container.innerHTML = '';
monitors.forEach(monitor => {
const card = document.createElement('div');
card.className = 'display-monitor-card';
card.id = `monitor-card-${monitor.id}`;
const brightnessValue = monitor.brightness !== null ? monitor.brightness : 0;
const brightnessDisabled = monitor.brightness === null ? 'disabled' : '';
let powerBtn = '';
if (monitor.power_supported) {
powerBtn = `
<button class="display-power-btn ${monitor.power_on ? 'on' : 'off'}" id="power-btn-${monitor.id}"
onclick="toggleDisplayPower(${monitor.id}, '${monitor.name.replace(/'/g, "\\'")}')"
title="${monitor.power_on ? t('display.power_off') : t('display.power_on')}">
<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M13 3h-2v10h2V3zm4.83 2.17l-1.42 1.42A6.92 6.92 0 0119 12c0 3.87-3.13 7-7 7s-7-3.13-7-7c0-2.27 1.08-4.28 2.76-5.56L6.34 5.02A8.95 8.95 0 003 12c0 4.97 4.03 9 9 9s9-4.03 9-9a8.95 8.95 0 00-3.17-6.83z"/></svg>
</button>`;
}
const details = [monitor.resolution, monitor.manufacturer].filter(Boolean).join(' \u00B7 ');
const detailsHtml = details ? `<span class="display-monitor-details">${details}</span>` : '';
const primaryBadge = monitor.is_primary ? `<span class="display-primary-badge">${t('display.primary')}</span>` : '';
card.innerHTML = `
<div class="display-monitor-header">
<svg class="display-monitor-icon" viewBox="0 0 24 24" width="20" height="20">
<path fill="currentColor" d="M20 3H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h6v2H8v2h8v-2h-2v-2h6c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 12H4V5h16v10z"/>
</svg>
<div class="display-monitor-info">
<span class="display-monitor-name">${monitor.name}${primaryBadge}</span>
${detailsHtml}
</div>
${powerBtn}
</div>
<div class="display-brightness-control">
<svg class="display-brightness-icon" viewBox="0 0 24 24" width="16" height="16">
<path fill="currentColor" d="M20 8.69V4h-4.69L12 .69 8.69 4H4v4.69L.69 12 4 15.31V20h4.69L12 23.31 15.31 20H20v-4.69L23.31 12 20 8.69zM12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6 6 2.69 6 6-2.69 6-6 6zm0-10c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4z"/>
</svg>
<input type="range" class="display-brightness-slider" min="0" max="100" value="${brightnessValue}" ${brightnessDisabled}
oninput="onDisplayBrightnessInput(${monitor.id}, this.value)"
onchange="onDisplayBrightnessChange(${monitor.id}, this.value)">
<span class="display-brightness-value" id="brightness-val-${monitor.id}">${brightnessValue}%</span>
</div>`;
container.appendChild(card);
});
} catch (e) {
console.error('Failed to load display monitors:', e);
}
}
function onDisplayBrightnessInput(monitorId, value) {
const label = document.getElementById(`brightness-val-${monitorId}`);
if (label) label.textContent = `${value}%`;
if (displayBrightnessTimers[monitorId]) clearTimeout(displayBrightnessTimers[monitorId]);
displayBrightnessTimers[monitorId] = setTimeout(() => {
sendDisplayBrightness(monitorId, parseInt(value));
displayBrightnessTimers[monitorId] = null;
}, DISPLAY_THROTTLE_MS);
}
function onDisplayBrightnessChange(monitorId, value) {
if (displayBrightnessTimers[monitorId]) {
clearTimeout(displayBrightnessTimers[monitorId]);
displayBrightnessTimers[monitorId] = null;
}
sendDisplayBrightness(monitorId, parseInt(value));
}
async function sendDisplayBrightness(monitorId, brightness) {
const token = localStorage.getItem('media_server_token');
try {
await fetch(`/api/display/brightness/${monitorId}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ brightness })
});
} catch (e) {
console.error('Failed to set brightness:', e);
}
}
async function toggleDisplayPower(monitorId, monitorName) {
const btn = document.getElementById(`power-btn-${monitorId}`);
const isOn = btn && btn.classList.contains('on');
const newState = !isOn;
const token = localStorage.getItem('media_server_token');
try {
const response = await fetch(`/api/display/power/${monitorId}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ on: newState })
});
const data = await response.json();
if (data.success) {
if (btn) {
btn.classList.toggle('on', newState);
btn.classList.toggle('off', !newState);
btn.title = newState ? t('display.power_off') : t('display.power_on');
}
showToast(newState ? 'Monitor turned on' : 'Monitor turned off', 'success');
} else {
showToast('Failed to change monitor power', 'error');
}
} catch (e) {
console.error('Failed to set display power:', e);
showToast('Failed to change monitor power', 'error');
}
}
// ============================================================
// Header Quick Links
// ============================================================
async function loadHeaderLinks() {
const token = localStorage.getItem('media_server_token');
if (!token) return;
const container = document.getElementById('headerLinks');
if (!container) return;
try {
const response = await fetch('/api/links/list', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) return;
const links = await response.json();
container.innerHTML = '';
for (const link of links) {
const a = document.createElement('a');
a.href = link.url;
a.target = '_blank';
a.rel = 'noopener noreferrer';
a.className = 'header-link';
a.title = link.label || link.url;
const iconSvg = await fetchMdiIcon(link.icon || 'mdi:link');
a.innerHTML = iconSvg;
container.appendChild(a);
}
} catch (e) {
console.warn('Failed to load header links:', e);
}
}
// ============================================================
// Links Management
// ============================================================
let _loadLinksPromise = null;
let linkFormDirty = false;
async function loadLinksTable() {
if (_loadLinksPromise) return _loadLinksPromise;
_loadLinksPromise = _loadLinksTableImpl();
_loadLinksPromise.finally(() => { _loadLinksPromise = null; });
return _loadLinksPromise;
}
async function _loadLinksTableImpl() {
const token = localStorage.getItem('media_server_token');
const tbody = document.getElementById('linksTableBody');
try {
const response = await fetch('/api/links/list', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) {
throw new Error('Failed to fetch links');
}
const linksList = await response.json();
if (linksList.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="empty-state"><div class="empty-state-illustration"><svg viewBox="0 0 64 64"><path d="M26 20a10 10 0 010 14l-6 6a10 10 0 01-14-14l6-6a10 10 0 0114 0" fill="none" stroke="currentColor" stroke-width="2"/><path d="M38 44a10 10 0 010-14l6-6a10 10 0 0114 14l-6 6a10 10 0 01-14 0" fill="none" stroke="currentColor" stroke-width="2"/><path d="M24 40l16-16" stroke="currentColor" stroke-width="2"/></svg><p>' + t('links.empty') + '</p></div></td></tr>';
return;
}
tbody.innerHTML = linksList.map(link => `
<tr>
<td><span class="name-with-icon"><span class="table-icon" data-mdi-icon="${escapeHtml(link.icon || 'mdi:link')}"></span><code>${escapeHtml(link.name)}</code></span></td>
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"
title="${escapeHtml(link.url)}">${escapeHtml(link.url)}</td>
<td>${escapeHtml(link.label || '')}</td>
<td>
<div class="action-buttons">
<button class="action-btn" data-action="edit" data-link-name="${escapeHtml(link.name)}" title="${t('links.button.edit')}">
<svg viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
</button>
<button class="action-btn delete" data-action="delete" data-link-name="${escapeHtml(link.name)}" title="${t('links.button.delete')}">
<svg viewBox="0 0 24 24"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
</button>
</div>
</td>
</tr>
`).join('');
resolveMdiIcons(tbody);
} catch (error) {
console.error('Error loading links:', error);
tbody.innerHTML = '<tr><td colspan="4" class="empty-state" style="color: var(--error);">Failed to load links</td></tr>';
}
}
function showAddLinkDialog() {
const dialog = document.getElementById('linkDialog');
const form = document.getElementById('linkForm');
const title = document.getElementById('linkDialogTitle');
form.reset();
document.getElementById('linkOriginalName').value = '';
document.getElementById('linkIsEdit').value = 'false';
document.getElementById('linkName').disabled = false;
document.getElementById('linkIconPreview').innerHTML = '';
title.textContent = t('links.dialog.add');
linkFormDirty = false;
document.body.classList.add('dialog-open');
dialog.showModal();
}
async function showEditLinkDialog(linkName) {
const token = localStorage.getItem('media_server_token');
const dialog = document.getElementById('linkDialog');
const title = document.getElementById('linkDialogTitle');
try {
const response = await fetch('/api/links/list', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) {
throw new Error('Failed to fetch link details');
}
const linksList = await response.json();
const link = linksList.find(l => l.name === linkName);
if (!link) {
showToast(t('links.msg.not_found'), 'error');
return;
}
document.getElementById('linkOriginalName').value = linkName;
document.getElementById('linkIsEdit').value = 'true';
document.getElementById('linkName').value = linkName;
document.getElementById('linkName').disabled = true;
document.getElementById('linkUrl').value = link.url;
document.getElementById('linkIcon').value = link.icon || '';
document.getElementById('linkLabel').value = link.label || '';
document.getElementById('linkDescription').value = link.description || '';
// Update icon preview
const preview = document.getElementById('linkIconPreview');
if (link.icon) {
fetchMdiIcon(link.icon).then(svg => { preview.innerHTML = svg; });
} else {
preview.innerHTML = '';
}
title.textContent = t('links.dialog.edit');
linkFormDirty = false;
document.body.classList.add('dialog-open');
dialog.showModal();
} catch (error) {
console.error('Error loading link for edit:', error);
showToast(t('links.msg.load_failed'), 'error');
}
}
async function closeLinkDialog() {
if (linkFormDirty) {
if (!await showConfirm(t('links.confirm.unsaved'))) {
return;
}
}
const dialog = document.getElementById('linkDialog');
linkFormDirty = false;
closeDialog(dialog);
document.body.classList.remove('dialog-open');
}
async function saveLink(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('linkIsEdit').value === 'true';
const linkName = isEdit ?
document.getElementById('linkOriginalName').value :
document.getElementById('linkName').value;
const data = {
url: document.getElementById('linkUrl').value,
icon: document.getElementById('linkIcon').value || 'mdi:link',
label: document.getElementById('linkLabel').value || '',
description: document.getElementById('linkDescription').value || ''
};
const endpoint = isEdit ?
`/api/links/update/${linkName}` :
`/api/links/create/${linkName}`;
const method = isEdit ? 'PUT' : 'POST';
try {
const response = await fetch(endpoint, {
method,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
const result = await response.json();
if (response.ok && result.success) {
showToast(t(isEdit ? 'links.msg.updated' : 'links.msg.created'), 'success');
linkFormDirty = false;
closeLinkDialog();
} else {
showToast(result.detail || t(isEdit ? 'links.msg.update_failed' : 'links.msg.create_failed'), 'error');
}
} catch (error) {
console.error('Error saving link:', error);
showToast(t(isEdit ? 'links.msg.update_failed' : 'links.msg.create_failed'), 'error');
} finally {
if (submitBtn) submitBtn.disabled = false;
}
}
async function deleteLinkConfirm(linkName) {
if (!await showConfirm(t('links.confirm.delete').replace('{name}', linkName))) {
return;
}
const token = localStorage.getItem('media_server_token');
try {
const response = await fetch(`/api/links/delete/${linkName}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
});
const result = await response.json();
if (response.ok && result.success) {
showToast(t('links.msg.deleted'), 'success');
} else {
showToast(result.detail || t('links.msg.delete_failed'), 'error');
}
} catch (error) {
console.error('Error deleting link:', error);
showToast(t('links.msg.delete_failed'), 'error');
}
}

View File

@@ -0,0 +1,294 @@
// ============================================================
// Main: Initialization orchestrator (loaded last)
// ============================================================
window.addEventListener('DOMContentLoaded', async () => {
// Cache DOM references
cacheDom();
// Initialize theme and accent color
initTheme();
initAccentColor();
// Register service worker for PWA installability
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {});
}
// Initialize vinyl mode
applyVinylMode();
// Initialize audio visualizer
checkVisualizerAvailability().then(() => {
if (visualizerEnabled && visualizerAvailable) {
applyVisualizerMode();
}
});
// Initialize dynamic background
applyDynamicBackground();
// Initialize locale (async - loads JSON file)
await initLocale();
// Load version from health endpoint
fetchVersion();
const token = localStorage.getItem('media_server_token');
if (token) {
connectWebSocket(token);
loadScripts();
loadScriptsTable();
loadCallbacksTable();
loadLinksTable();
loadAudioDevices();
} else {
showAuthForm();
}
// 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;
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);
setTimeout(() => { isUserAdjustingVolume = false; }, VOLUME_RELEASE_DELAY_MS);
});
}
setupVolumeSlider('volume-slider');
setupVolumeSlider('mini-volume-slider');
// Restore saved tab (migrate old tab names)
let savedTab = localStorage.getItem('activeTab') || 'player';
if (['scripts', 'callbacks', 'links'].includes(savedTab)) savedTab = 'settings';
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 observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (activeTab !== 'player') return;
setMiniPlayerVisible(!entry.isIntersecting);
});
}, { threshold: 0.1 });
observer.observe(playerContainer);
// Drag-to-seek for progress bars
setupProgressDrag(
document.getElementById('mini-progress-bar'),
document.getElementById('mini-progress-fill')
);
setupProgressDrag(
document.getElementById('progress-bar'),
document.getElementById('progress-fill')
);
// Enter key in token input
document.getElementById('token-input').addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
authenticate();
}
});
// Script form dirty state tracking
const scriptForm = document.getElementById('scriptForm');
scriptForm.addEventListener('input', () => {
scriptFormDirty = true;
});
scriptForm.addEventListener('change', () => {
scriptFormDirty = true;
});
// Callback form dirty state tracking
const callbackForm = document.getElementById('callbackForm');
callbackForm.addEventListener('input', () => {
callbackFormDirty = true;
});
callbackForm.addEventListener('change', () => {
callbackFormDirty = true;
});
// Script dialog backdrop click to close
const scriptDialog = document.getElementById('scriptDialog');
scriptDialog.addEventListener('click', (e) => {
// Check if click is on the backdrop (not the dialog content)
if (e.target === scriptDialog) {
closeScriptDialog();
}
});
// Callback dialog backdrop click to close
const callbackDialog = document.getElementById('callbackDialog');
callbackDialog.addEventListener('click', (e) => {
// Check if click is on the backdrop (not the dialog content)
if (e.target === callbackDialog) {
closeCallbackDialog();
}
});
// Delegated click handlers for script table actions (XSS-safe)
document.getElementById('scriptsTableBody').addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
const name = btn.dataset.scriptName;
if (action === 'execute') executeScriptDebug(name);
else if (action === 'edit') showEditScriptDialog(name);
else if (action === 'delete') deleteScriptConfirm(name);
});
// Delegated click handlers for callback table actions (XSS-safe)
document.getElementById('callbacksTableBody').addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
const name = btn.dataset.callbackName;
if (action === 'execute') executeCallbackDebug(name);
else if (action === 'edit') showEditCallbackDialog(name);
else if (action === 'delete') deleteCallbackConfirm(name);
});
// Link dialog backdrop click to close
const linkDialog = document.getElementById('linkDialog');
linkDialog.addEventListener('click', (e) => {
if (e.target === linkDialog) {
closeLinkDialog();
}
});
// Delegated click handlers for link table actions (XSS-safe)
document.getElementById('linksTableBody').addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
const name = btn.dataset.linkName;
if (action === 'edit') showEditLinkDialog(name);
else if (action === 'delete') deleteLinkConfirm(name);
});
// Track link form dirty state
const linkForm = document.getElementById('linkForm');
linkForm.addEventListener('input', () => {
linkFormDirty = true;
});
linkForm.addEventListener('change', () => {
linkFormDirty = true;
});
// Initialize browser toolbar and load folders
initBrowserToolbar();
if (token) {
loadMediaFolders();
}
// Icon preview for script and link dialogs
setupIconPreview('scriptIcon', 'scriptIconPreview');
setupIconPreview('linkIcon', 'linkIconPreview');
// Settings sections: restore collapse state and persist on toggle
document.querySelectorAll('.settings-section').forEach(details => {
const key = `settings_section_${details.querySelector('summary')?.getAttribute('data-i18n') || ''}`;
const saved = localStorage.getItem(key);
if (saved === 'closed') details.removeAttribute('open');
else if (saved === 'open') details.setAttribute('open', '');
details.addEventListener('toggle', () => {
localStorage.setItem(key, details.open ? 'open' : 'closed');
});
});
// Cleanup blob URLs on page unload
window.addEventListener('beforeunload', () => {
thumbnailCache.forEach(url => URL.revokeObjectURL(url));
thumbnailCache.clear();
});
// Tab bar keyboard navigation (WAI-ARIA Tabs pattern)
document.getElementById('tabBar').addEventListener('keydown', (e) => {
const tabs = Array.from(document.querySelectorAll('.tab-btn'));
const currentIdx = tabs.indexOf(document.activeElement);
if (currentIdx === -1) return;
let newIdx;
if (e.key === 'ArrowRight') {
newIdx = (currentIdx + 1) % tabs.length;
} else if (e.key === 'ArrowLeft') {
newIdx = (currentIdx - 1 + tabs.length) % tabs.length;
} else if (e.key === 'Home') {
newIdx = 0;
} else if (e.key === 'End') {
newIdx = tabs.length - 1;
} else {
return;
}
e.preventDefault();
tabs[newIdx].focus();
switchTab(tabs[newIdx].dataset.tab);
});
// Global keyboard shortcuts
document.addEventListener('keydown', (e) => {
// Skip when typing in inputs, textareas, selects, or when a dialog is open
const tag = e.target.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
if (document.querySelector('dialog[open]')) return;
switch (e.key) {
case ' ':
e.preventDefault();
togglePlayPause();
break;
case 'ArrowLeft':
e.preventDefault();
if (currentDuration > 0) seek(Math.max(0, currentPosition - 5));
break;
case 'ArrowRight':
e.preventDefault();
if (currentDuration > 0) seek(Math.min(currentDuration, currentPosition + 5));
break;
case 'ArrowUp':
e.preventDefault();
setVolume(Math.min(100, parseInt(dom.volumeSlider.value) + 5));
break;
case 'ArrowDown':
e.preventDefault();
setVolume(Math.max(0, parseInt(dom.volumeSlider.value) - 5));
break;
case 'm':
case 'M':
toggleMute();
break;
}
});
});

View File

@@ -0,0 +1,742 @@
// ============================================================
// Player: Tabs, theme, accent, vinyl, visualizer, UI updates
// ============================================================
// Tab management
let activeTab = 'player';
function setMiniPlayerVisible(visible) {
const miniPlayer = document.getElementById('mini-player');
if (visible) {
miniPlayer.classList.remove('hidden');
document.body.classList.add('mini-player-visible');
} else {
miniPlayer.classList.add('hidden');
document.body.classList.remove('mini-player-visible');
}
}
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) {
indicator.offsetHeight;
indicator.style.transition = '';
}
}
function switchTab(tabName) {
activeTab = tabName;
document.querySelectorAll('[data-tab-content]').forEach(el => {
el.classList.remove('active');
el.style.display = '';
});
const target = document.querySelector(`[data-tab-content="${tabName}"]`);
if (target) {
target.classList.add('active');
}
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.remove('active');
btn.setAttribute('aria-selected', 'false');
btn.setAttribute('tabindex', '-1');
});
const activeBtn = document.querySelector(`.tab-btn[data-tab="${tabName}"]`);
if (activeBtn) {
activeBtn.classList.add('active');
activeBtn.setAttribute('aria-selected', 'true');
activeBtn.setAttribute('tabindex', '0');
updateTabIndicator(activeBtn);
}
if (tabName === 'display') {
loadDisplayMonitors();
}
localStorage.setItem('activeTab', tabName);
if (tabName !== 'player') {
setMiniPlayerVisible(true);
} else {
const playerContainer = document.querySelector('.player-container');
const rect = playerContainer.getBoundingClientRect();
const inView = rect.top < window.innerHeight && rect.bottom > 0;
setMiniPlayerVisible(!inView);
}
}
// Theme management
function initTheme() {
const savedTheme = localStorage.getItem('theme') || 'dark';
setTheme(savedTheme);
}
function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
const sunIcon = document.getElementById('theme-icon-sun');
const moonIcon = document.getElementById('theme-icon-moon');
if (theme === 'light') {
sunIcon.style.display = 'none';
moonIcon.style.display = 'block';
} else {
sunIcon.style.display = 'block';
moonIcon.style.display = 'none';
}
const metaThemeColor = document.querySelector('meta[name="theme-color"]');
if (metaThemeColor) {
metaThemeColor.setAttribute('content', theme === 'light' ? '#ffffff' : '#121212');
}
if (typeof updateBackgroundColors === 'function') updateBackgroundColors();
}
function toggleTheme() {
const currentTheme = document.documentElement.getAttribute('data-theme') || 'dark';
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
setTheme(newTheme);
}
// Accent color management
const accentPresets = [
{ name: 'Green', color: '#1db954', hover: '#1ed760' },
{ name: 'Blue', color: '#3b82f6', hover: '#60a5fa' },
{ name: 'Purple', color: '#8b5cf6', hover: '#a78bfa' },
{ name: 'Pink', color: '#ec4899', hover: '#f472b6' },
{ name: 'Orange', color: '#f97316', hover: '#fb923c' },
{ name: 'Red', color: '#ef4444', hover: '#f87171' },
{ name: 'Teal', color: '#14b8a6', hover: '#2dd4bf' },
{ name: 'Cyan', color: '#06b6d4', hover: '#22d3ee' },
{ name: 'Yellow', color: '#eab308', hover: '#facc15' },
];
function lightenColor(hex, percent) {
const num = parseInt(hex.replace('#', ''), 16);
const r = Math.min(255, (num >> 16) + Math.round(255 * percent / 100));
const g = Math.min(255, ((num >> 8) & 0xff) + Math.round(255 * percent / 100));
const b = Math.min(255, (num & 0xff) + Math.round(255 * percent / 100));
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
}
function initAccentColor() {
const saved = localStorage.getItem('accentColor');
if (saved) {
const preset = accentPresets.find(p => p.color === saved);
if (preset) {
applyAccentColor(preset.color, preset.hover);
} else {
applyAccentColor(saved, lightenColor(saved, 15));
}
}
renderAccentSwatches();
}
function applyAccentColor(color, hover) {
document.documentElement.style.setProperty('--accent', color);
document.documentElement.style.setProperty('--accent-hover', hover);
localStorage.setItem('accentColor', color);
const dot = document.getElementById('accentDot');
if (dot) dot.style.background = color;
if (typeof updateBackgroundColors === 'function') updateBackgroundColors();
}
function renderAccentSwatches() {
const dropdown = document.getElementById('accentDropdown');
if (!dropdown) return;
const current = localStorage.getItem('accentColor') || '#1db954';
const isCustom = !accentPresets.some(p => p.color === current);
const swatches = accentPresets.map(p =>
`<div class="accent-swatch ${p.color === current ? 'active' : ''}"
style="background: ${p.color}"
onclick="selectAccentColor('${p.color}', '${p.hover}')"
title="${p.name}"></div>`
).join('');
const customRow = `
<div class="accent-custom-row ${isCustom ? 'active' : ''}" onclick="document.getElementById('accentCustomInput').click()">
<span class="accent-custom-swatch" style="background: ${isCustom ? current : '#888'}"></span>
<span class="accent-custom-label">${t('accent.custom')}</span>
<input type="color" id="accentCustomInput" value="${current}"
onclick="event.stopPropagation()"
onchange="selectAccentColor(this.value, lightenColor(this.value, 15))">
</div>`;
dropdown.innerHTML = swatches + customRow;
}
function selectAccentColor(color, hover) {
applyAccentColor(color, hover);
renderAccentSwatches();
document.getElementById('accentDropdown').classList.remove('open');
}
function toggleAccentPicker() {
document.getElementById('accentDropdown').classList.toggle('open');
}
document.addEventListener('click', (e) => {
if (!e.target.closest('.accent-picker')) {
document.getElementById('accentDropdown')?.classList.remove('open');
}
});
// Vinyl mode
let vinylMode = localStorage.getItem('vinylMode') === 'true';
function getVinylAngle() {
const art = document.getElementById('album-art');
if (!art) return 0;
const st = getComputedStyle(art);
const tr = st.transform;
if (!tr || tr === 'none') return 0;
const m = tr.match(/matrix\((.+)\)/);
if (!m) return 0;
const vals = m[1].split(',').map(Number);
const angle = Math.round(Math.atan2(vals[1], vals[0]) * (180 / Math.PI));
return ((angle % 360) + 360) % 360;
}
function saveVinylAngle() {
if (!vinylMode) return;
localStorage.setItem('vinylAngle', getVinylAngle());
}
function restoreVinylAngle() {
const saved = localStorage.getItem('vinylAngle');
if (saved) {
const art = document.getElementById('album-art');
if (art) art.style.setProperty('--vinyl-offset', `${saved}deg`);
}
}
setInterval(saveVinylAngle, 2000);
window.addEventListener('beforeunload', saveVinylAngle);
function toggleVinylMode() {
if (vinylMode) saveVinylAngle();
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');
restoreVinylAngle();
updateVinylSpin();
} else {
saveVinylAngle();
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');
}
}
// Audio Visualizer
let visualizerEnabled = localStorage.getItem('visualizerEnabled') === 'true';
let visualizerAvailable = false;
let visualizerCtx = null;
let visualizerAnimFrame = null;
let frequencyData = null;
let smoothedFrequencies = null;
const VISUALIZER_SMOOTHING = 0.15;
async function checkVisualizerAvailability() {
try {
const token = localStorage.getItem('media_server_token');
const resp = await fetch('/api/media/visualizer/status', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (resp.ok) {
const data = await resp.json();
visualizerAvailable = data.available;
}
} catch (e) {
visualizerAvailable = false;
}
const btn = document.getElementById('visualizerToggle');
if (btn) btn.style.display = visualizerAvailable ? '' : 'none';
}
function toggleVisualizer() {
visualizerEnabled = !visualizerEnabled;
localStorage.setItem('visualizerEnabled', visualizerEnabled);
applyVisualizerMode();
}
function applyVisualizerMode() {
const container = document.querySelector('.album-art-container');
const btn = document.getElementById('visualizerToggle');
if (!container) return;
if (visualizerEnabled && visualizerAvailable) {
container.classList.add('visualizer-active');
if (btn) btn.classList.add('active');
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'enable_visualizer' }));
}
initVisualizerCanvas();
startVisualizerRender();
} else {
container.classList.remove('visualizer-active');
if (btn) btn.classList.remove('active');
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'disable_visualizer' }));
}
stopVisualizerRender();
}
// Sync the audio device status badge with the new capture state
updateAudioDeviceStatus({
running: visualizerEnabled && visualizerAvailable,
available: visualizerAvailable
});
}
function initVisualizerCanvas() {
const canvas = document.getElementById('spectrogram-canvas');
if (!canvas) return;
visualizerCtx = canvas.getContext('2d');
canvas.width = 300;
canvas.height = 64;
}
function startVisualizerRender() {
if (visualizerAnimFrame) return;
renderVisualizerFrame();
}
function stopVisualizerRender() {
if (visualizerAnimFrame) {
cancelAnimationFrame(visualizerAnimFrame);
visualizerAnimFrame = null;
}
const canvas = document.getElementById('spectrogram-canvas');
if (visualizerCtx && canvas) {
visualizerCtx.clearRect(0, 0, canvas.width, canvas.height);
}
const art = document.getElementById('album-art');
if (art) {
art.style.transform = '';
art.style.removeProperty('--vinyl-scale');
}
const glow = document.getElementById('album-art-glow');
if (glow) glow.style.opacity = '';
frequencyData = null;
smoothedFrequencies = null;
}
function renderVisualizerFrame() {
visualizerAnimFrame = requestAnimationFrame(renderVisualizerFrame);
const canvas = document.getElementById('spectrogram-canvas');
if (!frequencyData || !visualizerCtx || !canvas) return;
const bins = frequencyData.frequencies;
const numBins = bins.length;
const w = canvas.width;
const h = canvas.height;
const gap = 2;
const barWidth = (w / numBins) - gap;
const accent = getComputedStyle(document.documentElement)
.getPropertyValue('--accent').trim();
if (!smoothedFrequencies || smoothedFrequencies.length !== numBins) {
smoothedFrequencies = new Array(numBins).fill(0);
}
for (let i = 0; i < numBins; i++) {
smoothedFrequencies[i] = smoothedFrequencies[i] * VISUALIZER_SMOOTHING
+ bins[i] * (1 - VISUALIZER_SMOOTHING);
}
visualizerCtx.clearRect(0, 0, w, h);
for (let i = 0; i < numBins; i++) {
const barHeight = Math.max(1, smoothedFrequencies[i] * h);
const x = i * (barWidth + gap) + gap / 2;
const y = h - barHeight;
const grad = visualizerCtx.createLinearGradient(x, y, x, h);
grad.addColorStop(0, accent);
grad.addColorStop(1, accent + '30');
visualizerCtx.fillStyle = grad;
visualizerCtx.beginPath();
visualizerCtx.roundRect(x, y, barWidth, barHeight, 1.5);
visualizerCtx.fill();
}
const bass = frequencyData.bass || 0;
const scale = 1 + bass * 0.04;
const art = document.getElementById('album-art');
if (art) {
if (vinylMode) {
art.style.setProperty('--vinyl-scale', scale);
} else {
art.style.transform = `scale(${scale})`;
}
}
const glow = document.getElementById('album-art-glow');
if (glow) {
glow.style.opacity = (0.4 + bass * 0.4).toFixed(2);
}
}
// Audio device selection
async function loadAudioDevices() {
const section = document.getElementById('audioDeviceSection');
const select = document.getElementById('audioDeviceSelect');
if (!section || !select) return;
try {
const token = localStorage.getItem('media_server_token');
const [devicesResp, statusResp] = await Promise.all([
fetch('/api/media/visualizer/devices', {
headers: { 'Authorization': `Bearer ${token}` }
}),
fetch('/api/media/visualizer/status', {
headers: { 'Authorization': `Bearer ${token}` }
})
]);
if (!devicesResp.ok || !statusResp.ok) return;
const devices = await devicesResp.json();
const status = await statusResp.json();
if (!status.available && devices.length === 0) {
section.style.display = 'none';
return;
}
section.style.display = '';
while (select.options.length > 1) select.remove(1);
for (const dev of devices) {
const opt = document.createElement('option');
opt.value = dev.name;
opt.textContent = dev.name;
select.appendChild(opt);
}
if (status.current_device) {
for (let i = 0; i < select.options.length; i++) {
if (select.options[i].value === status.current_device) {
select.selectedIndex = i;
break;
}
}
}
updateAudioDeviceStatus(status);
} catch (e) {
section.style.display = 'none';
}
}
function updateAudioDeviceStatus(status) {
const el = document.getElementById('audioDeviceStatus');
if (!el) return;
// Badge reflects local visualizer state (capture is on-demand per subscriber)
if (visualizerEnabled && status.available) {
el.className = 'audio-device-status active';
el.textContent = t('settings.audio.status_active');
} else if (status.available) {
el.className = 'audio-device-status available';
el.textContent = t('settings.audio.status_available');
} else {
el.className = 'audio-device-status unavailable';
el.textContent = t('settings.audio.status_unavailable');
}
}
async function onAudioDeviceChanged() {
const select = document.getElementById('audioDeviceSelect');
if (!select) return;
const deviceName = select.value || null;
const token = localStorage.getItem('media_server_token');
try {
const resp = await fetch('/api/media/visualizer/device', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ device_name: deviceName })
});
if (resp.ok) {
const result = await resp.json();
updateAudioDeviceStatus({ available: result.success, ...result });
await checkVisualizerAvailability();
if (visualizerEnabled) applyVisualizerMode();
showToast(t('settings.audio.device_changed'), 'success');
} else {
showToast(t('settings.audio.device_change_failed'), 'error');
}
} catch (e) {
showToast(t('settings.audio.device_change_failed'), 'error');
}
}
// ============================================================
// UI State Updates
// ============================================================
let lastArtworkKey = null;
let currentArtworkBlobUrl = null;
let lastPositionUpdate = 0;
let lastPositionValue = 0;
let interpolationInterval = null;
function setupProgressDrag(bar, fill) {
let dragging = false;
function getPercent(clientX) {
const rect = bar.getBoundingClientRect();
return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
}
function updatePreview(percent) {
fill.style.width = (percent * 100) + '%';
}
function handleStart(clientX) {
if (currentDuration <= 0) return;
dragging = true;
bar.classList.add('dragging');
updatePreview(getPercent(clientX));
}
function handleMove(clientX) {
if (!dragging) return;
updatePreview(getPercent(clientX));
}
function handleEnd(clientX) {
if (!dragging) return;
dragging = false;
bar.classList.remove('dragging');
const percent = getPercent(clientX);
seek(percent * currentDuration);
}
bar.addEventListener('mousedown', (e) => { e.preventDefault(); handleStart(e.clientX); });
document.addEventListener('mousemove', (e) => { handleMove(e.clientX); });
document.addEventListener('mouseup', (e) => { handleEnd(e.clientX); });
bar.addEventListener('touchstart', (e) => { handleStart(e.touches[0].clientX); }, { passive: true });
document.addEventListener('touchmove', (e) => { if (dragging) handleMove(e.touches[0].clientX); });
document.addEventListener('touchend', (e) => {
if (dragging) {
const touch = e.changedTouches[0];
handleEnd(touch.clientX);
}
});
bar.addEventListener('click', (e) => {
if (currentDuration > 0) {
seek(getPercent(e.clientX) * currentDuration);
}
});
}
function updateUI(status) {
lastStatus = status;
const fallbackTitle = status.state === 'idle' ? t('player.no_media') : t('player.title_unavailable');
dom.trackTitle.textContent = status.title || fallbackTitle;
dom.artist.textContent = status.artist || '';
dom.album.textContent = status.album || '';
dom.miniTrackTitle.textContent = status.title || fallbackTitle;
dom.miniArtist.textContent = status.artist || '';
const previousState = currentState;
currentState = status.state;
updatePlaybackState(status.state);
const altText = status.title && status.artist
? `${status.artist} ${status.title}`
: status.title || t('player.no_media');
dom.albumArt.alt = altText;
dom.miniAlbumArt.alt = altText;
const artworkSource = status.album_art_url || null;
const artworkKey = `${status.title || ''}|${status.artist || ''}|${artworkSource || ''}`;
if (artworkKey !== lastArtworkKey) {
lastArtworkKey = artworkKey;
const placeholderArt = "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";
const placeholderGlow = "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";
if (artworkSource) {
const token = localStorage.getItem('media_server_token');
fetch(`/api/media/artwork?_=${Date.now()}`, {
headers: { 'Authorization': `Bearer ${token}` }
})
.then(r => r.ok ? r.blob() : null)
.then(blob => {
if (!blob) return;
const oldBlobUrl = currentArtworkBlobUrl;
const url = URL.createObjectURL(blob);
currentArtworkBlobUrl = url;
dom.albumArt.src = url;
dom.miniAlbumArt.src = url;
if (dom.albumArtGlow) dom.albumArtGlow.src = url;
if (oldBlobUrl) setTimeout(() => URL.revokeObjectURL(oldBlobUrl), 1000);
})
.catch(err => console.error('Artwork fetch failed:', err));
} else {
if (currentArtworkBlobUrl) {
URL.revokeObjectURL(currentArtworkBlobUrl);
currentArtworkBlobUrl = null;
}
dom.albumArt.src = placeholderArt;
dom.miniAlbumArt.src = placeholderArt;
if (dom.albumArtGlow) dom.albumArtGlow.src = placeholderGlow;
}
}
if (status.duration && status.position !== null) {
currentDuration = status.duration;
currentPosition = status.position;
lastPositionUpdate = Date.now();
lastPositionValue = status.position;
updateProgress(status.position, status.duration);
}
if (!isUserAdjustingVolume) {
dom.volumeSlider.value = status.volume;
dom.volumeDisplay.textContent = `${status.volume}%`;
dom.miniVolumeSlider.value = status.volume;
dom.miniVolumeDisplay.textContent = `${status.volume}%`;
}
updateMuteIcon(status.muted);
const src = resolveMediaSource(status.source);
dom.source.textContent = src ? src.name : t('player.unknown_source');
dom.sourceIcon.innerHTML = src?.icon || '';
const hasMedia = status.state !== 'idle';
dom.btnPlayPause.disabled = !hasMedia;
dom.btnNext.disabled = !hasMedia;
dom.btnPrevious.disabled = !hasMedia;
dom.miniBtnPlayPause.disabled = !hasMedia;
if (status.state === 'playing' && previousState !== 'playing') {
startPositionInterpolation();
} else if (status.state !== 'playing' && previousState === 'playing') {
stopPositionInterpolation();
}
}
function updatePlaybackState(state) {
currentPlayState = state;
switch(state) {
case 'playing':
dom.playbackState.textContent = t('state.playing');
dom.stateIcon.innerHTML = SVG_PLAY;
dom.playPauseIcon.innerHTML = SVG_PAUSE;
dom.miniPlayPauseIcon.innerHTML = SVG_PAUSE;
break;
case 'paused':
dom.playbackState.textContent = t('state.paused');
dom.stateIcon.innerHTML = SVG_PAUSE;
dom.playPauseIcon.innerHTML = SVG_PLAY;
dom.miniPlayPauseIcon.innerHTML = SVG_PLAY;
break;
case 'stopped':
dom.playbackState.textContent = t('state.stopped');
dom.stateIcon.innerHTML = SVG_STOP;
dom.playPauseIcon.innerHTML = SVG_PLAY;
dom.miniPlayPauseIcon.innerHTML = SVG_PLAY;
break;
default:
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;
const widthStr = `${percent}%`;
const currentStr = formatTime(position);
const totalStr = formatTime(duration);
const posRound = Math.round(position);
const durRound = Math.round(duration);
dom.progressFill.style.width = widthStr;
dom.currentTime.textContent = currentStr;
dom.totalTime.textContent = totalStr;
dom.progressBar.dataset.duration = duration;
dom.progressBar.setAttribute('aria-valuenow', posRound);
dom.progressBar.setAttribute('aria-valuemax', durRound);
dom.miniProgressFill.style.width = widthStr;
dom.miniCurrentTime.textContent = currentStr;
dom.miniTotalTime.textContent = totalStr;
if (dom.miniPlayer) dom.miniPlayer.style.setProperty('--mini-progress', widthStr);
const miniBar = document.getElementById('mini-progress-bar');
miniBar.setAttribute('aria-valuenow', posRound);
miniBar.setAttribute('aria-valuemax', durRound);
}
function startPositionInterpolation() {
if (interpolationInterval) {
clearInterval(interpolationInterval);
}
interpolationInterval = setInterval(() => {
if (currentState === 'playing' && currentDuration > 0 && lastPositionUpdate > 0) {
const elapsed = (Date.now() - lastPositionUpdate) / 1000;
const interpolatedPosition = Math.min(lastPositionValue + elapsed, currentDuration);
updateProgress(interpolatedPosition, currentDuration);
}
}, POSITION_INTERPOLATION_MS);
}
function stopPositionInterpolation() {
if (interpolationInterval) {
clearInterval(interpolationInterval);
interpolationInterval = null;
}
}
function updateMuteIcon(muted) {
const path = muted ? SVG_MUTED : SVG_UNMUTED;
dom.muteIcon.innerHTML = path;
dom.miniMuteIcon.innerHTML = path;
}

View File

@@ -0,0 +1,537 @@
// ============================================================
// Scripts: CRUD, quick access, execution dialog
// ============================================================
let scriptFormDirty = false;
async function loadScripts() {
const token = localStorage.getItem('media_server_token');
try {
const response = await fetch('/api/scripts/list', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
scripts = await response.json();
displayQuickAccess();
}
} catch (error) {
console.error('Error loading scripts:', error);
}
}
let _quickAccessGen = 0;
async function displayQuickAccess() {
const gen = ++_quickAccessGen;
const grid = document.getElementById('scripts-grid');
const fragment = document.createDocumentFragment();
const hasScripts = scripts.length > 0;
let hasLinks = false;
scripts.forEach(script => {
const button = document.createElement('button');
button.className = 'script-btn';
button.onclick = () => executeScript(script.name, button);
if (script.icon) {
const iconEl = document.createElement('div');
iconEl.className = 'script-icon';
iconEl.setAttribute('data-mdi-icon', script.icon);
button.appendChild(iconEl);
}
const label = document.createElement('div');
label.className = 'script-label';
label.textContent = script.label || script.name;
button.appendChild(label);
if (script.description) {
const description = document.createElement('div');
description.className = 'script-description';
description.textContent = script.description;
button.appendChild(description);
}
fragment.appendChild(button);
});
try {
const token = localStorage.getItem('media_server_token');
if (token) {
const response = await fetch('/api/links/list', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (gen !== _quickAccessGen) return;
if (response.ok) {
const links = await response.json();
hasLinks = links.length > 0;
links.forEach(link => {
const card = document.createElement('a');
card.className = 'script-btn link-card';
card.href = link.url;
card.target = '_blank';
card.rel = 'noopener noreferrer';
if (link.icon) {
const iconEl = document.createElement('div');
iconEl.className = 'script-icon';
iconEl.setAttribute('data-mdi-icon', link.icon);
card.appendChild(iconEl);
}
const label = document.createElement('div');
label.className = 'script-label';
label.textContent = link.label || link.name;
card.appendChild(label);
if (link.description) {
const desc = document.createElement('div');
desc.className = 'script-description';
desc.textContent = link.description;
card.appendChild(desc);
}
fragment.appendChild(card);
});
}
}
} catch (e) {
if (gen !== _quickAccessGen) return;
console.warn('Failed to load links for quick access:', e);
}
if (!hasScripts && !hasLinks) {
const empty = document.createElement('div');
empty.className = 'scripts-empty empty-state-illustration';
empty.innerHTML = `<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('quick_access.no_items')}</p>`;
fragment.prepend(empty);
}
grid.innerHTML = '';
grid.appendChild(fragment);
resolveMdiIcons(grid);
}
async function executeScript(scriptName, buttonElement) {
const token = localStorage.getItem('media_server_token');
buttonElement.classList.add('executing');
try {
const response = await fetch(`/api/scripts/execute/${scriptName}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ args: [] })
});
const result = await response.json();
if (response.ok && result.success) {
showToast(`${scriptName} executed successfully`, 'success');
} else {
showToast(`Failed to execute ${scriptName}`, 'error');
}
} catch (error) {
console.error(`Error executing script ${scriptName}:`, error);
showToast(`Error executing ${scriptName}`, 'error');
} finally {
buttonElement.classList.remove('executing');
}
}
// ============================================================
// Script Management CRUD
// ============================================================
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');
try {
const response = await fetch('/api/scripts/list', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) {
throw new Error('Failed to fetch scripts');
}
const scriptsList = await response.json();
if (scriptsList.length === 0) {
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;
}
tbody.innerHTML = scriptsList.map(script => `
<tr>
<td><span class="name-with-icon">${script.icon ? `<span class="table-icon" data-mdi-icon="${escapeHtml(script.icon)}"></span>` : ''}<code>${escapeHtml(script.name)}</code></span></td>
<td>${escapeHtml(script.label || script.name)}</td>
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"
title="${escapeHtml(script.command || 'N/A')}">${escapeHtml(script.command || 'N/A')}</td>
<td>${script.timeout}s</td>
<td>
<div class="action-buttons">
<button class="action-btn execute" data-action="execute" data-script-name="${escapeHtml(script.name)}" title="Execute script">
<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
</button>
<button class="action-btn" data-action="edit" data-script-name="${escapeHtml(script.name)}" title="Edit script">
<svg viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
</button>
<button class="action-btn delete" data-action="delete" data-script-name="${escapeHtml(script.name)}" title="Delete script">
<svg viewBox="0 0 24 24"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
</button>
</div>
</td>
</tr>
`).join('');
resolveMdiIcons(tbody);
} catch (error) {
console.error('Error loading scripts:', error);
tbody.innerHTML = '<tr><td colspan="5" class="empty-state" style="color: var(--error);">Failed to load scripts</td></tr>';
}
}
function showAddScriptDialog() {
const dialog = document.getElementById('scriptDialog');
const form = document.getElementById('scriptForm');
const title = document.getElementById('dialogTitle');
form.reset();
document.getElementById('scriptOriginalName').value = '';
document.getElementById('scriptIsEdit').value = 'false';
document.getElementById('scriptName').disabled = false;
document.getElementById('scriptIconPreview').innerHTML = '';
title.textContent = t('scripts.dialog.add');
scriptFormDirty = false;
document.body.classList.add('dialog-open');
dialog.showModal();
}
async function showEditScriptDialog(scriptName) {
const token = localStorage.getItem('media_server_token');
const dialog = document.getElementById('scriptDialog');
const title = document.getElementById('dialogTitle');
try {
const response = await fetch('/api/scripts/list', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) {
throw new Error('Failed to fetch script details');
}
const scriptsList = await response.json();
const script = scriptsList.find(s => s.name === scriptName);
if (!script) {
showToast('Script not found', 'error');
return;
}
document.getElementById('scriptOriginalName').value = scriptName;
document.getElementById('scriptIsEdit').value = 'true';
document.getElementById('scriptName').value = scriptName;
document.getElementById('scriptName').disabled = true;
document.getElementById('scriptLabel').value = script.label || '';
document.getElementById('scriptCommand').value = script.command || '';
document.getElementById('scriptDescription').value = script.description || '';
document.getElementById('scriptIcon').value = script.icon || '';
document.getElementById('scriptTimeout').value = script.timeout || 30;
const preview = document.getElementById('scriptIconPreview');
if (script.icon) {
fetchMdiIcon(script.icon).then(svg => { preview.innerHTML = svg; });
} else {
preview.innerHTML = '';
}
title.textContent = t('scripts.dialog.edit');
scriptFormDirty = false;
document.body.classList.add('dialog-open');
dialog.showModal();
} catch (error) {
console.error('Error loading script for edit:', error);
showToast('Failed to load script details', 'error');
}
}
async function closeScriptDialog() {
if (scriptFormDirty) {
if (!await showConfirm(t('scripts.confirm.unsaved'))) {
return;
}
}
const dialog = document.getElementById('scriptDialog');
scriptFormDirty = false;
closeDialog(dialog);
document.body.classList.remove('dialog-open');
}
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 ?
document.getElementById('scriptOriginalName').value :
document.getElementById('scriptName').value;
const data = {
command: document.getElementById('scriptCommand').value,
label: document.getElementById('scriptLabel').value || null,
description: document.getElementById('scriptDescription').value || '',
icon: document.getElementById('scriptIcon').value || null,
timeout: parseInt(document.getElementById('scriptTimeout').value) || 30,
shell: true
};
const endpoint = isEdit ?
`/api/scripts/update/${scriptName}` :
`/api/scripts/create/${scriptName}`;
const method = isEdit ? 'PUT' : 'POST';
try {
const response = await fetch(endpoint, {
method,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
const result = await response.json();
if (response.ok && result.success) {
showToast(`Script ${isEdit ? 'updated' : 'created'} successfully`, 'success');
scriptFormDirty = false;
closeScriptDialog();
} 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;
}
}
async function deleteScriptConfirm(scriptName) {
if (!await showConfirm(t('scripts.confirm.delete').replace('{name}', scriptName))) {
return;
}
const token = localStorage.getItem('media_server_token');
try {
const response = await fetch(`/api/scripts/delete/${scriptName}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
});
const result = await response.json();
if (response.ok && result.success) {
showToast('Script deleted successfully', 'success');
} else {
showToast(result.detail || 'Failed to delete script', 'error');
}
} catch (error) {
console.error('Error deleting script:', error);
showToast('Error deleting script', 'error');
}
}
// ============================================================
// Execution Result Dialog (shared by scripts and callbacks)
// ============================================================
function closeExecutionDialog() {
const dialog = document.getElementById('executionDialog');
closeDialog(dialog);
document.body.classList.remove('dialog-open');
}
function showExecutionResult(name, result, type = 'script') {
const dialog = document.getElementById('executionDialog');
const title = document.getElementById('executionDialogTitle');
const statusDiv = document.getElementById('executionStatus');
const outputSection = document.getElementById('outputSection');
const errorSection = document.getElementById('errorSection');
const outputPre = document.getElementById('executionOutput');
const errorPre = document.getElementById('executionError');
title.textContent = `Execution Result: ${name}`;
const success = result.success && result.exit_code === 0;
const statusClass = success ? 'success' : 'error';
const statusText = success ? 'Success' : 'Failed';
statusDiv.innerHTML = `
<div class="status-item ${statusClass}">
<label>Status</label>
<value>${statusText}</value>
</div>
<div class="status-item">
<label>Exit Code</label>
<value>${result.exit_code !== undefined ? result.exit_code : 'N/A'}</value>
</div>
<div class="status-item">
<label>Duration</label>
<value>${result.execution_time !== undefined && result.execution_time !== null ? result.execution_time.toFixed(3) + 's' : 'N/A'}</value>
</div>
`;
outputSection.style.display = 'block';
if (result.stdout && result.stdout.trim()) {
outputPre.textContent = result.stdout;
} else {
outputPre.textContent = '(no output)';
outputPre.style.fontStyle = 'italic';
outputPre.style.color = 'var(--text-secondary)';
}
if (result.stderr && result.stderr.trim()) {
errorSection.style.display = 'block';
errorPre.textContent = result.stderr;
errorPre.style.fontStyle = 'normal';
errorPre.style.color = 'var(--error)';
} else if (!success && result.error) {
errorSection.style.display = 'block';
errorPre.textContent = result.error;
errorPre.style.fontStyle = 'normal';
errorPre.style.color = 'var(--error)';
} else {
errorSection.style.display = 'none';
}
dialog.showModal();
}
async function executeScriptDebug(scriptName) {
const token = localStorage.getItem('media_server_token');
const dialog = document.getElementById('executionDialog');
const title = document.getElementById('executionDialogTitle');
const statusDiv = document.getElementById('executionStatus');
title.textContent = `Executing: ${scriptName}`;
statusDiv.innerHTML = `
<div class="status-item">
<label>Status</label>
<value><span class="loading-spinner"></span> Running...</value>
</div>
`;
document.getElementById('outputSection').style.display = 'none';
document.getElementById('errorSection').style.display = 'none';
document.body.classList.add('dialog-open');
dialog.showModal();
try {
const response = await fetch(`/api/scripts/execute/${scriptName}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ args: [] })
});
const result = await response.json();
if (response.ok) {
showExecutionResult(scriptName, result, 'script');
} else {
showExecutionResult(scriptName, {
success: false,
exit_code: -1,
error: result.detail || 'Execution failed',
stderr: result.detail || 'Unknown error'
}, 'script');
}
} catch (error) {
console.error(`Error executing script ${scriptName}:`, error);
showExecutionResult(scriptName, {
success: false,
exit_code: -1,
error: error.message,
stderr: `Network error: ${error.message}`
}, 'script');
}
}
async function executeCallbackDebug(callbackName) {
const token = localStorage.getItem('media_server_token');
const dialog = document.getElementById('executionDialog');
const title = document.getElementById('executionDialogTitle');
const statusDiv = document.getElementById('executionStatus');
title.textContent = `Executing: ${callbackName}`;
statusDiv.innerHTML = `
<div class="status-item">
<label>Status</label>
<value><span class="loading-spinner"></span> Running...</value>
</div>
`;
document.getElementById('outputSection').style.display = 'none';
document.getElementById('errorSection').style.display = 'none';
document.body.classList.add('dialog-open');
dialog.showModal();
try {
const response = await fetch(`/api/callbacks/execute/${callbackName}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
const result = await response.json();
if (response.ok) {
showExecutionResult(callbackName, result, 'callback');
} else {
showExecutionResult(callbackName, {
success: false,
exit_code: -1,
error: result.detail || 'Execution failed',
stderr: result.detail || 'Unknown error'
}, 'callback');
}
} catch (error) {
console.error(`Error executing callback ${callbackName}:`, error);
showExecutionResult(callbackName, {
success: false,
exit_code: -1,
error: error.message,
stderr: `Network error: ${error.message}`
}, 'callback');
}
}

View File

@@ -0,0 +1,169 @@
// ============================================================
// WebSocket: Connection, reconnection, authentication
// ============================================================
let reconnectTimeout = null;
let pingInterval = null;
let wsReconnectAttempts = 0;
function showAuthForm(errorMessage = '') {
const overlay = document.getElementById('auth-overlay');
overlay.classList.remove('hidden');
const errorEl = document.getElementById('auth-error');
if (errorMessage) {
errorEl.textContent = errorMessage;
errorEl.classList.add('visible');
} else {
errorEl.classList.remove('visible');
}
}
function hideAuthForm() {
document.getElementById('auth-overlay').classList.add('hidden');
}
function authenticate() {
const token = document.getElementById('token-input').value.trim();
if (!token) {
showAuthForm(t('auth.required'));
return;
}
localStorage.setItem('media_server_token', token);
connectWebSocket(token);
}
function clearToken() {
localStorage.removeItem('media_server_token');
if (ws) {
ws.close();
}
showAuthForm(t('auth.cleared'));
}
function connectWebSocket(token) {
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)}`;
ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('WebSocket connected');
wsReconnectAttempts = 0;
updateConnectionStatus(true);
hideConnectionBanner();
hideAuthForm();
loadScripts();
loadScriptsTable();
loadCallbacksTable();
loadLinksTable();
loadHeaderLinks();
loadAudioDevices();
if (visualizerEnabled && visualizerAvailable) {
ws.send(JSON.stringify({ type: 'enable_visualizer' }));
}
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'status' || msg.type === 'status_update') {
updateUI(msg.data);
} else if (msg.type === 'scripts_changed') {
console.log('Scripts changed, reloading...');
loadScripts();
loadScriptsTable();
} else if (msg.type === 'links_changed') {
console.log('Links changed, reloading...');
loadHeaderLinks();
loadLinksTable();
displayQuickAccess();
} else if (msg.type === 'audio_data') {
frequencyData = msg.data;
} else if (msg.type === 'error') {
console.error('WebSocket error:', msg.message);
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
updateConnectionStatus(false);
};
ws.onclose = (event) => {
console.log('WebSocket closed:', event.code);
updateConnectionStatus(false);
stopPositionInterpolation();
if (event.code === 4001) {
localStorage.removeItem('media_server_token');
showAuthForm(t('auth.invalid'));
} else if (event.code !== 1000) {
wsReconnectAttempts++;
if (wsReconnectAttempts <= WS_MAX_RECONNECT_ATTEMPTS) {
const delay = Math.min(
WS_BACKOFF_BASE_MS * Math.pow(1.5, wsReconnectAttempts - 1),
WS_BACKOFF_MAX_MS
);
console.log(`Reconnecting in ${Math.round(delay / 1000)}s (attempt ${wsReconnectAttempts}/${WS_MAX_RECONNECT_ATTEMPTS})...`);
if (wsReconnectAttempts >= 3) {
showConnectionBanner(t('connection.reconnecting').replace('{attempt}', wsReconnectAttempts), false);
}
reconnectTimeout = setTimeout(() => {
const savedToken = localStorage.getItem('media_server_token');
if (savedToken) {
connectWebSocket(savedToken);
}
}, delay);
} else {
showConnectionBanner(t('connection.lost'), true);
}
}
};
pingInterval = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }));
}
}, WS_PING_INTERVAL_MS);
}
function updateConnectionStatus(connected) {
if (connected) {
dom.statusDot.classList.add('connected');
} else {
dom.statusDot.classList.remove('connected');
}
}
function showConnectionBanner(message, showButton) {
const banner = document.getElementById('connectionBanner');
const text = document.getElementById('connectionBannerText');
const btn = document.getElementById('connectionBannerBtn');
text.textContent = message;
btn.style.display = showButton ? '' : 'none';
banner.classList.remove('hidden');
}
function hideConnectionBanner() {
const banner = document.getElementById('connectionBanner');
banner.classList.add('hidden');
}
function manualReconnect() {
const savedToken = localStorage.getItem('media_server_token');
if (savedToken) {
wsReconnectAttempts = 0;
hideConnectionBanner();
connectWebSocket(savedToken);
}
}

View File

@@ -24,6 +24,7 @@
"player.unknown_source": "Unknown", "player.unknown_source": "Unknown",
"player.vinyl": "Vinyl mode", "player.vinyl": "Vinyl mode",
"player.visualizer": "Audio visualizer", "player.visualizer": "Audio visualizer",
"player.background": "Dynamic background",
"state.playing": "Playing", "state.playing": "Playing",
"state.paused": "Paused", "state.paused": "Paused",
"state.stopped": "Stopped", "state.stopped": "Stopped",

View File

@@ -24,6 +24,7 @@
"player.unknown_source": "Неизвестно", "player.unknown_source": "Неизвестно",
"player.vinyl": "Режим винила", "player.vinyl": "Режим винила",
"player.visualizer": "Аудио визуализатор", "player.visualizer": "Аудио визуализатор",
"player.background": "Динамический фон",
"state.playing": "Воспроизведение", "state.playing": "Воспроизведение",
"state.paused": "Пауза", "state.paused": "Пауза",
"state.stopped": "Остановлено", "state.stopped": "Остановлено",

View File

@@ -0,0 +1,23 @@
{
"name": "Media Server",
"short_name": "Media",
"description": "Remote media player control and file browser",
"start_url": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#121212",
"theme_color": "#121212",
"icons": [
{
"src": "/static/icons/icon.svg",
"sizes": "any",
"type": "image/svg+xml"
},
{
"src": "/static/icons/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "maskable"
}
]
}

15
media_server/static/sw.js Normal file
View File

@@ -0,0 +1,15 @@
// Minimal service worker for PWA installability.
// This app requires a live WebSocket connection, so offline caching is not useful.
// All fetch requests are passed through to the network.
self.addEventListener('install', () => {
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim());
});
self.addEventListener('fetch', (event) => {
event.respondWith(fetch(event.request));
});