diff --git a/media_server/config_manager.py b/media_server/config_manager.py new file mode 100644 index 0000000..5a59af5 --- /dev/null +++ b/media_server/config_manager.py @@ -0,0 +1,176 @@ +"""Thread-safe configuration file manager for runtime script updates.""" + +import logging +import os +import threading +from pathlib import Path +from typing import Optional + +import yaml + +from .config import ScriptConfig, settings + +logger = logging.getLogger(__name__) + + +class ConfigManager: + """Thread-safe configuration file manager.""" + + def __init__(self, config_path: Optional[Path] = None): + """Initialize the config manager. + + Args: + config_path: Path to config file. If None, will search standard locations. + """ + self._lock = threading.Lock() + self._config_path = config_path or self._find_config_path() + logger.info(f"ConfigManager initialized with path: {self._config_path}") + + def _find_config_path(self) -> Path: + """Find the active config file path. + + Returns: + Path to the config file. + + Raises: + FileNotFoundError: If no config file is found. + """ + # Same search logic as Settings.load_from_yaml() + search_paths = [ + Path("config.yaml"), + Path("config.yml"), + ] + + # Add platform-specific config directory + if os.name == "nt": # Windows + appdata = os.environ.get("APPDATA", "") + if appdata: + search_paths.append(Path(appdata) / "media-server" / "config.yaml") + else: # Linux/Unix/macOS + search_paths.append(Path.home() / ".config" / "media-server" / "config.yaml") + search_paths.append(Path("/etc/media-server/config.yaml")) + + for search_path in search_paths: + if search_path.exists(): + return search_path + + # If not found, use the default location + if os.name == "nt": + default_path = Path(os.environ.get("APPDATA", "")) / "media-server" / "config.yaml" + else: + default_path = Path.home() / ".config" / "media-server" / "config.yaml" + + logger.warning(f"No config file found, using default path: {default_path}") + return default_path + + def add_script(self, name: str, config: ScriptConfig) -> None: + """Add a new script to config. + + Args: + name: Script name (must be unique). + config: Script configuration. + + Raises: + ValueError: If script already exists. + IOError: If config file cannot be written. + """ + with self._lock: + # Read YAML + if not self._config_path.exists(): + data = {} + else: + with open(self._config_path, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) or {} + + # Check if script already exists + if "scripts" in data and name in data["scripts"]: + raise ValueError(f"Script '{name}' already exists") + + # Add script + if "scripts" not in data: + data["scripts"] = {} + data["scripts"][name] = config.model_dump(exclude_none=True) + + # Write YAML + self._config_path.parent.mkdir(parents=True, exist_ok=True) + with open(self._config_path, "w", encoding="utf-8") as f: + yaml.dump(data, f, default_flow_style=False, sort_keys=False) + + # Update in-memory settings + settings.scripts[name] = config + + logger.info(f"Script '{name}' added to config") + + def update_script(self, name: str, config: ScriptConfig) -> None: + """Update an existing script. + + Args: + name: Script name. + config: New script configuration. + + Raises: + ValueError: If script does not exist. + IOError: If config file cannot be written. + """ + with self._lock: + # Read YAML + if not self._config_path.exists(): + raise ValueError(f"Config file not found: {self._config_path}") + + with open(self._config_path, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) or {} + + # Check if script exists + if "scripts" not in data or name not in data["scripts"]: + raise ValueError(f"Script '{name}' does not exist") + + # Update script + data["scripts"][name] = config.model_dump(exclude_none=True) + + # Write YAML + with open(self._config_path, "w", encoding="utf-8") as f: + yaml.dump(data, f, default_flow_style=False, sort_keys=False) + + # Update in-memory settings + settings.scripts[name] = config + + logger.info(f"Script '{name}' updated in config") + + def delete_script(self, name: str) -> None: + """Delete a script from config. + + Args: + name: Script name. + + Raises: + ValueError: If script does not exist. + IOError: If config file cannot be written. + """ + with self._lock: + # Read YAML + if not self._config_path.exists(): + raise ValueError(f"Config file not found: {self._config_path}") + + with open(self._config_path, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) or {} + + # Check if script exists + if "scripts" not in data or name not in data["scripts"]: + raise ValueError(f"Script '{name}' does not exist") + + # Delete script + del data["scripts"][name] + + # Write YAML + with open(self._config_path, "w", encoding="utf-8") as f: + yaml.dump(data, f, default_flow_style=False, sort_keys=False) + + # Update in-memory settings + if name in settings.scripts: + del settings.scripts[name] + + logger.info(f"Script '{name}' deleted from config") + + +# Global config manager instance +config_manager = ConfigManager() diff --git a/media_server/routes/audio.py b/media_server/routes/audio.py new file mode 100644 index 0000000..1e1f1e4 --- /dev/null +++ b/media_server/routes/audio.py @@ -0,0 +1,28 @@ +"""Audio device API endpoints.""" + +import logging + +from fastapi import APIRouter, Depends + +from ..auth import verify_token +from ..services import get_audio_devices + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/audio", tags=["audio"]) + + +@router.get("/devices") +async def list_audio_devices(_: str = Depends(verify_token)) -> list[dict[str, str]]: + """List available audio output devices. + + Returns a list of audio devices with their IDs and friendly names. + Use the device name in the `audio_device` config option to control + a specific device instead of the default one. + + Returns: + List of audio devices with id and name + """ + devices = get_audio_devices() + logger.debug("Found %d audio devices", len(devices)) + return devices diff --git a/media_server/routes/scripts.py b/media_server/routes/scripts.py index e0270ec..5025f78 100644 --- a/media_server/routes/scripts.py +++ b/media_server/routes/scripts.py @@ -2,6 +2,7 @@ import asyncio import logging +import re import subprocess from typing import Any @@ -9,7 +10,9 @@ from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel, Field from ..auth import verify_token -from ..config import settings +from ..config import ScriptConfig, settings +from ..config_manager import config_manager +from ..services.websocket_manager import ws_manager router = APIRouter(prefix="/api/scripts", tags=["scripts"]) logger = logging.getLogger(__name__) @@ -37,6 +40,7 @@ class ScriptInfo(BaseModel): name: str label: str + command: str description: str icon: str | None = None timeout: int @@ -53,6 +57,7 @@ async def list_scripts(_: str = Depends(verify_token)) -> list[ScriptInfo]: ScriptInfo( name=name, label=config.label or name.replace("_", " ").title(), + command=config.command, description=config.description, icon=config.icon, timeout=config.timeout, @@ -166,3 +171,185 @@ def _run_script( "stdout": "", "stderr": str(e), } + + +# Script management endpoints + + +class ScriptCreateRequest(BaseModel): + """Request model for creating or updating a script.""" + + command: str = Field(..., description="Command to execute", min_length=1) + label: str | None = Field(default=None, description="User-friendly label") + description: str = Field(default="", description="Script description") + icon: str | None = Field(default=None, description="Custom MDI icon (e.g., 'mdi:power')") + timeout: int = Field(default=30, description="Execution timeout in seconds", ge=1, le=300) + working_dir: str | None = Field(default=None, description="Working directory") + shell: bool = Field(default=True, description="Run command in shell") + + +def _validate_script_name(name: str) -> None: + """Validate script name. + + Args: + name: Script name to validate. + + Raises: + HTTPException: If name is invalid. + """ + if not name: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Script name cannot be empty", + ) + + if not re.match(r"^[a-zA-Z0-9_]+$", name): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Script name must contain only alphanumeric characters and underscores", + ) + + if len(name) > 64: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Script name must be 64 characters or less", + ) + + +@router.post("/create/{script_name}") +async def create_script( + script_name: str, + request: ScriptCreateRequest, + _: str = Depends(verify_token), +) -> dict[str, Any]: + """Create a new script. + + Args: + script_name: Name for the new script (alphanumeric + underscore only). + request: Script configuration. + + Returns: + Success response with script name. + + Raises: + HTTPException: If script already exists or name is invalid. + """ + # Validate name + _validate_script_name(script_name) + + # Check if script already exists + if script_name in settings.scripts: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Script '{script_name}' already exists. Use PUT /api/scripts/update/{script_name} to update it.", + ) + + # Create script config + script_config = ScriptConfig(**request.model_dump()) + + # Add to config file and in-memory + try: + config_manager.add_script(script_name, script_config) + except Exception as e: + logger.error(f"Failed to add script '{script_name}': {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to add script: {str(e)}", + ) + + # Notify WebSocket clients + await ws_manager.broadcast_scripts_changed() + + logger.info(f"Script '{script_name}' created successfully") + return {"success": True, "script": script_name} + + +@router.put("/update/{script_name}") +async def update_script( + script_name: str, + request: ScriptCreateRequest, + _: str = Depends(verify_token), +) -> dict[str, Any]: + """Update an existing script. + + Args: + script_name: Name of the script to update. + request: Updated script configuration. + + Returns: + Success response with script name. + + Raises: + HTTPException: If script does not exist. + """ + # Validate name + _validate_script_name(script_name) + + # Check if script exists + if script_name not in settings.scripts: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Script '{script_name}' not found. Use POST /api/scripts/create/{script_name} to create it.", + ) + + # Create updated script config + script_config = ScriptConfig(**request.model_dump()) + + # Update config file and in-memory + try: + config_manager.update_script(script_name, script_config) + except Exception as e: + logger.error(f"Failed to update script '{script_name}': {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to update script: {str(e)}", + ) + + # Notify WebSocket clients + await ws_manager.broadcast_scripts_changed() + + logger.info(f"Script '{script_name}' updated successfully") + return {"success": True, "script": script_name} + + +@router.delete("/delete/{script_name}") +async def delete_script( + script_name: str, + _: str = Depends(verify_token), +) -> dict[str, Any]: + """Delete a script. + + Args: + script_name: Name of the script to delete. + + Returns: + Success response with script name. + + Raises: + HTTPException: If script does not exist. + """ + # Validate name + _validate_script_name(script_name) + + # Check if script exists + if script_name not in settings.scripts: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Script '{script_name}' not found", + ) + + # Delete from config file and in-memory + try: + config_manager.delete_script(script_name) + except Exception as e: + logger.error(f"Failed to delete script '{script_name}': {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to delete script: {str(e)}", + ) + + # Notify WebSocket clients + await ws_manager.broadcast_scripts_changed() + + logger.info(f"Script '{script_name}' deleted successfully") + return {"success": True, "script": script_name} diff --git a/media_server/service/install_task_windows.ps1 b/media_server/service/install_task_windows.ps1 index 4aca109..ffef890 100644 --- a/media_server/service/install_task_windows.ps1 +++ b/media_server/service/install_task_windows.ps1 @@ -1,6 +1,7 @@ +Unregister-ScheduledTask -TaskName "MediaServer" -Confirm:$false + # Get the project root directory (two levels up from this script) $projectRoot = (Get-Item $PSScriptRoot).Parent.Parent.FullName - $action = New-ScheduledTaskAction -Execute "python" -Argument "-m media_server.main" -WorkingDirectory $projectRoot $trigger = New-ScheduledTaskTrigger -AtStartup $principal = New-ScheduledTaskPrincipal -UserId "$env:USERNAME" -LogonType S4U -RunLevel Highest diff --git a/media_server/services/__init__.py b/media_server/services/__init__.py index 22f16ea..d8575d8 100644 --- a/media_server/services/__init__.py +++ b/media_server/services/__init__.py @@ -41,8 +41,9 @@ def get_media_controller() -> "MediaController": if system == "Windows": from .windows_media import WindowsMediaController + from ..config import settings - _controller_instance = WindowsMediaController() + _controller_instance = WindowsMediaController(audio_device=settings.audio_device) elif system == "Linux": # Check if running on Android if _is_android(): @@ -72,4 +73,13 @@ def get_current_album_art() -> bytes | None: return None -__all__ = ["get_media_controller", "get_current_album_art"] +def get_audio_devices() -> list[dict[str, str]]: + """Get list of available audio output devices (Windows only for now).""" + system = platform.system() + if system == "Windows": + from .windows_media import WindowsMediaController + return WindowsMediaController.get_audio_devices() + return [] + + +__all__ = ["get_media_controller", "get_current_album_art", "get_audio_devices"] diff --git a/media_server/services/websocket_manager.py b/media_server/services/websocket_manager.py index 7289cb7..03f1cb7 100644 --- a/media_server/services/websocket_manager.py +++ b/media_server/services/websocket_manager.py @@ -68,6 +68,12 @@ class ConnectionManager: for ws in disconnected: await self.disconnect(ws) + async def broadcast_scripts_changed(self) -> None: + """Notify all connected clients that scripts have changed.""" + message = {"type": "scripts_changed", "data": {}} + await self.broadcast(message) + logger.info("Broadcast sent: scripts_changed") + def status_changed( self, old: dict[str, Any] | None, new: dict[str, Any] ) -> bool: diff --git a/media_server/services/windows_media.py b/media_server/services/windows_media.py index ed9186e..440b0ae 100644 --- a/media_server/services/windows_media.py +++ b/media_server/services/windows_media.py @@ -54,41 +54,100 @@ except ImportError: # Volume control imports PYCAW_AVAILABLE = False _volume_control = None +_configured_device_name: str | None = None try: from ctypes import cast, POINTER from comtypes import CLSCTX_ALL, CoInitialize, CoUninitialize from pycaw.pycaw import AudioUtilities, IAudioEndpointVolume - def _init_volume_control(): - """Initialize volume control interface.""" - global _volume_control - if _volume_control is not None: - return _volume_control + import warnings + # Suppress pycaw warnings about missing device properties + warnings.filterwarnings("ignore", category=UserWarning, module="pycaw") + + def _get_all_audio_devices() -> list[dict[str, str]]: + """Get list of all audio output devices.""" + devices = [] try: - devices = AudioUtilities.GetSpeakers() - interface = devices.Activate(IAudioEndpointVolume._iid_, CLSCTX_ALL, None) + # Use pycaw's GetAllDevices which handles property retrieval + all_devices = AudioUtilities.GetAllDevices() + for device in all_devices: + # Only include render (output) devices with valid names + # Render devices have IDs starting with {0.0.0 + if device.FriendlyName and device.id and device.id.startswith("{0.0.0"): + devices.append({ + "id": device.id, + "name": device.FriendlyName, + }) + except Exception as e: + logger.error(f"Error enumerating audio devices: {e}") + return devices + + def _find_device_by_name(device_name: str): + """Find an audio device by its friendly name (partial match). + + Returns the AudioDevice wrapper for the matched device. + """ + try: + # Get all devices and find matching one + all_devices = AudioUtilities.GetAllDevices() + for device in all_devices: + if device.FriendlyName and device_name.lower() in device.FriendlyName.lower(): + logger.info(f"Found audio device: {device.FriendlyName}") + return device + except Exception as e: + logger.error(f"Error finding device by name: {e}") + return None + + def _init_volume_control(device_name: str | None = None): + """Initialize volume control interface. + + Args: + device_name: Name of the audio device to control (partial match). + If None, uses the default audio device. + """ + global _volume_control, _configured_device_name + if _volume_control is not None and device_name == _configured_device_name: + return _volume_control + + _configured_device_name = device_name + + try: + if device_name: + # Find specific device by name + device = _find_device_by_name(device_name) + if device is None: + logger.warning(f"Audio device '{device_name}' not found, using default") + device = AudioUtilities.GetSpeakers() + else: + # Use default device + device = AudioUtilities.GetSpeakers() + + if hasattr(device, 'Activate'): + interface = device.Activate(IAudioEndpointVolume._iid_, CLSCTX_ALL, None) + elif hasattr(device, '_dev'): + interface = device._dev.Activate(IAudioEndpointVolume._iid_, CLSCTX_ALL, None) + else: + logger.warning("Could not activate audio device") + return None + _volume_control = cast(interface, POINTER(IAudioEndpointVolume)) return _volume_control - except AttributeError: - # Try accessing the underlying device - try: - devices = AudioUtilities.GetSpeakers() - if hasattr(devices, '_dev'): - interface = devices._dev.Activate(IAudioEndpointVolume._iid_, CLSCTX_ALL, None) - _volume_control = cast(interface, POINTER(IAudioEndpointVolume)) - return _volume_control - except Exception as e: - logger.debug(f"Volume control init failed: {e}") except Exception as e: - logger.debug(f"Volume control init error: {e}") + logger.error(f"Volume control init error: {e}") return None PYCAW_AVAILABLE = True except ImportError as e: logger.warning(f"pycaw not available: {e}") - def _init_volume_control(): + def _get_all_audio_devices() -> list[dict[str, str]]: + return [] + + def _find_device_by_name(device_name: str): + return None + + def _init_volume_control(device_name: str | None = None): return None WINDOWS_AVAILABLE = WINSDK_AVAILABLE @@ -427,25 +486,38 @@ def _sync_seek(position: float) -> bool: class WindowsMediaController(MediaController): """Media controller for Windows using WinRT and pycaw.""" - def __init__(self): + def __init__(self, audio_device: str | None = None): + """Initialize the Windows media controller. + + Args: + audio_device: Name of the audio device to control (partial match). + If None, uses the default audio device. + """ if not WINDOWS_AVAILABLE: raise RuntimeError( "Windows media control requires winsdk, pycaw, and comtypes packages" ) self._volume_interface = None self._volume_init_attempted = False + self._audio_device = audio_device def _get_volume_interface(self): """Get the audio endpoint volume interface.""" if not self._volume_init_attempted: self._volume_init_attempted = True - self._volume_interface = _init_volume_control() + self._volume_interface = _init_volume_control(self._audio_device) if self._volume_interface: - logger.info("Volume control initialized successfully") + device_info = f" (device: {self._audio_device})" if self._audio_device else " (default device)" + logger.info(f"Volume control initialized successfully{device_info}") else: logger.warning("Volume control not available") return self._volume_interface + @staticmethod + def get_audio_devices() -> list[dict[str, str]]: + """Get list of available audio output devices.""" + return _get_all_audio_devices() + async def get_status(self) -> MediaStatus: """Get current media playback status.""" status = MediaStatus() diff --git a/media_server/static/index.html b/media_server/static/index.html index 63d263d..ab30f12 100644 --- a/media_server/static/index.html +++ b/media_server/static/index.html @@ -329,6 +329,203 @@ pointer-events: none; } + /* Script Management Styles */ + .script-management { + background: var(--bg-secondary); + border-radius: 12px; + padding: 2rem; + margin-top: 2rem; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); + } + + .script-management h2 { + font-size: 1.25rem; + margin-bottom: 1rem; + color: var(--text-primary); + } + + .script-management-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + } + + .add-script-btn { + padding: 0.5rem 1rem; + border-radius: 6px; + background: var(--accent); + border: none; + color: var(--text-primary); + cursor: pointer; + font-size: 0.875rem; + font-weight: 600; + transition: background 0.2s; + } + + .add-script-btn:hover { + background: var(--accent-hover); + } + + .scripts-table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; + } + + .scripts-table th { + text-align: left; + padding: 0.75rem; + border-bottom: 2px solid var(--border); + color: var(--text-secondary); + font-weight: 600; + font-size: 0.75rem; + text-transform: uppercase; + } + + .scripts-table td { + padding: 0.75rem; + border-bottom: 1px solid var(--border); + } + + .scripts-table tr:hover { + background: var(--bg-tertiary); + } + + .scripts-table code { + background: var(--bg-tertiary); + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + color: var(--accent); + } + + .action-btn { + padding: 0.25rem 0.75rem; + margin-right: 0.5rem; + border-radius: 4px; + border: 1px solid var(--border); + background: var(--bg-tertiary); + color: var(--text-primary); + cursor: pointer; + font-size: 0.75rem; + transition: all 0.2s; + } + + .action-btn:hover { + background: var(--accent); + border-color: var(--accent); + } + + .action-btn.delete:hover { + background: var(--error); + border-color: var(--error); + } + + /* Dialog Styles */ + dialog { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 12px; + padding: 0; + max-width: 500px; + width: 90%; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.8); + } + + dialog::backdrop { + background: rgba(0, 0, 0, 0.8); + } + + .dialog-header { + padding: 1.5rem; + border-bottom: 1px solid var(--border); + } + + .dialog-header h3 { + margin: 0; + font-size: 1.25rem; + } + + .dialog-body { + padding: 1.5rem; + } + + .dialog-body label { + display: block; + margin-bottom: 1rem; + color: var(--text-secondary); + font-size: 0.875rem; + } + + .dialog-body input, + .dialog-body textarea { + display: block; + width: 100%; + padding: 0.5rem; + margin-top: 0.25rem; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-primary); + font-family: inherit; + font-size: 0.875rem; + } + + .dialog-body textarea { + min-height: 80px; + resize: vertical; + } + + .dialog-body input:focus, + .dialog-body textarea:focus { + outline: none; + border-color: var(--accent); + } + + .dialog-footer { + padding: 1.5rem; + border-top: 1px solid var(--border); + display: flex; + justify-content: flex-end; + gap: 0.5rem; + } + + .dialog-footer button { + padding: 0.5rem 1rem; + border-radius: 6px; + border: none; + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; + } + + .dialog-footer .btn-primary { + background: var(--accent); + color: var(--text-primary); + } + + .dialog-footer .btn-primary:hover { + background: var(--accent-hover); + } + + .dialog-footer .btn-secondary { + background: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid var(--border); + } + + .dialog-footer .btn-secondary:hover { + background: var(--border); + } + + .empty-state { + text-align: center; + padding: 2rem; + color: var(--text-muted); + } + .scripts-empty { text-align: center; color: var(--text-muted); @@ -599,8 +796,80 @@
No scripts configured
+ + +
+
+

Script Management

+ +
+ + + + + + + + + + + + + + + +
NameLabelCommandTimeoutActions
No scripts configured. Click "Add Script" to create one.
+
+ + +
+

Add Script

+
+
+
+ + + + + + + + + + + + + + +
+ +
+
+
@@ -613,12 +882,18 @@ let isUserAdjustingVolume = false; let scripts = []; + // Position interpolation + let lastPositionUpdate = 0; + let lastPositionValue = 0; + let interpolationInterval = null; + // Initialize on page load window.addEventListener('DOMContentLoaded', () => { const token = localStorage.getItem('media_server_token'); if (token) { connectWebSocket(token); loadScripts(); + loadScriptsTable(); } else { showAuthForm(); } @@ -704,6 +979,7 @@ updateConnectionStatus(true); hideAuthForm(); loadScripts(); + loadScriptsTable(); }; ws.onmessage = (event) => { @@ -711,6 +987,10 @@ if (msg.type === 'status' || msg.type === 'status_update') { updateUI(msg.data); + } else if (msg.type === 'scripts_changed') { + console.log('Scripts changed, reloading...'); + loadScripts(); // Reload Quick Actions + loadScriptsTable(); // Reload Script Management table } else if (msg.type === 'error') { console.error('WebSocket error:', msg.message); } @@ -724,6 +1004,7 @@ ws.onclose = (event) => { console.log('WebSocket closed:', event.code); updateConnectionStatus(false); + stopPositionInterpolation(); if (event.code === 4001) { // Invalid token @@ -769,6 +1050,7 @@ document.getElementById('album').textContent = status.album || ''; // Update state + const previousState = currentState; currentState = status.state; updatePlaybackState(status.state); @@ -785,6 +1067,11 @@ if (status.duration && status.position !== null) { currentDuration = status.duration; currentPosition = status.position; + + // Track position update for interpolation + lastPositionUpdate = Date.now(); + lastPositionValue = status.position; + updateProgress(status.position, status.duration); } @@ -805,6 +1092,13 @@ document.getElementById('btn-play-pause').disabled = !hasMedia; document.getElementById('btn-next').disabled = !hasMedia; document.getElementById('btn-previous').disabled = !hasMedia; + + // Start/stop position interpolation based on playback state + if (status.state === 'playing' && previousState !== 'playing') { + startPositionInterpolation(); + } else if (status.state !== 'playing' && previousState === 'playing') { + stopPositionInterpolation(); + } } function updatePlaybackState(state) { @@ -843,6 +1137,32 @@ document.getElementById('progress-bar').dataset.duration = duration; } + function startPositionInterpolation() { + // Clear any existing interval + if (interpolationInterval) { + clearInterval(interpolationInterval); + } + + // Update position every 100ms for smooth animation + interpolationInterval = setInterval(() => { + if (currentState === 'playing' && currentDuration > 0 && lastPositionUpdate > 0) { + // Calculate elapsed time since last position update + const elapsed = (Date.now() - lastPositionUpdate) / 1000; + const interpolatedPosition = Math.min(lastPositionValue + elapsed, currentDuration); + + // Update UI with interpolated position + updateProgress(interpolatedPosition, currentDuration); + } + }, 100); + } + + function stopPositionInterpolation() { + if (interpolationInterval) { + clearInterval(interpolationInterval); + interpolationInterval = null; + } + } + function updateMuteIcon(muted) { const muteIcon = document.getElementById('mute-icon'); if (muted) { @@ -1008,6 +1328,193 @@ toast.classList.remove('show'); }, 3000); } + + // Script Management Functions + + async function loadScriptsTable() { + 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 = 'No scripts configured. Click "Add Script" to create one.'; + return; + } + + tbody.innerHTML = scriptsList.map(script => ` + + ${script.name} + ${script.label || script.name} + ${escapeHtml(script.command || 'N/A')} + ${script.timeout}s + + + + + + `).join(''); + } catch (error) { + console.error('Error loading scripts:', error); + tbody.innerHTML = 'Failed to load scripts'; + } + } + + function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + function showAddScriptDialog() { + const dialog = document.getElementById('scriptDialog'); + const form = document.getElementById('scriptForm'); + const title = document.getElementById('dialogTitle'); + + // Reset form + form.reset(); + document.getElementById('scriptOriginalName').value = ''; + document.getElementById('scriptIsEdit').value = 'false'; + document.getElementById('scriptName').disabled = false; + title.textContent = 'Add Script'; + + dialog.showModal(); + } + + async function showEditScriptDialog(scriptName) { + const token = localStorage.getItem('media_server_token'); + const dialog = document.getElementById('scriptDialog'); + const title = document.getElementById('dialogTitle'); + + try { + // Fetch current script details + 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; + } + + // Populate form + document.getElementById('scriptOriginalName').value = scriptName; + document.getElementById('scriptIsEdit').value = 'true'; + document.getElementById('scriptName').value = scriptName; + document.getElementById('scriptName').disabled = true; // Can't change name + 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; + + title.textContent = 'Edit Script'; + dialog.showModal(); + } catch (error) { + console.error('Error loading script for edit:', error); + showToast('Failed to load script details', 'error'); + } + } + + function closeScriptDialog() { + const dialog = document.getElementById('scriptDialog'); + dialog.close(); + } + + async function saveScript(event) { + event.preventDefault(); + + 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'); + closeScriptDialog(); + // Don't reload manually - WebSocket will trigger it + } else { + showToast(result.detail || `Failed to ${isEdit ? 'update' : 'create'} script`, 'error'); + } + } catch (error) { + console.error('Error saving script:', error); + showToast(`Error ${isEdit ? 'updating' : 'creating'} script`, 'error'); + } + } + + async function deleteScriptConfirm(scriptName) { + if (!confirm(`Are you sure you want to delete the script "${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'); + // Don't reload manually - WebSocket will trigger it + } else { + showToast(result.detail || 'Failed to delete script', 'error'); + } + } catch (error) { + console.error('Error deleting script:', error); + showToast('Error deleting script', 'error'); + } + }