Compare commits
9 Commits
b11edc25b9
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| be48318212 | |||
| 0eca8292cb | |||
| 3cfc437599 | |||
| a20812ec29 | |||
| 652f10fc4c | |||
| 3846610042 | |||
| 92d6709d58 | |||
| 9404b37f05 | |||
| 73a6f387e1 |
37
README.md
37
README.md
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -218,6 +239,10 @@ h1 {
|
|||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-btn.active {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.header-btn svg {
|
.header-btn svg {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
@@ -479,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) {
|
||||||
@@ -498,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -824,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;
|
||||||
@@ -922,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 {
|
||||||
@@ -968,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -980,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1017,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 {
|
||||||
@@ -1097,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);
|
||||||
@@ -1122,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1135,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 {
|
||||||
@@ -1248,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1482,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 {
|
||||||
@@ -1619,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 */
|
||||||
@@ -1628,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 {
|
||||||
@@ -2183,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 {
|
||||||
@@ -2254,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2541,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 {
|
||||||
@@ -2668,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 {
|
||||||
@@ -2971,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 {
|
||||||
@@ -3016,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;
|
||||||
}
|
}
|
||||||
@@ -3060,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);
|
||||||
@@ -3113,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
10
media_server/static/icons/icon.svg
Normal file
10
media_server/static/icons/icon.svg
Normal 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 |
@@ -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">
|
||||||
@@ -74,12 +84,15 @@
|
|||||||
<div class="header-toolbar">
|
<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="header-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="header-btn" 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>
|
||||||
@@ -92,7 +105,7 @@
|
|||||||
<option value="ru">RU</option>
|
<option value="ru">RU</option>
|
||||||
</select>
|
</select>
|
||||||
<span class="header-toolbar-sep"></span>
|
<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">
|
<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>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -189,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>
|
||||||
@@ -638,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
314
media_server/static/js/background.js
Normal file
314
media_server/static/js/background.js
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
882
media_server/static/js/browser.js
Normal file
882
media_server/static/js/browser.js
Normal 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();
|
||||||
|
}
|
||||||
209
media_server/static/js/callbacks.js
Normal file
209
media_server/static/js/callbacks.js
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
503
media_server/static/js/core.js
Normal file
503
media_server/static/js/core.js
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
414
media_server/static/js/links.js
Normal file
414
media_server/static/js/links.js
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
294
media_server/static/js/main.js
Normal file
294
media_server/static/js/main.js
Normal 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
742
media_server/static/js/player.js
Normal file
742
media_server/static/js/player.js
Normal 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;
|
||||||
|
}
|
||||||
537
media_server/static/js/scripts.js
Normal file
537
media_server/static/js/scripts.js
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
169
media_server/static/js/websocket.js
Normal file
169
media_server/static/js/websocket.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "Остановлено",
|
||||||
|
|||||||
23
media_server/static/manifest.json
Normal file
23
media_server/static/manifest.json
Normal 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
15
media_server/static/sw.js
Normal 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));
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user