Compare commits
7 Commits
d1f621f0b4
...
61cdce9b60
| Author | SHA1 | Date | |
|---|---|---|---|
| 61cdce9b60 | |||
| 0cf49deac0 | |||
| 527f3d0aa4 | |||
| 982dda42ac | |||
| eaeebb64cd | |||
| bcc6d40ed7 | |||
| 770bba7e60 |
+50
-8
@@ -1,14 +1,55 @@
|
||||
## v0.2.3 (2026-05-01)
|
||||
## v0.2.5 (2026-05-16)
|
||||
|
||||
### UI / Player
|
||||
### Security
|
||||
|
||||
- Square the vinyl stage (`1:0.85` → `1:1`) and pin the tonearm to `height: 36%` instead of `aspect-ratio: 1` so its vertical span tracks the stage on resize. Refines the geometry shipped in v0.2.2. ([d27484a](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d27484a))
|
||||
- Brighten the tonearm SVG: lighter pivot/arm gradient stops, thicker stroke widths, stronger cartridge highlight. ([d27484a](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d27484a))
|
||||
- Tilt the sleeve `-2deg` so it reads as resting on the disc rather than rectilinearly composed. ([d27484a](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d27484a))
|
||||
- **Loopback-by-default + auto-generated token:** Server now binds `127.0.0.1` by default; first-run bootstrap generates a random `api_token` and refuses to bind a non-loopback interface without auth unless explicitly opted in. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- **Browser path-traversal hardening:** `BrowserService.validate_path` now rejects absolute paths, drive letters, UNC paths, and NUL bytes. `/api/browser/{play,metadata,thumbnail}` require a `folder_id` plus a folder-relative path — arbitrary filesystem reads via the browser API are no longer possible. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- **Strict input validation on links/scripts:** Pydantic validators reject non-http(s) URLs and any icon outside the `mdi:<slug>` namespace. Create/update/delete on scripts, callbacks, and links is gated by the corresponding `*_management` flags. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- **Hardened response headers + CORS:** Strict `Content-Security-Policy`, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer`, `X-Content-Type-Options: nosniff`. CORS locked to `localhost:<port>` + `127.0.0.1:<port>` by default; configurable for trusted origins. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- **Atomic config writes with restrictive permissions:** `config.yaml` writes go through a temp file + `os.replace` and land with `0o600` on POSIX, so a crash mid-write can never leave a half-written token on disk readable to other users. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- **Subprocess process-group isolation:** Spawned scripts/callbacks now get their own process group (`CREATE_NEW_PROCESS_GROUP` on Windows, `start_new_session=True` on POSIX), so a timeout actually kills the whole tree instead of orphaning child processes. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- **Frontend XSS hardening:** Monitor name + details are `escapeHtml`'d, the power button moved to a delegated `data-action` handler, and remote MDI SVGs are parsed and sanitized (strip `<script>`, `<foreignObject>`, `on*` handlers, `javascript:` hrefs) before they touch `innerHTML`. All dynamic URL segments now go through `encodeURIComponent`. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- **CSP-compliant event wiring:** Strict `script-src 'self'` was blocking every inline `onclick`/`onchange`/`oninput`/`onsubmit` in the UI, leaving buttons and forms silently dead. All 53 inline handler attributes in `index.html` were renamed to `data-on*` and a new `wireInlineHandlers()` in `app.js` parses each expression on `DOMContentLoaded` and attaches a real `addEventListener` — supports no-arg calls, string/number/bool/null literals, and the `event` token. No `unsafe-inline` or `unsafe-hashes` needed. ([eaeebb6](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/eaeebb6))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Displays:** keep the primary-display star visible on long monitor names. Move `overflow: hidden` + ellipsis off the parent flex container onto a new inner span, and add `flex-shrink: 0` to the badge so the favourite indicator no longer gets clipped when the model name truncates. ([372e4eb](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/372e4eb))
|
||||
- **WebSocket reconnect robustness:** Close the previous socket before opening a new one, clear the ping interval per-socket, clear `reconnectTimeout` up-front, retry on `online`/`visibilitychange`, and wrap `JSON.parse` in try/catch — eliminates the stale-socket leaks and "stuck offline after sleep" cases. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- **Artwork fetch race:** `AbortController` + generation guard so a rapid track change can no longer paint the previous track's artwork over the current one. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- **Audio analyzer no longer spins infinitely without a loopback device:** A sticky `_unavailable` flag short-circuits start/stop; cleared by `set_device()` so the user can recover once a device appears. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- **Volume short-circuit cache invalidation:** Cache is now busted when the server reports a remote volume change, so the UI no longer ignores volume updates that happened outside the app. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- **Browser thumbnail race:** Per-folder generation counter + `isConnected` checks; in-flight fetches are aborted on navigation, so thumbnails from a folder you already left can't paint into the current view. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- **Track-skip uses cached title** instead of a full WinRT status round-trip — skip feedback is now instant. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- **Browser list column alignment:** `.browser-list` switched to CSS grid + subgrid so header and rows share column tracks, eliminating the misaligned columns when content widths differed between rows. Matching responsive column overrides applied at the parent. Root-folder SVG sizing (hardcoded 24×24 in `browser.js`) now fills the 56px icon box instead of rendering at ~43%. Compact-grid icon fills its thumb wrapper so the emoji centers instead of being stranded top-left. Premature `isConnected` bail removed from `loadThumbnail` — the img element is intentionally detached when called from `renderBrowserGrid/List`, and the post-await checks already handle navigation-away correctly. ([982dda4](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/982dda4))
|
||||
|
||||
### Performance
|
||||
|
||||
- **Blocking IO off the event loop:** Linux MPRIS/`pactl` calls, `/api/display` DDC/CI handlers, and `browse_directory` are all wrapped in `asyncio.to_thread` — slow SMB shares or laggy monitors can no longer stall the entire async runtime. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- **Windows status poll loop reuse:** The 0.5s status poll now caches one asyncio loop per worker thread via `threading.local` instead of `new_event_loop`/`close` on every tick. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- **WebSocket broadcast: serialize once:** `broadcast()` serializes JSON a single time and uses `send_text` to fan out to all clients. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- **Thumbnail cache cleanup actually runs:** The hourly cleanup task was defined but never scheduled — it is now wired into the lifespan handler so the cache no longer grows unbounded. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- **Progress drag listeners attached only while dragging** — no more global `mousemove` handler firing on every cursor twitch. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
|
||||
### UI/UX
|
||||
|
||||
- **Copper accent consistency:** Green leftover focus rings (`rgba(29,185,84,…)`) replaced with copper (`rgba(var(--copper-rgb),…)`) across the UI. Dialogs now have square corners and a copper top hairline so they read as part of the editorial chrome. `.browser-item` is transparent with a copper hover border (was a filled card). ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- **Audio device select** uses `var(--sans)` instead of the generic system font so it matches surrounding controls. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- **Mobile padding tuned for ≤480px screens.** ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- **Accessible breadcrumb home:** Now a real `<button>` with `aria-label`, and `aria-current` is set on the root. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- **i18n gaps filled:** `display.msg.power_*`, `execution.*`, `scripts.params.execute`, `callbacks.empty` now have proper en + ru strings. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
|
||||
---
|
||||
|
||||
### Development / Internal
|
||||
|
||||
#### Quality
|
||||
|
||||
- All `asyncio.get_event_loop()` in coroutines migrated to `get_running_loop()` (the former is deprecated in Python 3.12+). ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- `ThreadPoolExecutor`s now shut down cleanly during lifespan teardown. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- `config_manager` dedup: 12 near-identical CRUD methods collapsed onto generic `_upsert`/`_delete` helpers — about **290 lines removed** with no behavior change. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- Service worker no longer pass-throughs every fetch. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- M3U playlist written via `NamedTemporaryFile` so a fixed-path symlink can no longer clobber it. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- `__version__` prefers live `pyproject.toml` in dev checkouts so `pip install -e .` users see the source-of-truth version, not the stale metadata baked in at install time. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- `_broadcast_after_open` hardening: initialize status, swallow per-poll errors, and track background tasks in a strong-ref set with done-callback cleanup so they aren't garbage-collected mid-flight. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
|
||||
---
|
||||
|
||||
@@ -17,7 +58,8 @@
|
||||
|
||||
| Hash | Message | Author |
|
||||
|------|---------|--------|
|
||||
| [d27484a](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d27484a) | ui(player): square vinyl stage, brighter tonearm, tilted sleeve | alexei.dolgolyov |
|
||||
| [372e4eb](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/372e4eb) | fix(displays): keep primary-display star visible on long monitor names | alexei.dolgolyov |
|
||||
| [982dda4](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/982dda4) | fix(browser): align list columns via subgrid and fix icon sizing | alexei.dolgolyov |
|
||||
| [eaeebb6](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/eaeebb6) | fix(csp): replace inline on* handlers with data-on* + JS wiring | alexei.dolgolyov |
|
||||
| [bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40) | fix: comprehensive security, bug, performance, and UI/UX audit | alexei.dolgolyov |
|
||||
|
||||
</details>
|
||||
|
||||
+10
-3
@@ -1,7 +1,13 @@
|
||||
# Media Server Configuration
|
||||
# Copy this file to config.yaml and customize as needed.
|
||||
# By default, authentication is DISABLED (no tokens = open access).
|
||||
# To enable auth, uncomment and configure the api_tokens section below.
|
||||
#
|
||||
# Secure-by-default: the server binds to loopback (127.0.0.1) only and refuses
|
||||
# to bind a non-loopback address with no tokens configured.
|
||||
#
|
||||
# To expose on the LAN you must do ONE of:
|
||||
# 1. Configure api_tokens below AND change host to "0.0.0.0", OR
|
||||
# 2. Set `allow_lan_without_auth: true` (LAN-open, no auth — insecure on
|
||||
# hostile networks, only acceptable on a trusted home LAN).
|
||||
|
||||
# API Tokens - Multiple tokens with friendly labels
|
||||
# This allows you to identify which client is making requests in the logs
|
||||
@@ -11,8 +17,9 @@
|
||||
# web_ui: "your-web-ui-token-here"
|
||||
|
||||
# Server settings
|
||||
host: "0.0.0.0"
|
||||
host: "127.0.0.1"
|
||||
port: 8765
|
||||
# allow_lan_without_auth: true # uncomment + change host to 0.0.0.0 for LAN-open mode
|
||||
|
||||
# Custom scripts
|
||||
scripts:
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
"""Media Server - REST API for controlling system media playback."""
|
||||
|
||||
import re
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
from pathlib import Path
|
||||
|
||||
_VERSION_RE = re.compile(r'^version\s*=\s*"([^"]+)"', re.MULTILINE)
|
||||
|
||||
|
||||
def _detect_version() -> str:
|
||||
# 1. Package metadata (works when pip-installed in dev)
|
||||
# 1. Live pyproject.toml — only present in dev checkouts. Prefer this
|
||||
# over installed package metadata so `pip install -e .` users don't
|
||||
# see stale versions after editing pyproject.toml without reinstalling.
|
||||
pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml"
|
||||
if pyproject.is_file():
|
||||
try:
|
||||
match = _VERSION_RE.search(pyproject.read_text(encoding="utf-8"))
|
||||
if match:
|
||||
return match.group(1)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# 2. Package metadata (works for any pip-installed copy).
|
||||
try:
|
||||
return version("media-server")
|
||||
except PackageNotFoundError:
|
||||
pass
|
||||
|
||||
# 2. VERSION file written by build scripts (production builds)
|
||||
# Located at install root, two levels up from this package
|
||||
# 3. VERSION file written by build scripts (production builds).
|
||||
version_file = Path(__file__).resolve().parent.parent.parent / "VERSION"
|
||||
if version_file.is_file():
|
||||
return version_file.read_text().strip()
|
||||
|
||||
+69
-22
@@ -1,6 +1,8 @@
|
||||
"""Configuration management for the media server."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
@@ -8,6 +10,8 @@ import yaml
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MediaFolderConfig(BaseModel):
|
||||
"""Configuration for a media folder."""
|
||||
@@ -81,8 +85,35 @@ class Settings(BaseSettings):
|
||||
)
|
||||
|
||||
# Server settings
|
||||
host: str = Field(default="0.0.0.0", description="Server bind address")
|
||||
host: str = Field(
|
||||
default="127.0.0.1",
|
||||
description=(
|
||||
"Server bind address. Use 127.0.0.1 for loopback-only (default, safest),"
|
||||
" or 0.0.0.0 to expose on the LAN (requires api_tokens to be set)."
|
||||
),
|
||||
)
|
||||
port: int = Field(default=8765, description="Server port")
|
||||
allow_lan_without_auth: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"Allow binding to a non-loopback address with no api_tokens configured."
|
||||
" Off by default to prevent unauthenticated LAN exposure."
|
||||
),
|
||||
)
|
||||
cors_origins: list[str] = Field(
|
||||
default_factory=list,
|
||||
description=(
|
||||
"Allowed CORS origins. Empty (default) means only same-origin requests"
|
||||
" from http://localhost:<port> and http://127.0.0.1:<port>."
|
||||
),
|
||||
)
|
||||
|
||||
# Admin-grade operations (script / callback / link / folder create/update/delete).
|
||||
# When True the same token used for read/play can also persist arbitrary shell
|
||||
# commands. Disable to make the API read+execute only.
|
||||
scripts_management: bool = Field(default=True, description="Allow scripts CRUD via API")
|
||||
callbacks_management: bool = Field(default=True, description="Allow callbacks CRUD via API")
|
||||
links_management: bool = Field(default=True, description="Allow links CRUD via API")
|
||||
|
||||
# Authentication (empty = auth disabled, anyone can access the API)
|
||||
api_tokens: dict[str, str] = Field(
|
||||
@@ -218,21 +249,25 @@ def get_config_dir() -> Path:
|
||||
|
||||
|
||||
def generate_default_config(path: Optional[Path] = None) -> Path:
|
||||
"""Generate a default configuration file with a new API token."""
|
||||
"""Generate a default configuration file with a freshly generated API token.
|
||||
|
||||
The token is written into ``api_tokens.default`` and printed to the logger
|
||||
so first-run users can copy it. Subsequent runs preserve whatever the user
|
||||
has set.
|
||||
"""
|
||||
if path is None:
|
||||
path = get_config_dir() / "config.yaml"
|
||||
|
||||
default_token = secrets.token_urlsafe(32)
|
||||
|
||||
config = {
|
||||
"host": "0.0.0.0",
|
||||
"host": "127.0.0.1",
|
||||
"port": 8765,
|
||||
# "api_tokens": {
|
||||
# "default": "your-secret-token-here",
|
||||
# },
|
||||
"api_tokens": {
|
||||
"default": default_token,
|
||||
},
|
||||
"poll_interval": 1.0,
|
||||
"log_level": "INFO",
|
||||
# Audio device to control (use GET /api/audio/devices to list available devices)
|
||||
# Set to null or remove to use default device
|
||||
# "audio_device": "Speakers (Realtek",
|
||||
"scripts": {
|
||||
"example_script": {
|
||||
"command": "echo Hello from Media Server!",
|
||||
@@ -240,26 +275,38 @@ def generate_default_config(path: Optional[Path] = None) -> Path:
|
||||
"timeout": 10,
|
||||
"shell": True,
|
||||
},
|
||||
# Add your custom scripts here:
|
||||
# "shutdown": {
|
||||
# "command": "shutdown /s /t 60",
|
||||
# "description": "Shutdown computer in 60 seconds",
|
||||
# "timeout": 5,
|
||||
# },
|
||||
# "lock_screen": {
|
||||
# "command": "rundll32.exe user32.dll,LockWorkStation",
|
||||
# "description": "Lock the workstation",
|
||||
# "timeout": 5,
|
||||
# },
|
||||
},
|
||||
}
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(config, f, default_flow_style=False, sort_keys=False)
|
||||
_write_yaml_atomic(path, config)
|
||||
_restrict_config_perms(path)
|
||||
|
||||
logger.info("Generated default config at %s", path)
|
||||
logger.info("API token (label=default): %s", default_token)
|
||||
|
||||
return path
|
||||
|
||||
|
||||
def _write_yaml_atomic(path: Path, data: dict) -> None:
|
||||
"""Write YAML to disk atomically via tmp file + rename, with restricted perms."""
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||
_restrict_config_perms(tmp)
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def _restrict_config_perms(path: Path) -> None:
|
||||
"""On POSIX, ensure config file is readable only by owner (0600)."""
|
||||
if os.name == "nt":
|
||||
return
|
||||
try:
|
||||
os.chmod(path, 0o600)
|
||||
os.chmod(path.parent, 0o700)
|
||||
except OSError:
|
||||
logger.debug("Could not chmod %s", path, exc_info=True)
|
||||
|
||||
|
||||
# Global settings instance
|
||||
settings = Settings.load_from_yaml()
|
||||
|
||||
+147
-402
@@ -1,52 +1,50 @@
|
||||
"""Thread-safe configuration file manager for runtime script updates."""
|
||||
"""Thread-safe configuration file manager for runtime updates."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
from .config import CallbackConfig, LinkConfig, MediaFolderConfig, ScriptConfig, settings
|
||||
from .config import (
|
||||
CallbackConfig,
|
||||
LinkConfig,
|
||||
MediaFolderConfig,
|
||||
ScriptConfig,
|
||||
_restrict_config_perms,
|
||||
_write_yaml_atomic,
|
||||
settings,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConfigManager:
|
||||
"""Thread-safe configuration file manager."""
|
||||
"""Thread-safe configuration file manager.
|
||||
|
||||
All writes go through ``_save()`` which writes to ``config.yaml.tmp`` and
|
||||
then ``os.replace()``s it into place so a crash mid-write cannot corrupt
|
||||
the only persistent user data. On POSIX the file is also chmodded to 0600
|
||||
so co-tenant users cannot read the API token.
|
||||
"""
|
||||
|
||||
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.
|
||||
@staticmethod
|
||||
def _find_config_path() -> Path:
|
||||
"""Find the active config file path (or the default if none exists yet)."""
|
||||
search_paths = [Path("config.yaml"), Path("config.yml")]
|
||||
|
||||
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
|
||||
if os.name == "nt":
|
||||
appdata = os.environ.get("APPDATA", "")
|
||||
if appdata:
|
||||
search_paths.append(Path(appdata) / "media-server" / "config.yaml")
|
||||
else: # Linux/Unix/macOS
|
||||
else:
|
||||
search_paths.append(Path.home() / ".config" / "media-server" / "config.yaml")
|
||||
search_paths.append(Path("/etc/media-server/config.yaml"))
|
||||
|
||||
@@ -54,7 +52,6 @@ class ConfigManager:
|
||||
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:
|
||||
@@ -63,422 +60,170 @@ class ConfigManager:
|
||||
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.
|
||||
def _load(self) -> dict[str, Any]:
|
||||
"""Read the config YAML, returning an empty dict if the file is missing."""
|
||||
if not self._config_path.exists():
|
||||
return {}
|
||||
with open(self._config_path, "r", encoding="utf-8") as f:
|
||||
return yaml.safe_load(f) or {}
|
||||
|
||||
Args:
|
||||
name: Script name (must be unique).
|
||||
config: Script configuration.
|
||||
def _save(self, data: dict[str, Any]) -> None:
|
||||
"""Atomically write the config YAML and lock down its permissions."""
|
||||
self._config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
_write_yaml_atomic(self._config_path, data)
|
||||
_restrict_config_perms(self._config_path)
|
||||
|
||||
Raises:
|
||||
ValueError: If script already exists.
|
||||
IOError: If config file cannot be written.
|
||||
"""
|
||||
# --- Generic per-section CRUD --------------------------------------
|
||||
|
||||
def _upsert(
|
||||
self,
|
||||
section: str,
|
||||
key: str,
|
||||
value: Any,
|
||||
*,
|
||||
require_absent: bool = False,
|
||||
require_present: bool = False,
|
||||
in_memory_target: dict[str, Any] | None = None,
|
||||
verb: str = "set",
|
||||
) -> None:
|
||||
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 {}
|
||||
data = self._load()
|
||||
existing = data.get(section, {})
|
||||
if require_absent and key in existing:
|
||||
raise ValueError(f"{section[:-1].title()} '{key}' already exists")
|
||||
if require_present and (not isinstance(existing, dict) or key not in existing):
|
||||
raise ValueError(f"{section[:-1].title()} '{key}' does not exist")
|
||||
|
||||
# Check if script already exists
|
||||
if "scripts" in data and name in data["scripts"]:
|
||||
raise ValueError(f"Script '{name}' already exists")
|
||||
if not isinstance(existing, dict):
|
||||
existing = {}
|
||||
existing[key] = value.model_dump(exclude_none=True)
|
||||
data[section] = existing
|
||||
|
||||
# Add script
|
||||
if "scripts" not in data:
|
||||
data["scripts"] = {}
|
||||
data["scripts"][name] = config.model_dump(exclude_none=True)
|
||||
self._save(data)
|
||||
|
||||
# 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)
|
||||
if in_memory_target is not None:
|
||||
in_memory_target[key] = value
|
||||
logger.info(f"{section[:-1].title()} '{key}' {verb} in config")
|
||||
|
||||
# Update in-memory settings
|
||||
settings.scripts[name] = config
|
||||
def _delete(
|
||||
self,
|
||||
section: str,
|
||||
key: str,
|
||||
*,
|
||||
in_memory_target: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
with self._lock:
|
||||
data = self._load()
|
||||
existing = data.get(section, {})
|
||||
if not isinstance(existing, dict) or key not in existing:
|
||||
raise ValueError(f"{section[:-1].title()} '{key}' does not exist")
|
||||
del existing[key]
|
||||
data[section] = existing
|
||||
|
||||
logger.info(f"Script '{name}' added to config")
|
||||
self._save(data)
|
||||
|
||||
if in_memory_target is not None and key in in_memory_target:
|
||||
del in_memory_target[key]
|
||||
logger.info(f"{section[:-1].title()} '{key}' deleted from config")
|
||||
|
||||
# --- Scripts -------------------------------------------------------
|
||||
|
||||
def add_script(self, name: str, config: ScriptConfig) -> None:
|
||||
self._upsert(
|
||||
"scripts", name, config,
|
||||
require_absent=True,
|
||||
in_memory_target=settings.scripts,
|
||||
verb="added",
|
||||
)
|
||||
|
||||
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")
|
||||
self._upsert(
|
||||
"scripts", name, config,
|
||||
require_present=True,
|
||||
in_memory_target=settings.scripts,
|
||||
verb="updated",
|
||||
)
|
||||
|
||||
def delete_script(self, name: str) -> None:
|
||||
"""Delete a script from config.
|
||||
self._delete("scripts", name, in_memory_target=settings.scripts)
|
||||
|
||||
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")
|
||||
# --- Callbacks -----------------------------------------------------
|
||||
|
||||
def add_callback(self, name: str, config: CallbackConfig) -> None:
|
||||
"""Add a new callback to config.
|
||||
|
||||
Args:
|
||||
name: Callback name (must be unique).
|
||||
config: Callback configuration.
|
||||
|
||||
Raises:
|
||||
ValueError: If callback 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 callback already exists
|
||||
if "callbacks" in data and name in data["callbacks"]:
|
||||
raise ValueError(f"Callback '{name}' already exists")
|
||||
|
||||
# Add callback
|
||||
if "callbacks" not in data:
|
||||
data["callbacks"] = {}
|
||||
data["callbacks"][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.callbacks[name] = config
|
||||
|
||||
logger.info(f"Callback '{name}' added to config")
|
||||
self._upsert(
|
||||
"callbacks", name, config,
|
||||
require_absent=True,
|
||||
in_memory_target=settings.callbacks,
|
||||
verb="added",
|
||||
)
|
||||
|
||||
def update_callback(self, name: str, config: CallbackConfig) -> None:
|
||||
"""Update an existing callback.
|
||||
|
||||
Args:
|
||||
name: Callback name.
|
||||
config: New callback configuration.
|
||||
|
||||
Raises:
|
||||
ValueError: If callback 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 callback exists
|
||||
if "callbacks" not in data or name not in data["callbacks"]:
|
||||
raise ValueError(f"Callback '{name}' does not exist")
|
||||
|
||||
# Update callback
|
||||
data["callbacks"][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.callbacks[name] = config
|
||||
|
||||
logger.info(f"Callback '{name}' updated in config")
|
||||
self._upsert(
|
||||
"callbacks", name, config,
|
||||
require_present=True,
|
||||
in_memory_target=settings.callbacks,
|
||||
verb="updated",
|
||||
)
|
||||
|
||||
def delete_callback(self, name: str) -> None:
|
||||
"""Delete a callback from config.
|
||||
self._delete("callbacks", name, in_memory_target=settings.callbacks)
|
||||
|
||||
Args:
|
||||
name: Callback name.
|
||||
|
||||
Raises:
|
||||
ValueError: If callback 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 callback exists
|
||||
if "callbacks" not in data or name not in data["callbacks"]:
|
||||
raise ValueError(f"Callback '{name}' does not exist")
|
||||
|
||||
# Delete callback
|
||||
del data["callbacks"][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.callbacks:
|
||||
del settings.callbacks[name]
|
||||
|
||||
logger.info(f"Callback '{name}' deleted from config")
|
||||
# --- Media folders -------------------------------------------------
|
||||
|
||||
def add_media_folder(self, folder_id: str, config: MediaFolderConfig) -> None:
|
||||
"""Add a new media folder to config.
|
||||
|
||||
Args:
|
||||
folder_id: Folder ID (must be unique).
|
||||
config: Media folder configuration.
|
||||
|
||||
Raises:
|
||||
ValueError: If folder 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 folder already exists
|
||||
if "media_folders" in data and folder_id in data["media_folders"]:
|
||||
raise ValueError(f"Media folder '{folder_id}' already exists")
|
||||
|
||||
# Add folder
|
||||
if "media_folders" not in data:
|
||||
data["media_folders"] = {}
|
||||
data["media_folders"][folder_id] = 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.media_folders[folder_id] = config
|
||||
|
||||
logger.info(f"Media folder '{folder_id}' added to config")
|
||||
self._upsert(
|
||||
"media_folders", folder_id, config,
|
||||
require_absent=True,
|
||||
in_memory_target=settings.media_folders,
|
||||
verb="added",
|
||||
)
|
||||
|
||||
def update_media_folder(self, folder_id: str, config: MediaFolderConfig) -> None:
|
||||
"""Update an existing media folder.
|
||||
|
||||
Args:
|
||||
folder_id: Folder ID.
|
||||
config: New media folder configuration.
|
||||
|
||||
Raises:
|
||||
ValueError: If folder 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 folder exists
|
||||
if "media_folders" not in data or folder_id not in data["media_folders"]:
|
||||
raise ValueError(f"Media folder '{folder_id}' does not exist")
|
||||
|
||||
# Update folder
|
||||
data["media_folders"][folder_id] = 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.media_folders[folder_id] = config
|
||||
|
||||
logger.info(f"Media folder '{folder_id}' updated in config")
|
||||
self._upsert(
|
||||
"media_folders", folder_id, config,
|
||||
require_present=True,
|
||||
in_memory_target=settings.media_folders,
|
||||
verb="updated",
|
||||
)
|
||||
|
||||
def delete_media_folder(self, folder_id: str) -> None:
|
||||
"""Delete a media folder from config.
|
||||
self._delete("media_folders", folder_id, in_memory_target=settings.media_folders)
|
||||
|
||||
Args:
|
||||
folder_id: Folder ID.
|
||||
|
||||
Raises:
|
||||
ValueError: If folder 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 folder exists
|
||||
if "media_folders" not in data or folder_id not in data["media_folders"]:
|
||||
raise ValueError(f"Media folder '{folder_id}' does not exist")
|
||||
|
||||
# Delete folder
|
||||
del data["media_folders"][folder_id]
|
||||
|
||||
# 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 folder_id in settings.media_folders:
|
||||
del settings.media_folders[folder_id]
|
||||
|
||||
logger.info(f"Media folder '{folder_id}' deleted from config")
|
||||
# --- Links ---------------------------------------------------------
|
||||
|
||||
def add_link(self, name: str, config: LinkConfig) -> None:
|
||||
"""Add a new link to config."""
|
||||
with self._lock:
|
||||
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 {}
|
||||
|
||||
if "links" in data and name in data["links"]:
|
||||
raise ValueError(f"Link '{name}' already exists")
|
||||
|
||||
if "links" not in data:
|
||||
data["links"] = {}
|
||||
data["links"][name] = config.model_dump(exclude_none=True)
|
||||
|
||||
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)
|
||||
|
||||
settings.links[name] = config
|
||||
logger.info(f"Link '{name}' added to config")
|
||||
self._upsert(
|
||||
"links", name, config,
|
||||
require_absent=True,
|
||||
in_memory_target=settings.links,
|
||||
verb="added",
|
||||
)
|
||||
|
||||
def update_link(self, name: str, config: LinkConfig) -> None:
|
||||
"""Update an existing link."""
|
||||
with self._lock:
|
||||
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 {}
|
||||
|
||||
if "links" not in data or name not in data["links"]:
|
||||
raise ValueError(f"Link '{name}' does not exist")
|
||||
|
||||
data["links"][name] = config.model_dump(exclude_none=True)
|
||||
|
||||
with open(self._config_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||
|
||||
settings.links[name] = config
|
||||
logger.info(f"Link '{name}' updated in config")
|
||||
self._upsert(
|
||||
"links", name, config,
|
||||
require_present=True,
|
||||
in_memory_target=settings.links,
|
||||
verb="updated",
|
||||
)
|
||||
|
||||
def delete_link(self, name: str) -> None:
|
||||
"""Delete a link from config."""
|
||||
self._delete("links", name, in_memory_target=settings.links)
|
||||
|
||||
# --- Top-level settings --------------------------------------------
|
||||
|
||||
def set_setting(self, key: str, value: Any) -> None:
|
||||
"""Set a top-level config setting and persist to YAML."""
|
||||
with self._lock:
|
||||
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 {}
|
||||
|
||||
if "links" not in data or name not in data["links"]:
|
||||
raise ValueError(f"Link '{name}' does not exist")
|
||||
|
||||
del data["links"][name]
|
||||
|
||||
with open(self._config_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||
|
||||
if name in settings.links:
|
||||
del settings.links[name]
|
||||
logger.info(f"Link '{name}' deleted from config")
|
||||
|
||||
def set_setting(self, key: str, value) -> None:
|
||||
"""Set a top-level config setting and persist to YAML.
|
||||
|
||||
Args:
|
||||
key: Setting name (e.g., "visualizer_device").
|
||||
value: Setting value (None removes the key).
|
||||
"""
|
||||
with self._lock:
|
||||
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 {}
|
||||
|
||||
data = self._load()
|
||||
if value is None:
|
||||
data.pop(key, None)
|
||||
else:
|
||||
data[key] = value
|
||||
|
||||
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
|
||||
self._save(data)
|
||||
if hasattr(settings, key):
|
||||
setattr(settings, key, value)
|
||||
logger.info("Setting '%s' updated to: %s", key, value)
|
||||
|
||||
|
||||
# Global config manager instance
|
||||
config_manager = ConfigManager()
|
||||
|
||||
+110
-13
@@ -22,6 +22,7 @@ from .routes import (
|
||||
browser_router,
|
||||
callbacks_router,
|
||||
display_router,
|
||||
foreground_router,
|
||||
health_router,
|
||||
links_router,
|
||||
media_router,
|
||||
@@ -63,10 +64,10 @@ async def lifespan(app: FastAPI):
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"Media Server starting on {settings.host}:{settings.port}")
|
||||
|
||||
# Log authentication status
|
||||
# Log authentication status — never log full or partial token material.
|
||||
if settings.api_tokens:
|
||||
for label, token in settings.api_tokens.items():
|
||||
logger.info(f"API Token [{label}]: {token[:8]}...")
|
||||
labels = ", ".join(settings.api_tokens.keys())
|
||||
logger.info(f"Authentication enabled. Tokens configured: [{labels}]")
|
||||
else:
|
||||
logger.warning("No API tokens configured — authentication is DISABLED")
|
||||
|
||||
@@ -87,6 +88,24 @@ async def lifespan(app: FastAPI):
|
||||
# Store globally so health endpoint can access cached result
|
||||
app.state.update_checker = update_checker
|
||||
|
||||
# Schedule periodic thumbnail cache cleanup so the 500 MB cap is actually
|
||||
# enforced. Runs once at startup and then hourly until shutdown.
|
||||
from .services.thumbnail_service import ThumbnailService
|
||||
|
||||
async def _thumbnail_cleanup_loop() -> None:
|
||||
while True:
|
||||
try:
|
||||
await asyncio.to_thread(ThumbnailService.cleanup_cache)
|
||||
except Exception as e:
|
||||
logger.warning("Thumbnail cache cleanup failed: %s", e)
|
||||
try:
|
||||
await asyncio.sleep(3600)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
|
||||
import asyncio
|
||||
cleanup_task = asyncio.create_task(_thumbnail_cleanup_loop())
|
||||
|
||||
# Register audio visualizer (capture starts on-demand when clients subscribe)
|
||||
analyzer = None
|
||||
if settings.visualizer_enabled:
|
||||
@@ -109,6 +128,13 @@ async def lifespan(app: FastAPI):
|
||||
if update_checker is not None:
|
||||
await update_checker.stop()
|
||||
|
||||
# Cancel periodic thumbnail cleanup
|
||||
cleanup_task.cancel()
|
||||
try:
|
||||
await cleanup_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# Stop audio visualizer
|
||||
await ws_manager.stop_audio_monitor()
|
||||
if analyzer and analyzer.running:
|
||||
@@ -117,6 +143,13 @@ async def lifespan(app: FastAPI):
|
||||
# Stop WebSocket status monitor
|
||||
await ws_manager.stop_status_monitor()
|
||||
|
||||
# Shut down dedicated thread pools so pending scripts don't leak threads
|
||||
from .routes.callbacks import shutdown_callback_executor
|
||||
from .routes.scripts import shutdown_script_executor
|
||||
|
||||
shutdown_script_executor()
|
||||
shutdown_callback_executor()
|
||||
|
||||
# Clean up platform-specific resources
|
||||
import platform as _platform
|
||||
if _platform.system() == "Windows":
|
||||
@@ -138,16 +171,43 @@ def create_app() -> FastAPI:
|
||||
# Compress responses > 1KB
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
||||
|
||||
# Add CORS middleware for cross-origin requests
|
||||
# Token auth is via Authorization header, not cookies, so credentials are not needed
|
||||
# CORS — restrict to same-origin by default; users that integrate the API
|
||||
# from another origin (e.g. Home Assistant on a different host) can set
|
||||
# cors_origins in config.yaml.
|
||||
cors_origins = settings.cors_origins or [
|
||||
f"http://localhost:{settings.port}",
|
||||
f"http://127.0.0.1:{settings.port}",
|
||||
]
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_origins=cors_origins,
|
||||
allow_credentials=False,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Authorization", "Content-Type"],
|
||||
)
|
||||
|
||||
# Security headers — strict CSP for the bundled UI, disallow framing, hide referrer.
|
||||
@app.middleware("http")
|
||||
async def security_headers_middleware(request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
response.headers.setdefault(
|
||||
"Content-Security-Policy",
|
||||
(
|
||||
"default-src 'self'; "
|
||||
"img-src 'self' data: blob: https://api.iconify.design; "
|
||||
"connect-src 'self' https://api.iconify.design ws: wss:; "
|
||||
"script-src 'self'; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"font-src 'self' data:; "
|
||||
"frame-ancestors 'none'; "
|
||||
"base-uri 'self'"
|
||||
),
|
||||
)
|
||||
response.headers.setdefault("X-Frame-Options", "DENY")
|
||||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||
response.headers.setdefault("Referrer-Policy", "no-referrer")
|
||||
return response
|
||||
|
||||
# Add token logging middleware
|
||||
@app.middleware("http")
|
||||
async def token_logging_middleware(request: Request, call_next):
|
||||
@@ -182,6 +242,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(browser_router)
|
||||
app.include_router(callbacks_router)
|
||||
app.include_router(display_router)
|
||||
app.include_router(foreground_router)
|
||||
app.include_router(health_router)
|
||||
app.include_router(links_router)
|
||||
app.include_router(media_router)
|
||||
@@ -247,7 +308,8 @@ def main():
|
||||
if args.generate_config:
|
||||
config_path = generate_default_config()
|
||||
print(f"Configuration file generated at: {config_path}")
|
||||
print("Authentication is disabled by default. Add api_tokens to enable it.")
|
||||
print("A random API token was generated under api_tokens.default.")
|
||||
print("Run `python -m media_server.main --show-token` to view it.")
|
||||
return
|
||||
|
||||
if args.show_token:
|
||||
@@ -260,18 +322,53 @@ def main():
|
||||
print("\nAuthentication is DISABLED (no tokens configured)")
|
||||
return
|
||||
|
||||
# Stderr is invisible when launched via wscript / pythonw (Start Menu shortcut,
|
||||
# autostart). Mirror pre-uvicorn failures to a file in the config dir so the
|
||||
# next silent boot failure is diagnosable.
|
||||
def _fatal(msg: str, exit_code: int = 1) -> None:
|
||||
print(msg, file=sys.stderr)
|
||||
try:
|
||||
log_path = get_config_dir() / "startup-errors.log"
|
||||
from datetime import datetime
|
||||
with open(log_path, "a", encoding="utf-8") as f:
|
||||
f.write(f"[{datetime.now().isoformat(timespec='seconds')}] {msg}\n")
|
||||
except OSError:
|
||||
pass
|
||||
sys.exit(exit_code)
|
||||
|
||||
# First-run bootstrap: if no config has ever been written, generate one
|
||||
# with a random token instead of starting in the insecure "no-auth" mode.
|
||||
config_path = get_config_dir() / "config.yaml"
|
||||
if not config_path.exists() and not settings.api_tokens:
|
||||
try:
|
||||
generate_default_config(config_path)
|
||||
_fatal(
|
||||
f"\nFirst run: generated default config at {config_path}.\n"
|
||||
"Run --show-token to retrieve the API token, then restart.",
|
||||
exit_code=0,
|
||||
)
|
||||
except OSError as e:
|
||||
print(f"WARNING: could not bootstrap config: {e}", file=sys.stderr)
|
||||
|
||||
# Refuse to bind a non-loopback address with no tokens, unless explicitly opted in.
|
||||
non_loopback = args.host not in ("127.0.0.1", "localhost", "::1")
|
||||
if non_loopback and not settings.api_tokens and not settings.allow_lan_without_auth:
|
||||
_fatal(
|
||||
"ERROR: refusing to bind a non-loopback address with no api_tokens configured.\n"
|
||||
"Either set api_tokens in config.yaml, bind to 127.0.0.1,"
|
||||
" or set allow_lan_without_auth: true in config.yaml to override."
|
||||
)
|
||||
|
||||
# Check if port is available before starting
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
try:
|
||||
sock.bind((args.host if args.host != "0.0.0.0" else "127.0.0.1", args.port))
|
||||
except OSError:
|
||||
print(
|
||||
_fatal(
|
||||
f"ERROR: Port {args.port} is already in use. "
|
||||
f"Another instance of Media Server may be running.\n"
|
||||
f"Stop the other process or use --port to pick a different port.",
|
||||
file=sys.stderr,
|
||||
f"Stop the other process or use --port to pick a different port."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
from .tray import PYSTRAY_AVAILABLE, TrayManager
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from .audio import router as audio_router
|
||||
from .browser import router as browser_router
|
||||
from .callbacks import router as callbacks_router
|
||||
from .display import router as display_router
|
||||
from .foreground import router as foreground_router
|
||||
from .health import router as health_router
|
||||
from .links import router as links_router
|
||||
from .media import router as media_router
|
||||
@@ -14,6 +15,7 @@ __all__ = [
|
||||
"browser_router",
|
||||
"callbacks_router",
|
||||
"display_router",
|
||||
"foreground_router",
|
||||
"health_router",
|
||||
"links_router",
|
||||
"media_router",
|
||||
|
||||
+100
-80
@@ -23,6 +23,17 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/browser", tags=["browser"])
|
||||
|
||||
# Strong refs to background tasks so they don't get garbage-collected mid-flight.
|
||||
_background_tasks: set[asyncio.Task] = set()
|
||||
|
||||
|
||||
def _spawn_background(coro) -> asyncio.Task:
|
||||
"""Schedule a background coroutine and keep a strong ref to its Task."""
|
||||
task = asyncio.create_task(coro)
|
||||
_background_tasks.add(task)
|
||||
task.add_done_callback(_background_tasks.discard)
|
||||
return task
|
||||
|
||||
|
||||
def _require_folder_management() -> None:
|
||||
"""Raise 403 if media folder management is disabled in config."""
|
||||
@@ -38,16 +49,23 @@ async def _broadcast_after_open(controller, label: str, max_wait: float = 2.0) -
|
||||
|
||||
Fires as a background task so the HTTP response returns immediately.
|
||||
"""
|
||||
status = None
|
||||
try:
|
||||
interval = 0.3
|
||||
elapsed = 0.0
|
||||
while elapsed < max_wait:
|
||||
await asyncio.sleep(interval)
|
||||
elapsed += interval
|
||||
status = await controller.get_status()
|
||||
try:
|
||||
status = await controller.get_status()
|
||||
except Exception as poll_err: # noqa: BLE001 — broadcast is best-effort
|
||||
logger.debug("get_status during broadcast poll failed: %s", poll_err)
|
||||
continue
|
||||
if status.state in ("playing", "paused"):
|
||||
break
|
||||
|
||||
if status is None:
|
||||
return
|
||||
status_dict = status.model_dump()
|
||||
await ws_manager.broadcast({"type": "status", "data": status_dict})
|
||||
logger.info(f"Broadcasted status update after opening: {label}")
|
||||
@@ -74,9 +92,14 @@ class FolderUpdateRequest(BaseModel):
|
||||
|
||||
|
||||
class PlayRequest(BaseModel):
|
||||
"""Request model for playing a media file."""
|
||||
"""Request model for playing a media file.
|
||||
|
||||
path: str = Field(..., description="Full path to the media file")
|
||||
Both ``folder_id`` and ``path`` are required so the server can validate
|
||||
the file lives inside a configured media folder.
|
||||
"""
|
||||
|
||||
folder_id: str = Field(..., description="Media folder ID")
|
||||
path: str = Field(..., description="Path relative to folder root")
|
||||
|
||||
|
||||
class PlayFolderRequest(BaseModel):
|
||||
@@ -128,8 +151,10 @@ async def create_folder(
|
||||
"""
|
||||
_require_folder_management()
|
||||
try:
|
||||
# Validate folder_id format (alphanumeric and underscore only)
|
||||
if not request.folder_id.replace("_", "").isalnum():
|
||||
# Validate folder_id format (alphanumeric and underscore only).
|
||||
# Same constraint is enforced when validating paths so traversal can't
|
||||
# be smuggled through the ID itself.
|
||||
if not request.folder_id or not request.folder_id.replace("_", "").isalnum():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Folder ID must contain only alphanumeric characters and underscores",
|
||||
@@ -277,13 +302,15 @@ async def browse(
|
||||
# URL decode the path
|
||||
decoded_path = unquote(path)
|
||||
|
||||
# Browse directory
|
||||
result = BrowserService.browse_directory(
|
||||
folder_id=folder_id,
|
||||
path=decoded_path,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
nocache=nocache,
|
||||
# Browse directory in a thread — iterdir() + stat() can block on
|
||||
# network shares for many seconds; never run on the event loop.
|
||||
result = await asyncio.to_thread(
|
||||
BrowserService.browse_directory,
|
||||
folder_id,
|
||||
decoded_path,
|
||||
offset,
|
||||
limit,
|
||||
nocache,
|
||||
)
|
||||
|
||||
return result
|
||||
@@ -307,41 +334,40 @@ async def browse(
|
||||
# Metadata Endpoint
|
||||
@router.get("/metadata")
|
||||
async def get_metadata(
|
||||
path: str = Query(..., description="Full path to media file (URL-encoded)"),
|
||||
folder_id: str = Query(..., description="Media folder ID"),
|
||||
path: str = Query(..., description="Path relative to folder root (URL-encoded)"),
|
||||
_: str = Depends(verify_token),
|
||||
):
|
||||
"""Get metadata for a media file.
|
||||
"""Get metadata for a media file inside a configured media folder.
|
||||
|
||||
Args:
|
||||
path: Full path to the media file (URL-encoded).
|
||||
folder_id: ID of the media folder.
|
||||
path: Path relative to folder root (URL-encoded).
|
||||
|
||||
Returns:
|
||||
Media file metadata.
|
||||
|
||||
Raises:
|
||||
HTTPException: If file not found or metadata extraction fails.
|
||||
"""
|
||||
try:
|
||||
# URL decode the path
|
||||
decoded_path = unquote(path)
|
||||
file_path = Path(decoded_path)
|
||||
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
file_path = BrowserService.validate_path(folder_id, decoded_path)
|
||||
|
||||
if not file_path.is_file():
|
||||
raise HTTPException(status_code=400, detail="Path is not a file")
|
||||
if not BrowserService.is_media_file(file_path):
|
||||
raise HTTPException(status_code=400, detail="File is not a media file")
|
||||
|
||||
# Extract metadata in executor (blocking operation)
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
metadata = await loop.run_in_executor(
|
||||
None,
|
||||
MetadataService.extract_metadata,
|
||||
file_path,
|
||||
)
|
||||
|
||||
return metadata
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -352,59 +378,47 @@ async def get_metadata(
|
||||
# Thumbnail Endpoint
|
||||
@router.get("/thumbnail")
|
||||
async def get_thumbnail(
|
||||
path: str = Query(..., description="Full path to media file (URL-encoded)"),
|
||||
folder_id: str = Query(..., description="Media folder ID"),
|
||||
path: str = Query(..., description="Path relative to folder root (URL-encoded)"),
|
||||
size: str = Query(default="medium", description='Thumbnail size: "small" or "medium"'),
|
||||
_: str = Depends(verify_token),
|
||||
):
|
||||
"""Get thumbnail for a media file.
|
||||
|
||||
Args:
|
||||
path: Full path to the media file (URL-encoded).
|
||||
size: Thumbnail size ("small" or "medium").
|
||||
|
||||
Returns:
|
||||
JPEG image bytes.
|
||||
|
||||
Raises:
|
||||
HTTPException: If file not found or thumbnail generation fails.
|
||||
"""
|
||||
"""Get thumbnail for a media file inside a configured media folder."""
|
||||
try:
|
||||
# URL decode the path
|
||||
decoded_path = unquote(path)
|
||||
file_path = Path(decoded_path)
|
||||
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
file_path = BrowserService.validate_path(folder_id, decoded_path)
|
||||
|
||||
if not file_path.is_file():
|
||||
raise HTTPException(status_code=400, detail="Path is not a file")
|
||||
if not BrowserService.is_media_file(file_path):
|
||||
raise HTTPException(status_code=400, detail="File is not a media file")
|
||||
|
||||
# Validate size
|
||||
if size not in ("small", "medium"):
|
||||
size = "medium"
|
||||
|
||||
# Get thumbnail
|
||||
thumbnail_data = await ThumbnailService.get_thumbnail(file_path, size)
|
||||
|
||||
if thumbnail_data is None:
|
||||
return Response(status_code=204)
|
||||
|
||||
# Calculate ETag (hash of path + mtime)
|
||||
import hashlib
|
||||
stat = file_path.stat()
|
||||
etag_data = f"{file_path}:{stat.st_mtime}:{size}".encode()
|
||||
etag = hashlib.md5(etag_data).hexdigest()
|
||||
|
||||
# Return image with caching headers
|
||||
return Response(
|
||||
content=thumbnail_data,
|
||||
media_type="image/jpeg",
|
||||
headers={
|
||||
"ETag": f'"{etag}"',
|
||||
"Cache-Control": "public, max-age=86400", # 24 hours
|
||||
"Cache-Control": "public, max-age=86400",
|
||||
},
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -420,44 +434,37 @@ async def play_file(
|
||||
):
|
||||
"""Open a media file with the default system player.
|
||||
|
||||
Args:
|
||||
request: Play request with file path.
|
||||
|
||||
Returns:
|
||||
Success message.
|
||||
|
||||
Raises:
|
||||
HTTPException: If file not found or playback fails.
|
||||
Requires both ``folder_id`` and a folder-relative ``path``; the resolved
|
||||
file must live inside the configured media folder and be a recognized
|
||||
media file. This prevents arbitrary OS-handler invocation (e.g.,
|
||||
``os.startfile`` on Windows ``.lnk``/UNC paths).
|
||||
"""
|
||||
try:
|
||||
file_path = Path(request.path)
|
||||
|
||||
# Validate file exists
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
decoded_path = unquote(request.path)
|
||||
file_path = BrowserService.validate_path(request.folder_id, decoded_path)
|
||||
|
||||
if not file_path.is_file():
|
||||
raise HTTPException(status_code=400, detail="Path is not a file")
|
||||
|
||||
# Validate file is a media file
|
||||
if not BrowserService.is_media_file(file_path):
|
||||
raise HTTPException(status_code=400, detail="File is not a media file")
|
||||
|
||||
# Get media controller and open file
|
||||
controller = get_media_controller()
|
||||
success = await controller.open_file(str(file_path))
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="Failed to open file")
|
||||
|
||||
# Poll until player registers with media session API (up to 2s)
|
||||
asyncio.create_task(_broadcast_after_open(controller, file_path.name))
|
||||
_spawn_background(_broadcast_after_open(controller, file_path.name))
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Playing {file_path.name}",
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -489,26 +496,38 @@ async def play_folder(
|
||||
if not full_path.is_dir():
|
||||
raise HTTPException(status_code=400, detail="Path is not a directory")
|
||||
|
||||
# Collect all media files sorted by name
|
||||
media_files = sorted(
|
||||
[f for f in full_path.iterdir() if f.is_file() and BrowserService.is_media_file(f)],
|
||||
key=lambda f: f.name.lower(),
|
||||
)
|
||||
def _scan(directory: Path) -> list[Path]:
|
||||
return sorted(
|
||||
(
|
||||
f for f in directory.iterdir()
|
||||
if f.is_file() and BrowserService.is_media_file(f)
|
||||
),
|
||||
key=lambda f: f.name.lower(),
|
||||
)
|
||||
|
||||
media_files = await asyncio.to_thread(_scan, full_path)
|
||||
|
||||
if not media_files:
|
||||
raise HTTPException(status_code=404, detail="No media files found in this folder")
|
||||
|
||||
# Generate M3U playlist with absolute paths and EXTINF entries
|
||||
# Written to local temp dir to avoid extra SMB file handle on network shares
|
||||
# Uses utf-8-sig (BOM) so players detect encoding properly
|
||||
# Generate M3U playlist with absolute paths and EXTINF entries.
|
||||
# Use NamedTemporaryFile to get a fresh per-call path — prevents
|
||||
# symlink-clobber races between concurrent /play-folder requests
|
||||
# and any local user pre-creating a fixed temp filename.
|
||||
lines = ["#EXTM3U"]
|
||||
for f in media_files:
|
||||
lines.append(f"#EXTINF:-1,{f.stem}")
|
||||
lines.append(str(f))
|
||||
m3u_content = "\r\n".join(lines) + "\r\n"
|
||||
m3u_content = ("\r\n".join(lines) + "\r\n").encode("utf-8-sig")
|
||||
|
||||
playlist_path = Path(tempfile.gettempdir()) / ".media_server_playlist.m3u"
|
||||
playlist_path.write_text(m3u_content, encoding="utf-8-sig")
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="wb",
|
||||
prefix=".media_server_playlist_",
|
||||
suffix=".m3u",
|
||||
delete=False,
|
||||
) as f:
|
||||
f.write(m3u_content)
|
||||
playlist_path = Path(f.name)
|
||||
|
||||
# Open playlist with default player
|
||||
controller = get_media_controller()
|
||||
@@ -517,8 +536,9 @@ async def play_folder(
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="Failed to open playlist")
|
||||
|
||||
# Poll until player registers with media session API (up to 2s)
|
||||
asyncio.create_task(_broadcast_after_open(controller, f"playlist ({len(media_files)} files)"))
|
||||
_spawn_background(
|
||||
_broadcast_after_open(controller, f"playlist ({len(media_files)} files)")
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Any
|
||||
@@ -21,6 +22,22 @@ logger = logging.getLogger(__name__)
|
||||
_callback_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="callback")
|
||||
|
||||
|
||||
def shutdown_callback_executor() -> None:
|
||||
"""Shut down the callback executor cleanly on application teardown."""
|
||||
_callback_executor.shutdown(wait=False, cancel_futures=True)
|
||||
|
||||
|
||||
def _require_callbacks_management() -> None:
|
||||
if not settings.callbacks_management:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=(
|
||||
"Callbacks management is disabled. Set callbacks_management: true"
|
||||
" in config.yaml to enable."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class CallbackInfo(BaseModel):
|
||||
"""Information about a configured callback."""
|
||||
|
||||
@@ -131,7 +148,7 @@ async def execute_callback(
|
||||
|
||||
try:
|
||||
# Execute in dedicated thread pool to not block the default executor
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
result = await loop.run_in_executor(
|
||||
_callback_executor,
|
||||
lambda: _run_callback(
|
||||
@@ -178,6 +195,11 @@ def _run_callback(
|
||||
Dict with exit_code, stdout, stderr, execution_time
|
||||
"""
|
||||
start_time = time.time()
|
||||
popen_kwargs: dict[str, Any] = {}
|
||||
if sys.platform == "win32":
|
||||
popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
else:
|
||||
popen_kwargs["start_new_session"] = True
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
@@ -186,6 +208,7 @@ def _run_callback(
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
**popen_kwargs,
|
||||
)
|
||||
execution_time = time.time() - start_time
|
||||
return {
|
||||
@@ -230,7 +253,7 @@ async def create_callback(
|
||||
Raises:
|
||||
HTTPException: If callback already exists or name is invalid.
|
||||
"""
|
||||
# Validate name
|
||||
_require_callbacks_management()
|
||||
_validate_callback_name(callback_name)
|
||||
|
||||
# Check if callback already exists
|
||||
@@ -278,7 +301,7 @@ async def update_callback(
|
||||
Raises:
|
||||
HTTPException: If callback does not exist.
|
||||
"""
|
||||
# Validate name
|
||||
_require_callbacks_management()
|
||||
_validate_callback_name(callback_name)
|
||||
|
||||
# Check if callback exists
|
||||
@@ -324,7 +347,7 @@ async def delete_callback(
|
||||
Raises:
|
||||
HTTPException: If callback does not exist.
|
||||
"""
|
||||
# Validate name
|
||||
_require_callbacks_management()
|
||||
_validate_callback_name(callback_name)
|
||||
|
||||
# Check if callback exists
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Display brightness, power, contrast, input-source, color-preset and picture-mode API."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
@@ -45,19 +46,21 @@ class PictureModeRequest(BaseModel):
|
||||
code: int = Field(ge=0, le=255)
|
||||
|
||||
|
||||
# DDC/CI hardware writes open a per-monitor handle and can take seconds —
|
||||
# every public endpoint dispatches into a worker thread so the event loop
|
||||
# stays responsive.
|
||||
|
||||
|
||||
@router.get("/monitors")
|
||||
async def get_monitors(
|
||||
refresh: bool = False,
|
||||
rediscover: bool = False,
|
||||
_: str = Depends(verify_token),
|
||||
) -> list[dict]:
|
||||
"""List all connected monitors with their reported DDC/CI capabilities.
|
||||
|
||||
- `refresh=true` bypasses the response TTL cache (re-reads current state).
|
||||
- `rediscover=true` also drops the per-monitor capability cache, forcing
|
||||
a full DDC/CI capability probe. Use after a monitor hot-swap.
|
||||
"""
|
||||
monitors = list_monitors(force_refresh=refresh, rediscover=rediscover)
|
||||
"""List all connected monitors with their reported DDC/CI capabilities."""
|
||||
monitors = await asyncio.to_thread(
|
||||
list_monitors, force_refresh=refresh, rediscover=rediscover
|
||||
)
|
||||
logger.debug("Found %d monitors", len(monitors))
|
||||
return [m.to_dict() for m in monitors]
|
||||
|
||||
@@ -67,7 +70,7 @@ async def set_monitor_brightness(
|
||||
monitor_id: int, request: BrightnessRequest, _: str = Depends(verify_token)
|
||||
) -> dict:
|
||||
"""Set brightness for a specific monitor."""
|
||||
success = set_brightness(monitor_id, request.brightness)
|
||||
success = await asyncio.to_thread(set_brightness, monitor_id, request.brightness)
|
||||
if success:
|
||||
logger.info("Set monitor %d brightness to %d", monitor_id, request.brightness)
|
||||
return {"success": success}
|
||||
@@ -79,7 +82,7 @@ async def set_monitor_power(
|
||||
) -> dict:
|
||||
"""Turn a monitor on or off."""
|
||||
action = "on" if request.on else "off"
|
||||
success = set_power(monitor_id, request.on)
|
||||
success = await asyncio.to_thread(set_power, monitor_id, request.on)
|
||||
if success:
|
||||
logger.info("Set monitor %d power %s", monitor_id, action)
|
||||
return {"success": success}
|
||||
@@ -90,7 +93,7 @@ async def set_monitor_contrast(
|
||||
monitor_id: int, request: ContrastRequest, _: str = Depends(verify_token)
|
||||
) -> dict:
|
||||
"""Set DDC/CI contrast for a specific monitor."""
|
||||
success = set_contrast(monitor_id, request.contrast)
|
||||
success = await asyncio.to_thread(set_contrast, monitor_id, request.contrast)
|
||||
if success:
|
||||
logger.info("Set monitor %d contrast to %d", monitor_id, request.contrast)
|
||||
return {"success": success}
|
||||
@@ -101,7 +104,7 @@ async def set_monitor_input_source(
|
||||
monitor_id: int, request: InputSourceRequest, _: str = Depends(verify_token)
|
||||
) -> dict:
|
||||
"""Switch a monitor's DDC/CI input source (e.g. HDMI1, DP1)."""
|
||||
success = set_input_source(monitor_id, request.source)
|
||||
success = await asyncio.to_thread(set_input_source, monitor_id, request.source)
|
||||
if success:
|
||||
logger.info("Set monitor %d input source to %s", monitor_id, request.source)
|
||||
return {"success": success}
|
||||
@@ -112,7 +115,7 @@ async def set_monitor_color_preset(
|
||||
monitor_id: int, request: ColorPresetRequest, _: str = Depends(verify_token)
|
||||
) -> dict:
|
||||
"""Apply a DDC/CI color preset (color temperature) to the monitor."""
|
||||
success = set_color_preset(monitor_id, request.preset)
|
||||
success = await asyncio.to_thread(set_color_preset, monitor_id, request.preset)
|
||||
if success:
|
||||
logger.info("Set monitor %d color preset to %s", monitor_id, request.preset)
|
||||
return {"success": success}
|
||||
@@ -123,7 +126,7 @@ async def set_monitor_picture_mode(
|
||||
monitor_id: int, request: PictureModeRequest, _: str = Depends(verify_token)
|
||||
) -> dict:
|
||||
"""Apply a DDC/CI picture/scene mode (VCP 0xDC) by raw code."""
|
||||
success = set_picture_mode(monitor_id, request.code)
|
||||
success = await asyncio.to_thread(set_picture_mode, monitor_id, request.code)
|
||||
if success:
|
||||
logger.info("Set monitor %d picture mode to code %d", monitor_id, request.code)
|
||||
return {"success": success}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Foreground (topmost) window/process API."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from ..auth import verify_token
|
||||
from ..services.foreground_service import get_foreground_info
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/foreground", tags=["foreground"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_foreground(
|
||||
refresh: bool = False, _: str = Depends(verify_token)
|
||||
) -> dict:
|
||||
"""Return metadata about the foreground window and owning process.
|
||||
|
||||
The probe is cached for ~500ms server-side; pass ``?refresh=1`` to bypass
|
||||
the cache for one-shot queries.
|
||||
"""
|
||||
info = await asyncio.to_thread(get_foreground_info, refresh)
|
||||
return info.to_dict()
|
||||
@@ -3,9 +3,10 @@
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from ..auth import verify_token
|
||||
from ..config import LinkConfig, settings
|
||||
@@ -15,6 +16,35 @@ from ..services.websocket_manager import ws_manager
|
||||
router = APIRouter(prefix="/api/links", tags=["links"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Only allow MDI iconify slugs and safe `http(s)`-ish URLs through the API.
|
||||
_MDI_ICON_RE = re.compile(r"^mdi:[a-z0-9][a-z0-9-]{0,63}$")
|
||||
_ALLOWED_URL_SCHEMES = {"http", "https"}
|
||||
|
||||
|
||||
def _validate_url(url: str) -> str:
|
||||
"""Ensure the URL is well-formed http(s) — no ``javascript:`` etc."""
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme.lower() not in _ALLOWED_URL_SCHEMES:
|
||||
raise ValueError("URL must start with http:// or https://")
|
||||
if not parsed.netloc:
|
||||
raise ValueError("URL must include a host")
|
||||
return url
|
||||
|
||||
|
||||
def _validate_icon(icon: str) -> str:
|
||||
"""Restrict icon names to safe Material Design Icons slugs."""
|
||||
if not _MDI_ICON_RE.match(icon):
|
||||
raise ValueError("Icon must be of the form 'mdi:<lowercase-slug>'")
|
||||
return icon
|
||||
|
||||
|
||||
def _require_links_management() -> None:
|
||||
if not settings.links_management:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Links management is disabled. Set links_management: true in config.yaml to enable.",
|
||||
)
|
||||
|
||||
|
||||
class LinkInfo(BaseModel):
|
||||
"""Information about a configured link."""
|
||||
@@ -29,22 +59,25 @@ class LinkInfo(BaseModel):
|
||||
class LinkCreateRequest(BaseModel):
|
||||
"""Request model for creating or updating a link."""
|
||||
|
||||
url: str = Field(..., description="URL to open", min_length=1)
|
||||
url: str = Field(..., description="URL to open", min_length=1, max_length=2048)
|
||||
icon: str = Field(default="mdi:link", description="MDI icon name (e.g., 'mdi:led-strip-variant')")
|
||||
label: str = Field(default="", description="Tooltip text")
|
||||
description: str = Field(default="", description="Optional description")
|
||||
label: str = Field(default="", description="Tooltip text", max_length=128)
|
||||
description: str = Field(default="", description="Optional description", max_length=512)
|
||||
|
||||
@field_validator("url")
|
||||
@classmethod
|
||||
def _check_url(cls, v: str) -> str:
|
||||
return _validate_url(v)
|
||||
|
||||
@field_validator("icon")
|
||||
@classmethod
|
||||
def _check_icon(cls, v: str) -> str:
|
||||
return _validate_icon(v)
|
||||
|
||||
|
||||
def _validate_link_name(name: str) -> None:
|
||||
"""Validate link name.
|
||||
|
||||
Args:
|
||||
name: Link name to validate.
|
||||
|
||||
Raises:
|
||||
HTTPException: If name is invalid.
|
||||
"""
|
||||
if not re.match(r'^[a-zA-Z0-9_]+$', name):
|
||||
"""Validate link name."""
|
||||
if not re.match(r"^[a-zA-Z0-9_]+$", name):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Link name must contain only letters, numbers, and underscores",
|
||||
@@ -90,6 +123,7 @@ async def create_link(
|
||||
Returns:
|
||||
Success response with link name.
|
||||
"""
|
||||
_require_links_management()
|
||||
_validate_link_name(link_name)
|
||||
|
||||
if link_name in settings.links:
|
||||
@@ -129,6 +163,7 @@ async def update_link(
|
||||
Returns:
|
||||
Success response with link name.
|
||||
"""
|
||||
_require_links_management()
|
||||
_validate_link_name(link_name)
|
||||
|
||||
if link_name not in settings.links:
|
||||
@@ -166,6 +201,7 @@ async def delete_link(
|
||||
Returns:
|
||||
Success response with link name.
|
||||
"""
|
||||
_require_links_management()
|
||||
_validate_link_name(link_name)
|
||||
|
||||
if link_name not in settings.links:
|
||||
|
||||
@@ -27,7 +27,7 @@ def _run_callback(callback_name: str) -> None:
|
||||
|
||||
try:
|
||||
callback = settings.callbacks[callback_name]
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: _run_script(
|
||||
@@ -285,7 +285,7 @@ async def visualizer_devices(_: str = Depends(verify_token)) -> list[dict[str, s
|
||||
"""List available loopback audio devices for the visualizer."""
|
||||
from ..services.audio_analyzer import AudioAnalyzer
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(None, AudioAnalyzer.list_loopback_devices)
|
||||
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Any
|
||||
@@ -23,6 +25,22 @@ _script_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="script"
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def shutdown_script_executor() -> None:
|
||||
"""Shut down the dedicated executor cleanly on application teardown."""
|
||||
_script_executor.shutdown(wait=False, cancel_futures=True)
|
||||
|
||||
|
||||
def _require_scripts_management() -> None:
|
||||
if not settings.scripts_management:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=(
|
||||
"Scripts management is disabled. Set scripts_management: true"
|
||||
" in config.yaml to enable."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ScriptExecuteRequest(BaseModel):
|
||||
"""Request model for script execution with optional parameters."""
|
||||
|
||||
@@ -233,7 +251,7 @@ async def execute_script(
|
||||
|
||||
try:
|
||||
# Execute in dedicated thread pool to not block the default executor
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
result = await loop.run_in_executor(
|
||||
_script_executor,
|
||||
lambda: _run_script(
|
||||
@@ -285,8 +303,16 @@ def _run_script(
|
||||
start_time = time.time()
|
||||
env = None
|
||||
if extra_env:
|
||||
import os
|
||||
env = {**os.environ, **extra_env}
|
||||
|
||||
# Spawn the script in its own process group / job so a timeout kills the
|
||||
# whole tree, not just the shell (POSIX) and not just the parent (Windows).
|
||||
popen_kwargs: dict[str, Any] = {}
|
||||
if sys.platform == "win32":
|
||||
popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
else:
|
||||
popen_kwargs["start_new_session"] = True
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
@@ -296,6 +322,7 @@ def _run_script(
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
env=env,
|
||||
**popen_kwargs,
|
||||
)
|
||||
execution_time = time.time() - start_time
|
||||
return {
|
||||
@@ -455,7 +482,7 @@ async def create_script(
|
||||
Raises:
|
||||
HTTPException: If script already exists or name is invalid.
|
||||
"""
|
||||
# Validate name
|
||||
_require_scripts_management()
|
||||
_validate_script_name(script_name)
|
||||
|
||||
# Check if script already exists
|
||||
@@ -511,7 +538,7 @@ async def update_script(
|
||||
Raises:
|
||||
HTTPException: If script does not exist.
|
||||
"""
|
||||
# Validate name
|
||||
_require_scripts_management()
|
||||
_validate_script_name(script_name)
|
||||
|
||||
# Check if script exists
|
||||
@@ -565,7 +592,7 @@ async def delete_script(
|
||||
Raises:
|
||||
HTTPException: If script does not exist.
|
||||
"""
|
||||
# Validate name
|
||||
_require_scripts_management()
|
||||
_validate_script_name(script_name)
|
||||
|
||||
# Check if script exists
|
||||
|
||||
@@ -72,6 +72,11 @@ class AudioAnalyzer:
|
||||
self._lifecycle_lock = threading.Lock()
|
||||
self._data: dict | None = None
|
||||
self._current_device_name: str | None = None
|
||||
# Sticky "no usable device" flag — flipped to True if a capture
|
||||
# attempt fails because no loopback device exists. Prevents the
|
||||
# WebSocket manager from looping on start()/stop()/start() forever
|
||||
# when there's nothing to capture. Cleared by set_device().
|
||||
self._unavailable = False
|
||||
# Generation counter — bumped each time _data is refreshed.
|
||||
# Lets the broadcast loop dedupe without comparing dict identity
|
||||
# (which is fragile because we always allocate a new dict).
|
||||
@@ -123,6 +128,10 @@ class AudioAnalyzer:
|
||||
return True
|
||||
if not self.available:
|
||||
return False
|
||||
if self._unavailable:
|
||||
# We already tried and failed to acquire a device. Don't
|
||||
# spin a new capture thread for each new subscriber.
|
||||
return False
|
||||
|
||||
# Reset AGC envelope so a long silent gap between sessions
|
||||
# doesn't make the first new transients clip at the ceiling.
|
||||
@@ -235,6 +244,9 @@ class AudioAnalyzer:
|
||||
|
||||
self.device_name = device_name
|
||||
self._current_device_name = None
|
||||
# Clear the "no device" sticky flag — the user is asking for a
|
||||
# different device so it's worth attempting capture again.
|
||||
self._unavailable = False
|
||||
|
||||
if was_running:
|
||||
return self.start()
|
||||
@@ -269,6 +281,7 @@ class AudioAnalyzer:
|
||||
if device is None:
|
||||
logger.warning("No loopback audio device found - visualizer disabled")
|
||||
self._running = False
|
||||
self._unavailable = True
|
||||
return
|
||||
|
||||
interval = 1.0 / self.target_fps
|
||||
|
||||
@@ -63,14 +63,28 @@ class BrowserService:
|
||||
if not base_path.is_dir():
|
||||
raise ValueError(f"Media folder path is not a directory: {base_path}")
|
||||
|
||||
# Handle relative vs absolute paths
|
||||
if requested_path.startswith("/") or requested_path.startswith("\\"):
|
||||
# Relative to folder root (remove leading slash)
|
||||
requested_path = requested_path.lstrip("/\\")
|
||||
# Reject absolute paths, drive letters, UNC paths, and NUL bytes outright.
|
||||
# Only true folder-relative paths are accepted.
|
||||
if "\x00" in requested_path:
|
||||
raise ValueError("Path contains NUL byte")
|
||||
|
||||
# Strip a single leading "/" or "\\" (legacy callers send "/sub/dir") but
|
||||
# then refuse anything that still looks absolute.
|
||||
cleaned = requested_path.lstrip("/\\")
|
||||
# Detect Windows drive letter like "C:/..." after stripping.
|
||||
if len(cleaned) >= 2 and cleaned[1] == ":":
|
||||
raise ValueError("Absolute paths are not allowed")
|
||||
# Detect raw UNC ("\\\\server\\share") — the lstrip above strips at most
|
||||
# one leading slash, so a UNC original starts with another "\\" or "/".
|
||||
if cleaned.startswith("\\") or cleaned.startswith("/"):
|
||||
raise ValueError("Absolute paths are not allowed")
|
||||
candidate = Path(cleaned) if cleaned else None
|
||||
if candidate is not None and candidate.is_absolute():
|
||||
raise ValueError("Absolute paths are not allowed")
|
||||
|
||||
# Build and resolve full path
|
||||
if requested_path:
|
||||
full_path = (base_path / requested_path).resolve()
|
||||
if cleaned:
|
||||
full_path = (base_path / cleaned).resolve()
|
||||
else:
|
||||
full_path = base_path
|
||||
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
"""Extract page-level metadata from a focused desktop web browser.
|
||||
|
||||
The browser's window title is the reliable signal — every major browser
|
||||
formats it as ``"<page title> - <Browser Name>"``, so stripping the suffix
|
||||
gives us the page title for free.
|
||||
|
||||
URL extraction was attempted via UI Automation (UIA), but Chromium-based
|
||||
browsers (Chrome/Edge/Brave/Vivaldi) keep their accessibility tree dormant
|
||||
unless a screen reader is active or ``--force-renderer-accessibility`` is
|
||||
set — neither is something we want to require from end users. The UIA
|
||||
machinery is still here behind a feature flag in case a future caller
|
||||
opts into the accessibility-flag path; by default we just return the
|
||||
page title and leave ``url=None``.
|
||||
|
||||
Other platforms (macOS via AppleScript, Linux via AT-SPI) are out of scope
|
||||
for this iteration.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# UIA URL extraction is opt-in because Chromium browsers keep their
|
||||
# accessibility tree dormant unless the user starts the browser with
|
||||
# ``--force-renderer-accessibility`` (or a screen reader is running).
|
||||
# Without that, `FindAll` throws and we'd burn 5s per probe retrying.
|
||||
# Set MEDIA_SERVER_BROWSER_UIA=1 to enable; default off.
|
||||
_UIA_ENABLED = os.environ.get("MEDIA_SERVER_BROWSER_UIA", "").lower() in (
|
||||
"1", "true", "yes", "on"
|
||||
)
|
||||
|
||||
|
||||
# Known browser executables (lowercase, .exe-stripped). Used to decide
|
||||
# whether to spend the UIA query budget on this foreground process.
|
||||
BROWSER_PROCESS_HINTS: frozenset[str] = frozenset({
|
||||
"chrome",
|
||||
"msedge",
|
||||
"firefox",
|
||||
"brave",
|
||||
"opera",
|
||||
"vivaldi",
|
||||
"yandex",
|
||||
"browser", # Yandex Browser sometimes reports as browser.exe
|
||||
"arc",
|
||||
"thorium",
|
||||
})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BrowserPageInfo:
|
||||
url: str | None = None
|
||||
page_title: str | None = None
|
||||
|
||||
|
||||
_EMPTY = BrowserPageInfo()
|
||||
|
||||
|
||||
def is_browser_process(process_name: str | None) -> bool:
|
||||
"""Return True when ``process_name`` looks like a supported browser."""
|
||||
if not process_name:
|
||||
return False
|
||||
base = process_name.lower()
|
||||
if base.endswith(".exe"):
|
||||
base = base[:-4]
|
||||
return base in BROWSER_PROCESS_HINTS
|
||||
|
||||
|
||||
def _strip_browser_suffix(title: str | None, process_name: str | None) -> str | None:
|
||||
"""Pull the page title out of the browser's window title.
|
||||
|
||||
Most browsers format their window title as ``"<page> - <Browser Name>"``.
|
||||
We strip the trailing suffix so consumers get the page title alone. If
|
||||
the suffix can't be matched, return the raw title unchanged.
|
||||
"""
|
||||
if not title:
|
||||
return None
|
||||
suffixes = (
|
||||
" - Google Chrome",
|
||||
" — Google Chrome",
|
||||
" - Microsoft Edge",
|
||||
" - Microsoft Edge",
|
||||
" — Mozilla Firefox",
|
||||
" - Mozilla Firefox",
|
||||
" - Brave",
|
||||
" - Opera",
|
||||
" - Vivaldi",
|
||||
" - Yandex",
|
||||
)
|
||||
for s in suffixes:
|
||||
if title.endswith(s):
|
||||
return title[: -len(s)].strip() or None
|
||||
return title
|
||||
|
||||
|
||||
# ─── UIA lookup (Windows) ───────────────────────────────────────────
|
||||
|
||||
# UIA control type / property constants we need. Avoiding the full
|
||||
# UIAutomationClient typelib generation — those constants are stable.
|
||||
_UIA_EditControlTypeId = 50004
|
||||
_UIA_ControlTypePropertyId = 30003
|
||||
_UIA_ValueValuePropertyId = 30045
|
||||
_UIA_NamePropertyId = 30005
|
||||
_UIA_ValuePatternId = 10002
|
||||
_TreeScope_Descendants = 4
|
||||
_PropertyConditionFlags_IgnoreCase = 1
|
||||
|
||||
|
||||
# Lazy import + per-thread COM init.
|
||||
_uia_lock = threading.Lock()
|
||||
_uia_singleton = None
|
||||
_uia_load_error: str | None = None
|
||||
_uia_thread_local = threading.local()
|
||||
|
||||
|
||||
def _ensure_com() -> None:
|
||||
"""Initialise COM on the current thread (idempotent per thread)."""
|
||||
if getattr(_uia_thread_local, "initialised", False):
|
||||
return
|
||||
try:
|
||||
import comtypes # type: ignore
|
||||
|
||||
# COINIT_APARTMENTTHREADED is required by UIA; comtypes' default
|
||||
# CoInitializeEx already passes that flag.
|
||||
comtypes.CoInitialize()
|
||||
_uia_thread_local.initialised = True
|
||||
except Exception as e:
|
||||
logger.debug("CoInitialize failed: %s", e)
|
||||
|
||||
|
||||
def _get_uia():
|
||||
"""Return the IUIAutomation singleton, or None if unavailable."""
|
||||
global _uia_singleton, _uia_load_error
|
||||
if _uia_singleton is not None:
|
||||
return _uia_singleton
|
||||
if _uia_load_error is not None:
|
||||
return None
|
||||
with _uia_lock:
|
||||
if _uia_singleton is not None:
|
||||
return _uia_singleton
|
||||
try:
|
||||
import comtypes.client # type: ignore
|
||||
|
||||
# CLSID for CUIAutomation. Using GetActiveObject would fail,
|
||||
# so we cocreate. comtypes.client.CreateObject keeps the COM
|
||||
# plumbing tidy.
|
||||
_uia_singleton = comtypes.client.CreateObject(
|
||||
"{ff48dba4-60ef-4201-aa87-54103eef594e}",
|
||||
interface=comtypes.client.GetModule(
|
||||
"UIAutomationCore.dll"
|
||||
).IUIAutomation,
|
||||
)
|
||||
return _uia_singleton
|
||||
except Exception as e:
|
||||
_uia_load_error = str(e)
|
||||
logger.info("UIA unavailable; browser URL extraction disabled: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def _find_address_bar_value(hwnd: int) -> str | None:
|
||||
"""Walk the UIA tree under ``hwnd`` looking for the URL Edit control.
|
||||
|
||||
Strategy: find every descendant Edit control, then pick the first one
|
||||
whose Name contains an address-bar hint, or — failing that — the first
|
||||
one whose value parses as a URL-ish string. Browsers expose extra Edit
|
||||
controls (search bars, find-in-page) so name matching is the reliable
|
||||
signal; the URL-ish fallback covers locale variants we haven't seen.
|
||||
"""
|
||||
_ensure_com()
|
||||
uia = _get_uia()
|
||||
if uia is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
element = uia.ElementFromHandle(hwnd)
|
||||
if not element:
|
||||
return None
|
||||
|
||||
# Build a condition matching ControlType=Edit, then enumerate.
|
||||
edit_condition = uia.CreatePropertyCondition(
|
||||
_UIA_ControlTypePropertyId, _UIA_EditControlTypeId
|
||||
)
|
||||
edits = element.FindAll(_TreeScope_Descendants, edit_condition)
|
||||
count = edits.Length if edits else 0
|
||||
if count == 0:
|
||||
return None
|
||||
|
||||
# Hints (lowercase) used to identify the address bar by its Name
|
||||
# property. Covers en-US plus a few common locales / browsers.
|
||||
name_hints = (
|
||||
"address", # Chrome/Edge: "Address and search bar"
|
||||
"адрес", # Chrome ru: "Адресная строка и строка поиска"
|
||||
"адресная",
|
||||
"search with", # Firefox: "Search with Google or enter address"
|
||||
"поиск или ввод", # Firefox ru
|
||||
"url",
|
||||
"location",
|
||||
)
|
||||
|
||||
# First pass: name-based match (high confidence).
|
||||
candidates: list[tuple[int, str]] = []
|
||||
for i in range(count):
|
||||
edit = edits.GetElement(i)
|
||||
try:
|
||||
name = (edit.CurrentName or "").lower()
|
||||
except Exception:
|
||||
name = ""
|
||||
try:
|
||||
value = edit.GetCurrentPropertyValue(_UIA_ValueValuePropertyId)
|
||||
except Exception:
|
||||
value = None
|
||||
if value is None:
|
||||
continue
|
||||
value_str = str(value)
|
||||
for h in name_hints:
|
||||
if h in name:
|
||||
return value_str
|
||||
candidates.append((i, value_str))
|
||||
|
||||
# Second pass: URL-ish fallback. Pick the first candidate that
|
||||
# looks like a URL; this catches browser/locale combos we haven't
|
||||
# listed above.
|
||||
for _i, v in candidates:
|
||||
lv = v.lower()
|
||||
if (
|
||||
lv.startswith("http://")
|
||||
or lv.startswith("https://")
|
||||
or lv.startswith("about:")
|
||||
or lv.startswith("chrome://")
|
||||
or lv.startswith("edge://")
|
||||
or lv.startswith("brave://")
|
||||
or lv.startswith("file://")
|
||||
or lv.startswith("ftp://")
|
||||
):
|
||||
return v
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug("UIA address-bar lookup failed: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
# ─── Per-(hwnd, title) cache ────────────────────────────────────────
|
||||
|
||||
_cache_lock = threading.Lock()
|
||||
_cache_key: tuple[int | None, str | None] = (None, None)
|
||||
_cache_value: BrowserPageInfo = _EMPTY
|
||||
|
||||
|
||||
def get_browser_page(
|
||||
*,
|
||||
hwnd: int | None,
|
||||
process_name: str | None,
|
||||
window_title: str | None,
|
||||
) -> BrowserPageInfo:
|
||||
"""Return the URL + page title for the foreground browser tab, if any.
|
||||
|
||||
Callers pass the already-resolved foreground HWND/title/process_name so
|
||||
this service doesn't re-walk Win32 to find them. Returns ``_EMPTY`` for
|
||||
non-browser processes or when UIA can't resolve the URL.
|
||||
"""
|
||||
if not is_browser_process(process_name):
|
||||
return _EMPTY
|
||||
if platform.system() != "Windows":
|
||||
# macOS/Linux paths not implemented in this iteration.
|
||||
return _EMPTY
|
||||
if not hwnd:
|
||||
return _EMPTY
|
||||
|
||||
global _cache_key, _cache_value
|
||||
key = (hwnd, window_title)
|
||||
with _cache_lock:
|
||||
if key == _cache_key and _cache_value is not _EMPTY:
|
||||
return _cache_value
|
||||
|
||||
url = _find_address_bar_value(hwnd) if _UIA_ENABLED else None
|
||||
page_title = _strip_browser_suffix(window_title, process_name)
|
||||
info = BrowserPageInfo(url=url, page_title=page_title)
|
||||
|
||||
with _cache_lock:
|
||||
_cache_key = key
|
||||
_cache_value = info
|
||||
return info
|
||||
|
||||
|
||||
def reset_cache() -> None:
|
||||
"""Reset the cache. Useful in tests."""
|
||||
global _cache_key, _cache_value
|
||||
with _cache_lock:
|
||||
_cache_key = (None, None)
|
||||
_cache_value = _EMPTY
|
||||
@@ -0,0 +1,514 @@
|
||||
"""Foreground (topmost) window/process tracking.
|
||||
|
||||
Reports the process that currently owns the foreground window, plus useful
|
||||
metadata (window title, executable path, monitor index, whether the window
|
||||
covers a full monitor, process start time).
|
||||
|
||||
All probes happen behind a short TTL cache so the WebSocket status poll and
|
||||
per-entity HA polls don't pay the OS call cost on every tick.
|
||||
|
||||
Windows uses the Win32 API via ``ctypes`` (no extra dependency) and falls back
|
||||
gracefully when individual probes fail. Linux/macOS implementations are
|
||||
best-effort and return ``available=False`` when the required tooling is
|
||||
missing, so the rest of the stack keeps working.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import platform
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import asdict, dataclass, field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CACHE_TTL = 0.5 # seconds — fast enough for WebSocket broadcast loop
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ForegroundInfo:
|
||||
"""Snapshot of the foreground window/process."""
|
||||
|
||||
available: bool
|
||||
pid: int | None = None
|
||||
process_name: str | None = None
|
||||
executable_path: str | None = None
|
||||
window_title: str | None = None
|
||||
window_handle: int | None = None
|
||||
is_fullscreen: bool = False
|
||||
is_minimized: bool = False
|
||||
monitor_id: int | None = None
|
||||
monitor_geometry: dict[str, int] | None = None
|
||||
window_geometry: dict[str, int] | None = None
|
||||
started_at: float | None = None
|
||||
platform: str = field(default_factory=lambda: platform.system())
|
||||
error: str | None = None
|
||||
# Populated only when the foreground process is a recognised web
|
||||
# browser. ``browser_page_title`` is derived from the window title
|
||||
# (suffix stripped); ``browser_url`` requires UIA to succeed.
|
||||
is_browser: bool = False
|
||||
browser_url: str | None = None
|
||||
browser_page_title: str | None = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
_UNAVAILABLE = ForegroundInfo(available=False)
|
||||
|
||||
|
||||
class _Cache:
|
||||
"""Single-slot TTL cache shared across callers."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._value: ForegroundInfo | None = None
|
||||
self._fetched_at: float = 0.0
|
||||
|
||||
def get(self, ttl: float, fetch) -> ForegroundInfo:
|
||||
with self._lock:
|
||||
now = time.monotonic()
|
||||
if self._value is not None and (now - self._fetched_at) < ttl:
|
||||
return self._value
|
||||
# Fetch outside the lock — OS calls can take tens of ms.
|
||||
value = fetch()
|
||||
with self._lock:
|
||||
self._value = value
|
||||
self._fetched_at = time.monotonic()
|
||||
return value
|
||||
|
||||
def invalidate(self) -> None:
|
||||
with self._lock:
|
||||
self._value = None
|
||||
self._fetched_at = 0.0
|
||||
|
||||
|
||||
_cache = _Cache()
|
||||
|
||||
|
||||
def _probe_windows() -> ForegroundInfo:
|
||||
"""Probe foreground window state on Windows via Win32 API."""
|
||||
import ctypes
|
||||
import ctypes.wintypes as wt
|
||||
|
||||
user32 = ctypes.WinDLL("user32", use_last_error=True)
|
||||
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
psapi = ctypes.WinDLL("psapi", use_last_error=True)
|
||||
|
||||
# CRITICAL: declare argtypes/restype on every Win32 call that returns a
|
||||
# HANDLE/HWND/HMONITOR. ctypes defaults to `c_int` (32-bit) which
|
||||
# silently truncates 64-bit pointer values on x64 — that corrupts the
|
||||
# handle so `CloseHandle()` can either fail or close the wrong kernel
|
||||
# object, and pointer-equality comparisons (monitor index lookup) miss.
|
||||
user32.GetForegroundWindow.restype = wt.HWND
|
||||
user32.GetWindowThreadProcessId.argtypes = [wt.HWND, ctypes.POINTER(wt.DWORD)]
|
||||
user32.GetWindowThreadProcessId.restype = wt.DWORD
|
||||
user32.GetWindowTextLengthW.argtypes = [wt.HWND]
|
||||
user32.GetWindowTextLengthW.restype = ctypes.c_int
|
||||
user32.GetWindowTextW.argtypes = [wt.HWND, wt.LPWSTR, ctypes.c_int]
|
||||
user32.GetWindowTextW.restype = ctypes.c_int
|
||||
user32.IsIconic.argtypes = [wt.HWND]
|
||||
user32.IsIconic.restype = wt.BOOL
|
||||
user32.GetWindowRect.argtypes = [wt.HWND, ctypes.POINTER(wt.RECT)]
|
||||
user32.GetWindowRect.restype = wt.BOOL
|
||||
user32.MonitorFromWindow.argtypes = [wt.HWND, wt.DWORD]
|
||||
user32.MonitorFromWindow.restype = wt.HMONITOR
|
||||
user32.GetMonitorInfoW.argtypes = [wt.HMONITOR, ctypes.c_void_p]
|
||||
user32.GetMonitorInfoW.restype = wt.BOOL
|
||||
|
||||
kernel32.OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]
|
||||
kernel32.OpenProcess.restype = wt.HANDLE
|
||||
kernel32.CloseHandle.argtypes = [wt.HANDLE]
|
||||
kernel32.CloseHandle.restype = wt.BOOL
|
||||
kernel32.QueryFullProcessImageNameW.argtypes = [
|
||||
wt.HANDLE, wt.DWORD, wt.LPWSTR, ctypes.POINTER(wt.DWORD)
|
||||
]
|
||||
kernel32.QueryFullProcessImageNameW.restype = wt.BOOL
|
||||
kernel32.GetProcessTimes.argtypes = [
|
||||
wt.HANDLE,
|
||||
ctypes.POINTER(wt.FILETIME),
|
||||
ctypes.POINTER(wt.FILETIME),
|
||||
ctypes.POINTER(wt.FILETIME),
|
||||
ctypes.POINTER(wt.FILETIME),
|
||||
]
|
||||
kernel32.GetProcessTimes.restype = wt.BOOL
|
||||
|
||||
psapi.GetModuleFileNameExW.argtypes = [wt.HANDLE, wt.HMODULE, wt.LPWSTR, wt.DWORD]
|
||||
psapi.GetModuleFileNameExW.restype = wt.DWORD
|
||||
|
||||
hwnd = user32.GetForegroundWindow()
|
||||
if not hwnd:
|
||||
return ForegroundInfo(available=True, error="no foreground window")
|
||||
|
||||
# PID + window thread.
|
||||
pid = wt.DWORD(0)
|
||||
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
|
||||
pid_val = int(pid.value) if pid.value else None
|
||||
|
||||
# Window title — Unicode.
|
||||
length = user32.GetWindowTextLengthW(hwnd)
|
||||
title_buf = ctypes.create_unicode_buffer(length + 1)
|
||||
user32.GetWindowTextW(hwnd, title_buf, length + 1)
|
||||
window_title = title_buf.value or None
|
||||
|
||||
# Minimized flag.
|
||||
is_minimized = bool(user32.IsIconic(hwnd))
|
||||
|
||||
# Window rect (screen coords).
|
||||
rect = wt.RECT()
|
||||
window_geometry: dict[str, int] | None = None
|
||||
if user32.GetWindowRect(hwnd, ctypes.byref(rect)):
|
||||
window_geometry = {
|
||||
"left": int(rect.left),
|
||||
"top": int(rect.top),
|
||||
"right": int(rect.right),
|
||||
"bottom": int(rect.bottom),
|
||||
"width": int(rect.right - rect.left),
|
||||
"height": int(rect.bottom - rect.top),
|
||||
}
|
||||
|
||||
# Monitor under the window + its geometry.
|
||||
monitor_geometry: dict[str, int] | None = None
|
||||
monitor_id: int | None = None
|
||||
is_fullscreen = False
|
||||
try:
|
||||
MONITOR_DEFAULTTONEAREST = 2
|
||||
|
||||
class MONITORINFO(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("cbSize", wt.DWORD),
|
||||
("rcMonitor", wt.RECT),
|
||||
("rcWork", wt.RECT),
|
||||
("dwFlags", wt.DWORD),
|
||||
]
|
||||
|
||||
hmon = user32.MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST)
|
||||
if hmon:
|
||||
mi = MONITORINFO()
|
||||
mi.cbSize = ctypes.sizeof(mi)
|
||||
if user32.GetMonitorInfoW(hmon, ctypes.byref(mi)):
|
||||
monitor_geometry = {
|
||||
"left": int(mi.rcMonitor.left),
|
||||
"top": int(mi.rcMonitor.top),
|
||||
"right": int(mi.rcMonitor.right),
|
||||
"bottom": int(mi.rcMonitor.bottom),
|
||||
"width": int(mi.rcMonitor.right - mi.rcMonitor.left),
|
||||
"height": int(mi.rcMonitor.bottom - mi.rcMonitor.top),
|
||||
}
|
||||
# Fullscreen heuristic: window rect equals monitor rect AND
|
||||
# not minimized. Many media players (VLC, browser fullscreen)
|
||||
# set themselves to exactly the monitor bounds.
|
||||
if window_geometry and not is_minimized:
|
||||
is_fullscreen = (
|
||||
window_geometry["left"] == monitor_geometry["left"]
|
||||
and window_geometry["top"] == monitor_geometry["top"]
|
||||
and window_geometry["right"] == monitor_geometry["right"]
|
||||
and window_geometry["bottom"] == monitor_geometry["bottom"]
|
||||
)
|
||||
|
||||
# Resolve monitor index by enumerating displays in order. Coerce
|
||||
# both the foreground hmon and the per-enum hmon to int so the
|
||||
# equality compare uses 64-bit values consistently regardless of
|
||||
# how ctypes represents the handle internally.
|
||||
try:
|
||||
indexed: list[int] = []
|
||||
|
||||
def _cb(hm, _hdc, _rect, _data):
|
||||
indexed.append(int(hm) if hm else 0)
|
||||
return True
|
||||
|
||||
MONITORENUMPROC = ctypes.WINFUNCTYPE(
|
||||
ctypes.c_int,
|
||||
wt.HMONITOR,
|
||||
wt.HDC,
|
||||
ctypes.POINTER(wt.RECT),
|
||||
wt.LPARAM,
|
||||
)
|
||||
user32.EnumDisplayMonitors.argtypes = [
|
||||
wt.HDC, ctypes.POINTER(wt.RECT), MONITORENUMPROC, wt.LPARAM
|
||||
]
|
||||
user32.EnumDisplayMonitors.restype = wt.BOOL
|
||||
user32.EnumDisplayMonitors(None, None, MONITORENUMPROC(_cb), 0)
|
||||
target = int(hmon) if hmon else 0
|
||||
if target and target in indexed:
|
||||
monitor_id = indexed.index(target)
|
||||
except Exception as e:
|
||||
logger.debug("Monitor index resolution failed: %s", e)
|
||||
except Exception as e:
|
||||
logger.debug("Monitor info probe failed: %s", e)
|
||||
|
||||
# Process executable path + start time.
|
||||
executable_path: str | None = None
|
||||
process_name: str | None = None
|
||||
started_at: float | None = None
|
||||
if pid_val:
|
||||
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
||||
h_proc = kernel32.OpenProcess(
|
||||
PROCESS_QUERY_LIMITED_INFORMATION, False, pid_val
|
||||
)
|
||||
if h_proc:
|
||||
try:
|
||||
# Image filename — full path. QueryFullProcessImageNameW works
|
||||
# across 32/64-bit boundaries, unlike GetModuleFileNameExW.
|
||||
buf = ctypes.create_unicode_buffer(1024)
|
||||
size = wt.DWORD(len(buf))
|
||||
if kernel32.QueryFullProcessImageNameW(
|
||||
h_proc, 0, buf, ctypes.byref(size)
|
||||
):
|
||||
executable_path = buf.value or None
|
||||
else:
|
||||
# Fallback via psapi. Return value is the length copied
|
||||
# into the buffer (0 on failure); ignoring it would leave
|
||||
# `executable_path` as an empty string from the freshly
|
||||
# allocated buffer instead of None.
|
||||
written = psapi.GetModuleFileNameExW(h_proc, None, buf, len(buf))
|
||||
if written:
|
||||
executable_path = buf.value or None
|
||||
else:
|
||||
logger.debug(
|
||||
"QueryFullProcessImageNameW + psapi fallback both "
|
||||
"failed for pid=%s (err=%d)",
|
||||
pid_val,
|
||||
ctypes.get_last_error(),
|
||||
)
|
||||
|
||||
if executable_path:
|
||||
import os
|
||||
process_name = os.path.basename(executable_path)
|
||||
|
||||
# Process creation time (FILETIME, 100ns ticks since 1601).
|
||||
creation = wt.FILETIME()
|
||||
exit_t = wt.FILETIME()
|
||||
kernel_t = wt.FILETIME()
|
||||
user_t = wt.FILETIME()
|
||||
if kernel32.GetProcessTimes(
|
||||
h_proc,
|
||||
ctypes.byref(creation),
|
||||
ctypes.byref(exit_t),
|
||||
ctypes.byref(kernel_t),
|
||||
ctypes.byref(user_t),
|
||||
):
|
||||
ticks = (creation.dwHighDateTime << 32) | creation.dwLowDateTime
|
||||
# Convert to Unix epoch seconds (1601-01-01 → 1970-01-01).
|
||||
if ticks:
|
||||
started_at = (ticks - 116444736000000000) / 10_000_000
|
||||
finally:
|
||||
kernel32.CloseHandle(h_proc)
|
||||
|
||||
return ForegroundInfo(
|
||||
available=True,
|
||||
pid=pid_val,
|
||||
process_name=process_name,
|
||||
executable_path=executable_path,
|
||||
window_title=window_title,
|
||||
window_handle=int(hwnd) if hwnd else None,
|
||||
is_fullscreen=is_fullscreen,
|
||||
is_minimized=is_minimized,
|
||||
monitor_id=monitor_id,
|
||||
monitor_geometry=monitor_geometry,
|
||||
window_geometry=window_geometry,
|
||||
started_at=started_at,
|
||||
)
|
||||
|
||||
|
||||
def _probe_macos() -> ForegroundInfo:
|
||||
"""Best-effort probe on macOS via AppKit (PyObjC).
|
||||
|
||||
Returns ``available=False`` when PyObjC is not installed — we don't take
|
||||
a hard dependency on it because the typical macOS install path uses pip
|
||||
+ the standalone wheel.
|
||||
"""
|
||||
try:
|
||||
from AppKit import NSWorkspace # type: ignore
|
||||
from Quartz import ( # type: ignore
|
||||
CGWindowListCopyWindowInfo,
|
||||
kCGNullWindowID,
|
||||
kCGWindowListOptionOnScreenOnly,
|
||||
)
|
||||
except Exception:
|
||||
return ForegroundInfo(available=False, error="AppKit/Quartz not available")
|
||||
|
||||
try:
|
||||
ws = NSWorkspace.sharedWorkspace()
|
||||
app = ws.frontmostApplication()
|
||||
if app is None:
|
||||
return ForegroundInfo(available=True, error="no frontmost app")
|
||||
|
||||
pid = int(app.processIdentifier())
|
||||
process_name = str(app.localizedName() or "")
|
||||
bundle_url = app.bundleURL()
|
||||
executable_path = str(bundle_url.path()) if bundle_url else None
|
||||
started_at = None
|
||||
launch_date = app.launchDate()
|
||||
if launch_date is not None:
|
||||
started_at = float(launch_date.timeIntervalSince1970())
|
||||
|
||||
# Window title — frontmost on-screen window owned by this PID.
|
||||
window_title: str | None = None
|
||||
try:
|
||||
windows = CGWindowListCopyWindowInfo(
|
||||
kCGWindowListOptionOnScreenOnly, kCGNullWindowID
|
||||
)
|
||||
for w in windows or []:
|
||||
if int(w.get("kCGWindowOwnerPID", -1)) == pid:
|
||||
name = w.get("kCGWindowName")
|
||||
if name:
|
||||
window_title = str(name)
|
||||
break
|
||||
except Exception as e:
|
||||
logger.debug("CGWindowListCopyWindowInfo failed: %s", e)
|
||||
|
||||
return ForegroundInfo(
|
||||
available=True,
|
||||
pid=pid,
|
||||
process_name=process_name,
|
||||
executable_path=executable_path,
|
||||
window_title=window_title,
|
||||
started_at=started_at,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("macOS foreground probe failed: %s", e)
|
||||
return ForegroundInfo(available=False, error=str(e))
|
||||
|
||||
|
||||
def _probe_linux() -> ForegroundInfo:
|
||||
"""Best-effort probe on Linux via Xlib (X11 only).
|
||||
|
||||
Wayland sessions intentionally hide window/process info from unprivileged
|
||||
clients, so this returns ``available=False`` on Wayland. The caller still
|
||||
gets a structured response and can render "unavailable" in the UI.
|
||||
"""
|
||||
import os
|
||||
|
||||
if os.environ.get("WAYLAND_DISPLAY"):
|
||||
return ForegroundInfo(
|
||||
available=False, error="Wayland session — foreground probe unavailable"
|
||||
)
|
||||
|
||||
try:
|
||||
from Xlib import display, X # type: ignore # noqa: F401
|
||||
except Exception:
|
||||
return ForegroundInfo(available=False, error="python-xlib not installed")
|
||||
|
||||
try:
|
||||
d = display.Display()
|
||||
root = d.screen().root
|
||||
NET_ACTIVE_WINDOW = d.intern_atom("_NET_ACTIVE_WINDOW")
|
||||
NET_WM_PID = d.intern_atom("_NET_WM_PID")
|
||||
NET_WM_NAME = d.intern_atom("_NET_WM_NAME")
|
||||
UTF8_STRING = d.intern_atom("UTF8_STRING")
|
||||
|
||||
active = root.get_full_property(NET_ACTIVE_WINDOW, X.AnyPropertyType)
|
||||
if not active or not active.value:
|
||||
return ForegroundInfo(available=True, error="no active window")
|
||||
win_id = int(active.value[0])
|
||||
win = d.create_resource_object("window", win_id)
|
||||
|
||||
pid_prop = win.get_full_property(NET_WM_PID, X.AnyPropertyType)
|
||||
pid_val = int(pid_prop.value[0]) if pid_prop and pid_prop.value else None
|
||||
|
||||
name_prop = win.get_full_property(NET_WM_NAME, UTF8_STRING)
|
||||
window_title = (
|
||||
name_prop.value.decode("utf-8", "replace") if name_prop and name_prop.value else None
|
||||
)
|
||||
|
||||
process_name: str | None = None
|
||||
executable_path: str | None = None
|
||||
started_at: float | None = None
|
||||
if pid_val:
|
||||
try:
|
||||
exe = os.readlink(f"/proc/{pid_val}/exe")
|
||||
executable_path = exe
|
||||
process_name = os.path.basename(exe)
|
||||
except OSError as e:
|
||||
logger.debug("readlink /proc/%d/exe failed: %s", pid_val, e)
|
||||
try:
|
||||
started_at = os.stat(f"/proc/{pid_val}").st_ctime
|
||||
except OSError as e:
|
||||
logger.debug("stat /proc/%d failed: %s", pid_val, e)
|
||||
|
||||
return ForegroundInfo(
|
||||
available=True,
|
||||
pid=pid_val,
|
||||
process_name=process_name,
|
||||
executable_path=executable_path,
|
||||
window_title=window_title,
|
||||
window_handle=win_id,
|
||||
started_at=started_at,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Linux foreground probe failed: %s", e)
|
||||
return ForegroundInfo(available=False, error=str(e))
|
||||
|
||||
|
||||
def _enrich_browser(info: ForegroundInfo) -> ForegroundInfo:
|
||||
"""If ``info`` describes a focused browser, attach URL + page title.
|
||||
|
||||
The UIA lookup is wrapped in its own try/except so a failure here can't
|
||||
take down the rest of the foreground probe.
|
||||
"""
|
||||
try:
|
||||
from . import browser_url_service as bus
|
||||
except Exception as e:
|
||||
logger.debug("browser_url_service unavailable: %s", e)
|
||||
return info
|
||||
|
||||
if not info.available or not bus.is_browser_process(info.process_name):
|
||||
return info
|
||||
|
||||
try:
|
||||
page = bus.get_browser_page(
|
||||
hwnd=info.window_handle,
|
||||
process_name=info.process_name,
|
||||
window_title=info.window_title,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Browser URL enrichment failed: %s", e)
|
||||
return info
|
||||
|
||||
# ``dataclasses.replace`` keeps the frozen-dataclass contract.
|
||||
from dataclasses import replace
|
||||
return replace(
|
||||
info,
|
||||
is_browser=True,
|
||||
browser_url=page.url,
|
||||
browser_page_title=page.page_title,
|
||||
)
|
||||
|
||||
|
||||
def _probe() -> ForegroundInfo:
|
||||
system = platform.system()
|
||||
try:
|
||||
if system == "Windows":
|
||||
info = _probe_windows()
|
||||
elif system == "Darwin":
|
||||
info = _probe_macos()
|
||||
elif system == "Linux":
|
||||
info = _probe_linux()
|
||||
else:
|
||||
return ForegroundInfo(
|
||||
available=False, error=f"unsupported platform: {system}"
|
||||
)
|
||||
return _enrich_browser(info)
|
||||
except Exception as e:
|
||||
logger.warning("Foreground probe crashed: %s", e)
|
||||
return ForegroundInfo(available=False, error=str(e))
|
||||
|
||||
|
||||
def get_foreground_info(force_refresh: bool = False) -> ForegroundInfo:
|
||||
"""Return the current foreground window/process snapshot.
|
||||
|
||||
Args:
|
||||
force_refresh: bypass the short TTL cache. WebSocket broadcast loop
|
||||
should leave this False; the REST endpoint accepts ?refresh=1
|
||||
for callers that want a fresh probe.
|
||||
"""
|
||||
if force_refresh:
|
||||
_cache.invalidate()
|
||||
return _cache.get(_CACHE_TTL, _probe)
|
||||
|
||||
|
||||
def reset_cache() -> None:
|
||||
"""Reset the cache. Useful in tests."""
|
||||
_cache.invalidate()
|
||||
@@ -151,22 +151,19 @@ class LinuxMediaController(MediaController):
|
||||
logger.error(f"Failed to toggle mute: {e}")
|
||||
return False
|
||||
|
||||
async def get_status(self) -> MediaStatus:
|
||||
"""Get current media playback status."""
|
||||
def _sync_get_status(self) -> MediaStatus:
|
||||
"""Synchronous status read (called from a worker thread)."""
|
||||
status = MediaStatus()
|
||||
|
||||
# Get system volume
|
||||
volume, muted = self._get_volume_pulseaudio()
|
||||
status.volume = volume
|
||||
status.muted = muted
|
||||
|
||||
# Get active player
|
||||
player_name = self._get_active_player()
|
||||
if player_name is None:
|
||||
status.state = MediaState.IDLE
|
||||
return status
|
||||
|
||||
# Get playback status
|
||||
playback_status = self._get_property(player_name, "PlaybackStatus")
|
||||
if playback_status == "Playing":
|
||||
status.state = MediaState.PLAYING
|
||||
@@ -177,114 +174,70 @@ class LinuxMediaController(MediaController):
|
||||
else:
|
||||
status.state = MediaState.IDLE
|
||||
|
||||
# Get metadata
|
||||
metadata = self._get_property(player_name, "Metadata")
|
||||
if metadata:
|
||||
status.title = str(metadata.get("xesam:title", "")) or None
|
||||
|
||||
artists = metadata.get("xesam:artist", [])
|
||||
if artists:
|
||||
status.artist = str(artists[0]) if isinstance(artists, list) else str(artists)
|
||||
|
||||
status.album = str(metadata.get("xesam:album", "")) or None
|
||||
status.album_art_url = str(metadata.get("mpris:artUrl", "")) or None
|
||||
|
||||
# Duration in microseconds
|
||||
length = metadata.get("mpris:length", 0)
|
||||
if length:
|
||||
status.duration = int(length) / 1_000_000
|
||||
|
||||
# Get position (in microseconds)
|
||||
position = self._get_property(player_name, "Position")
|
||||
if position is not None:
|
||||
status.position = int(position) / 1_000_000
|
||||
|
||||
# Get source name
|
||||
status.source = player_name.replace(self.MPRIS_PREFIX, "")
|
||||
|
||||
return status
|
||||
|
||||
async def play(self) -> bool:
|
||||
"""Resume playback."""
|
||||
async def get_status(self) -> MediaStatus:
|
||||
"""Get current media playback status (off the event loop)."""
|
||||
# pactl + DBus calls each take 5-100ms on a Pi and would block every
|
||||
# other coroutine on the server. Run them in a worker thread.
|
||||
return await asyncio.to_thread(self._sync_get_status)
|
||||
|
||||
def _call_player(self, method_name: str) -> bool:
|
||||
player_name = self._get_active_player()
|
||||
if player_name is None:
|
||||
return False
|
||||
try:
|
||||
player = self._get_player_interface(player_name)
|
||||
player.Play()
|
||||
getattr(player, method_name)()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to play: {e}")
|
||||
logger.error(f"Failed to call player.{method_name}: {e}")
|
||||
return False
|
||||
|
||||
async def play(self) -> bool:
|
||||
return await asyncio.to_thread(self._call_player, "Play")
|
||||
|
||||
async def pause(self) -> bool:
|
||||
"""Pause playback."""
|
||||
player_name = self._get_active_player()
|
||||
if player_name is None:
|
||||
return False
|
||||
try:
|
||||
player = self._get_player_interface(player_name)
|
||||
player.Pause()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to pause: {e}")
|
||||
return False
|
||||
return await asyncio.to_thread(self._call_player, "Pause")
|
||||
|
||||
async def stop(self) -> bool:
|
||||
"""Stop playback."""
|
||||
player_name = self._get_active_player()
|
||||
if player_name is None:
|
||||
return False
|
||||
try:
|
||||
player = self._get_player_interface(player_name)
|
||||
player.Stop()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to stop: {e}")
|
||||
return False
|
||||
return await asyncio.to_thread(self._call_player, "Stop")
|
||||
|
||||
async def next_track(self) -> bool:
|
||||
"""Skip to next track."""
|
||||
player_name = self._get_active_player()
|
||||
if player_name is None:
|
||||
return False
|
||||
try:
|
||||
player = self._get_player_interface(player_name)
|
||||
player.Next()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to skip next: {e}")
|
||||
return False
|
||||
return await asyncio.to_thread(self._call_player, "Next")
|
||||
|
||||
async def previous_track(self) -> bool:
|
||||
"""Go to previous track."""
|
||||
player_name = self._get_active_player()
|
||||
if player_name is None:
|
||||
return False
|
||||
try:
|
||||
player = self._get_player_interface(player_name)
|
||||
player.Previous()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to skip previous: {e}")
|
||||
return False
|
||||
return await asyncio.to_thread(self._call_player, "Previous")
|
||||
|
||||
async def set_volume(self, volume: int) -> bool:
|
||||
"""Set system volume."""
|
||||
return self._set_volume_pulseaudio(volume)
|
||||
return await asyncio.to_thread(self._set_volume_pulseaudio, volume)
|
||||
|
||||
async def toggle_mute(self) -> bool:
|
||||
"""Toggle mute state."""
|
||||
return self._toggle_mute_pulseaudio()
|
||||
return await asyncio.to_thread(self._toggle_mute_pulseaudio)
|
||||
|
||||
async def seek(self, position: float) -> bool:
|
||||
"""Seek to position in seconds."""
|
||||
def _sync_seek(self, position: float) -> bool:
|
||||
player_name = self._get_active_player()
|
||||
if player_name is None:
|
||||
return False
|
||||
try:
|
||||
player = self._get_player_interface(player_name)
|
||||
# MPRIS expects position in microseconds
|
||||
player.SetPosition(
|
||||
self._get_property(player_name, "Metadata").get("mpris:trackid", "/"),
|
||||
int(position * 1_000_000),
|
||||
@@ -294,6 +247,9 @@ class LinuxMediaController(MediaController):
|
||||
logger.error(f"Failed to seek: {e}")
|
||||
return False
|
||||
|
||||
async def seek(self, position: float) -> bool:
|
||||
return await asyncio.to_thread(self._sync_seek, position)
|
||||
|
||||
async def open_file(self, file_path: str) -> bool:
|
||||
"""Open a media file with the default system player (Linux).
|
||||
|
||||
|
||||
@@ -321,7 +321,7 @@ class ThumbnailService:
|
||||
|
||||
if suffix in AUDIO_EXTENSIONS:
|
||||
# Audio files - run in executor (sync operation)
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
thumbnail_data = await loop.run_in_executor(
|
||||
None,
|
||||
ThumbnailService.generate_audio_thumbnail,
|
||||
|
||||
@@ -19,6 +19,9 @@ class ConnectionManager:
|
||||
self._active_connections: set[WebSocket] = set()
|
||||
self._lock = asyncio.Lock()
|
||||
self._last_status: dict[str, Any] | None = None
|
||||
self._last_foreground: dict[str, Any] | None = None
|
||||
self._foreground_poll_interval: float = 1.0
|
||||
self._last_foreground_poll: float = 0.0
|
||||
self._get_status_func: Callable[[], Coroutine[Any, Any, Any]] | None = None
|
||||
self._broadcast_task: asyncio.Task | None = None
|
||||
self._poll_interval: float = 0.5 # Internal poll interval for change detection
|
||||
@@ -54,6 +57,18 @@ class ConnectionManager:
|
||||
except Exception as e:
|
||||
logger.debug("Failed to send initial status: %s", e)
|
||||
|
||||
# Push a fresh foreground snapshot on connect so the UI can render
|
||||
# the tile immediately instead of waiting for the next change.
|
||||
try:
|
||||
from .foreground_service import get_foreground_info
|
||||
|
||||
fg = await asyncio.to_thread(get_foreground_info)
|
||||
fg_dict = fg.to_dict()
|
||||
self._last_foreground = fg_dict
|
||||
await websocket.send_json({"type": "foreground", "data": fg_dict})
|
||||
except Exception as e:
|
||||
logger.debug("Failed to send initial foreground snapshot: %s", e)
|
||||
|
||||
async def disconnect(self, websocket: WebSocket) -> None:
|
||||
"""Remove a WebSocket connection. Stops audio capture if last visualizer subscriber."""
|
||||
should_stop = False
|
||||
@@ -70,16 +85,27 @@ class ConnectionManager:
|
||||
)
|
||||
|
||||
async def broadcast(self, message: dict[str, Any]) -> None:
|
||||
"""Broadcast a message to all connected clients concurrently."""
|
||||
"""Broadcast a message to all connected clients concurrently.
|
||||
|
||||
The payload is serialized once and pushed via ``send_text`` to every
|
||||
client, instead of having Starlette/Pydantic encode it N times via
|
||||
``send_json``.
|
||||
"""
|
||||
async with self._lock:
|
||||
connections = list(self._active_connections)
|
||||
|
||||
if not connections:
|
||||
return
|
||||
|
||||
try:
|
||||
payload = json.dumps(message, default=str)
|
||||
except (TypeError, ValueError) as e:
|
||||
logger.error("Failed to encode broadcast message: %s", e)
|
||||
return
|
||||
|
||||
async def _send(ws: WebSocket) -> WebSocket | None:
|
||||
try:
|
||||
await ws.send_json(message)
|
||||
await ws.send_text(payload)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug("Failed to send to client: %s", e)
|
||||
@@ -104,6 +130,35 @@ class ConnectionManager:
|
||||
await self.broadcast(message)
|
||||
logger.info("Broadcast sent: links_changed")
|
||||
|
||||
def foreground_changed(
|
||||
self, old: dict[str, Any] | None, new: dict[str, Any]
|
||||
) -> bool:
|
||||
"""Detect a meaningful change in the foreground process snapshot.
|
||||
|
||||
The probe also returns ``window_geometry`` which jitters on every
|
||||
pixel of cursor drag — comparing the whole dict would flood clients.
|
||||
We only diff the fields a user (or HA automation) would actually act
|
||||
on. ``window_geometry``/``monitor_geometry``/``started_at`` are still
|
||||
delivered in the payload, but they don't drive broadcast cadence.
|
||||
"""
|
||||
if old is None:
|
||||
return True
|
||||
diff_fields = (
|
||||
"pid",
|
||||
"process_name",
|
||||
"executable_path",
|
||||
"window_title",
|
||||
"is_fullscreen",
|
||||
"is_minimized",
|
||||
"monitor_id",
|
||||
"available",
|
||||
"error",
|
||||
)
|
||||
for f in diff_fields:
|
||||
if old.get(f) != new.get(f):
|
||||
return True
|
||||
return False
|
||||
|
||||
async def subscribe_visualizer(self, websocket: WebSocket) -> None:
|
||||
"""Subscribe a client to audio visualizer data. Starts capture on first subscriber."""
|
||||
should_start = False
|
||||
@@ -129,7 +184,7 @@ class ConnectionManager:
|
||||
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()
|
||||
loop = asyncio.get_running_loop()
|
||||
started = await loop.run_in_executor(None, self._audio_analyzer.start)
|
||||
if started:
|
||||
logger.info("Audio capture started (first subscriber)")
|
||||
@@ -139,7 +194,7 @@ class ConnectionManager:
|
||||
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()
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(None, self._audio_analyzer.stop)
|
||||
logger.info("Audio capture stopped (no subscribers)")
|
||||
|
||||
@@ -171,7 +226,7 @@ class ConnectionManager:
|
||||
idle_interval = 1.0 / max(1, settings.visualizer_fps)
|
||||
# Bounded wait so we still notice subscribe/unsubscribe transitions.
|
||||
wake_timeout = max(0.05, idle_interval)
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
last_seq = -1
|
||||
|
||||
@@ -303,6 +358,10 @@ class ConnectionManager:
|
||||
get_status_func: Callable[[], Coroutine[Any, Any, Any]],
|
||||
) -> None:
|
||||
"""Background loop that polls for status changes and broadcasts."""
|
||||
# Foreground tracker is imported lazily so unit tests of the WS
|
||||
# manager don't drag in platform-specific probe code.
|
||||
from .foreground_service import get_foreground_info
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
# Only poll if we have connected clients
|
||||
@@ -329,6 +388,28 @@ class ConnectionManager:
|
||||
# Update cached status even without broadcast
|
||||
self._last_status = status_dict
|
||||
|
||||
# Foreground process — poll at a coarser interval than media
|
||||
# status. Broadcasts only fire on a real change, so a quiet
|
||||
# desktop costs nothing.
|
||||
now = time.time()
|
||||
if (
|
||||
now - self._last_foreground_poll
|
||||
) >= self._foreground_poll_interval:
|
||||
self._last_foreground_poll = now
|
||||
try:
|
||||
fg = await asyncio.to_thread(get_foreground_info)
|
||||
fg_dict = fg.to_dict()
|
||||
if self.foreground_changed(self._last_foreground, fg_dict):
|
||||
self._last_foreground = fg_dict
|
||||
await self.broadcast(
|
||||
{"type": "foreground_update", "data": fg_dict}
|
||||
)
|
||||
logger.debug("Broadcast sent: foreground change")
|
||||
else:
|
||||
self._last_foreground = fg_dict
|
||||
except Exception as e:
|
||||
logger.debug("Foreground poll failed: %s", e)
|
||||
|
||||
await asyncio.sleep(self._poll_interval)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
|
||||
@@ -15,6 +15,22 @@ logger = logging.getLogger(__name__)
|
||||
# Thread pool for WinRT operations (they don't play well with asyncio)
|
||||
_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="winrt")
|
||||
|
||||
# Cache an asyncio event loop per worker thread so the 500ms status poll
|
||||
# doesn't allocate + tear down a new loop on every tick. Creating a loop
|
||||
# every 0.5s churns CPU and leaks finalized loop references that linger in
|
||||
# WinRT callbacks. With this helper a thread reuses one loop forever and
|
||||
# we only pay the setup cost once per worker.
|
||||
_thread_local = threading.local()
|
||||
|
||||
|
||||
def _thread_loop() -> asyncio.AbstractEventLoop:
|
||||
loop = getattr(_thread_local, "loop", None)
|
||||
if loop is None or loop.is_closed():
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
_thread_local.loop = loop
|
||||
return loop
|
||||
|
||||
# Global storage for current album art (as bytes)
|
||||
_current_album_art_bytes: bytes | None = None
|
||||
|
||||
@@ -161,8 +177,6 @@ WINDOWS_AVAILABLE = WINSDK_AVAILABLE
|
||||
|
||||
def _sync_get_media_status() -> dict[str, Any]:
|
||||
"""Synchronously get media status (runs in thread pool)."""
|
||||
import asyncio
|
||||
|
||||
result = {
|
||||
"state": "idle",
|
||||
"title": None,
|
||||
@@ -174,9 +188,7 @@ def _sync_get_media_status() -> dict[str, Any]:
|
||||
}
|
||||
|
||||
try:
|
||||
# Create a new event loop for this thread
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
loop = _thread_loop()
|
||||
|
||||
try:
|
||||
# Get media session manager
|
||||
@@ -393,7 +405,8 @@ def _sync_get_media_status() -> dict[str, Any]:
|
||||
result["source"] = session.source_app_user_model_id
|
||||
|
||||
finally:
|
||||
loop.close()
|
||||
# Reuse the loop across calls — see _thread_loop above.
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting media status: {e}")
|
||||
@@ -439,35 +452,28 @@ def _find_best_session(manager, loop):
|
||||
|
||||
def _sync_media_command(command: str) -> bool:
|
||||
"""Synchronously execute a media command (runs in thread pool)."""
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
try:
|
||||
manager = loop.run_until_complete(MediaManager.request_async())
|
||||
if manager is None:
|
||||
return False
|
||||
|
||||
session = _find_best_session(manager, loop)
|
||||
if session is None:
|
||||
return False
|
||||
|
||||
if command == "play":
|
||||
return loop.run_until_complete(session.try_play_async())
|
||||
elif command == "pause":
|
||||
return loop.run_until_complete(session.try_pause_async())
|
||||
elif command == "stop":
|
||||
return loop.run_until_complete(session.try_stop_async())
|
||||
elif command == "next":
|
||||
return loop.run_until_complete(session.try_skip_next_async())
|
||||
elif command == "previous":
|
||||
return loop.run_until_complete(session.try_skip_previous_async())
|
||||
|
||||
loop = _thread_loop()
|
||||
manager = loop.run_until_complete(MediaManager.request_async())
|
||||
if manager is None:
|
||||
return False
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
session = _find_best_session(manager, loop)
|
||||
if session is None:
|
||||
return False
|
||||
|
||||
if command == "play":
|
||||
return loop.run_until_complete(session.try_play_async())
|
||||
elif command == "pause":
|
||||
return loop.run_until_complete(session.try_pause_async())
|
||||
elif command == "stop":
|
||||
return loop.run_until_complete(session.try_stop_async())
|
||||
elif command == "next":
|
||||
return loop.run_until_complete(session.try_skip_next_async())
|
||||
elif command == "previous":
|
||||
return loop.run_until_complete(session.try_skip_previous_async())
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error executing media command {command}: {e}")
|
||||
@@ -476,27 +482,20 @@ def _sync_media_command(command: str) -> bool:
|
||||
|
||||
def _sync_seek(position: float) -> bool:
|
||||
"""Synchronously seek to position."""
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
loop = _thread_loop()
|
||||
manager = loop.run_until_complete(MediaManager.request_async())
|
||||
if manager is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
manager = loop.run_until_complete(MediaManager.request_async())
|
||||
if manager is None:
|
||||
return False
|
||||
session = _find_best_session(manager, loop)
|
||||
if session is None:
|
||||
return False
|
||||
|
||||
session = _find_best_session(manager, loop)
|
||||
if session is None:
|
||||
return False
|
||||
|
||||
position_ticks = int(position * 10_000_000)
|
||||
return loop.run_until_complete(
|
||||
session.try_change_playback_position_async(position_ticks)
|
||||
)
|
||||
finally:
|
||||
loop.close()
|
||||
position_ticks = int(position * 10_000_000)
|
||||
return loop.run_until_complete(
|
||||
session.try_change_playback_position_async(position_ticks)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error seeking: {e}")
|
||||
@@ -559,7 +558,7 @@ class WindowsMediaController(MediaController):
|
||||
|
||||
# Get media info in thread pool (avoids asyncio/WinRT issues)
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
media_info = await asyncio.wait_for(
|
||||
loop.run_in_executor(_executor, _sync_get_media_status),
|
||||
timeout=5.0
|
||||
@@ -592,7 +591,7 @@ class WindowsMediaController(MediaController):
|
||||
async def _run_command(self, command: str) -> bool:
|
||||
"""Run a media command in the thread pool."""
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
return await asyncio.wait_for(
|
||||
loop.run_in_executor(_executor, _sync_media_command, command),
|
||||
timeout=5.0
|
||||
@@ -616,16 +615,15 @@ class WindowsMediaController(MediaController):
|
||||
"""Stop playback."""
|
||||
return await self._run_command("stop")
|
||||
|
||||
async def next_track(self) -> bool:
|
||||
"""Skip to next track."""
|
||||
# Get current title before skipping
|
||||
try:
|
||||
status = await self.get_status()
|
||||
old_title = status.title or ""
|
||||
except Exception:
|
||||
old_title = ""
|
||||
async def _skip_track(self, command: str) -> bool:
|
||||
# Read the current title from the position cache instead of doing a
|
||||
# full WinRT round-trip (which can take up to 5s) just for one field.
|
||||
with _position_lock:
|
||||
track_id = _position_cache.get("track_id") or ""
|
||||
# track_id is "title:artist:duration" — extract just the title.
|
||||
old_title = track_id.split(":", 1)[0] if track_id else ""
|
||||
|
||||
result = await self._run_command("next")
|
||||
result = await self._run_command(command)
|
||||
if result:
|
||||
with _position_lock:
|
||||
_track_skip_pending["active"] = True
|
||||
@@ -634,23 +632,13 @@ class WindowsMediaController(MediaController):
|
||||
logger.debug(f"Track skip initiated, old title: {old_title}")
|
||||
return result
|
||||
|
||||
async def next_track(self) -> bool:
|
||||
"""Skip to next track."""
|
||||
return await self._skip_track("next")
|
||||
|
||||
async def previous_track(self) -> bool:
|
||||
"""Go to previous track."""
|
||||
# Get current title before skipping
|
||||
try:
|
||||
status = await self.get_status()
|
||||
old_title = status.title or ""
|
||||
except Exception:
|
||||
old_title = ""
|
||||
|
||||
result = await self._run_command("previous")
|
||||
if result:
|
||||
with _position_lock:
|
||||
_track_skip_pending["active"] = True
|
||||
_track_skip_pending["old_title"] = old_title
|
||||
_track_skip_pending["skip_time"] = _time.time()
|
||||
logger.debug(f"Track skip initiated, old title: {old_title}")
|
||||
return result
|
||||
return await self._skip_track("previous")
|
||||
|
||||
async def set_volume(self, volume: int) -> bool:
|
||||
"""Set system volume."""
|
||||
@@ -680,7 +668,7 @@ class WindowsMediaController(MediaController):
|
||||
async def seek(self, position: float) -> bool:
|
||||
"""Seek to position in seconds."""
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
return await asyncio.wait_for(
|
||||
loop.run_in_executor(_executor, _sync_seek, position),
|
||||
timeout=5.0
|
||||
@@ -705,7 +693,7 @@ class WindowsMediaController(MediaController):
|
||||
"""
|
||||
try:
|
||||
import os
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(None, lambda: os.startfile(file_path))
|
||||
logger.info(f"Opened file with default player: {file_path}")
|
||||
return True
|
||||
|
||||
@@ -329,7 +329,7 @@ body.translations-loaded {
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
box-shadow: 0 0 0 4px rgba(29, 185, 84, 0.2);
|
||||
box-shadow: 0 0 0 4px rgba(var(--copper-rgb), 0.2);
|
||||
}
|
||||
|
||||
input:focus-visible,
|
||||
@@ -337,7 +337,7 @@ select:focus-visible,
|
||||
textarea:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(29, 185, 84, 0.15);
|
||||
box-shadow: 0 0 0 3px rgba(var(--copper-rgb), 0.15);
|
||||
}
|
||||
|
||||
.tab-btn:focus-visible {
|
||||
@@ -1004,7 +1004,7 @@ button:disabled {
|
||||
.controls button:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 3px;
|
||||
box-shadow: 0 0 0 4px rgba(29, 185, 84, 0.25);
|
||||
box-shadow: 0 0 0 4px rgba(var(--copper-rgb), 0.25);
|
||||
}
|
||||
|
||||
.mute-btn:focus-visible,
|
||||
@@ -1012,7 +1012,7 @@ button:disabled {
|
||||
.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);
|
||||
box-shadow: 0 0 0 4px rgba(var(--copper-rgb), 0.25);
|
||||
}
|
||||
|
||||
.controls button.primary {
|
||||
@@ -1060,7 +1060,7 @@ button:disabled {
|
||||
|
||||
#volume-slider:hover::-webkit-slider-thumb {
|
||||
transform: scale(1.3);
|
||||
box-shadow: 0 0 6px rgba(29, 185, 84, 0.4);
|
||||
box-shadow: 0 0 6px rgba(var(--copper-rgb), 0.4);
|
||||
}
|
||||
|
||||
#volume-slider::-moz-range-thumb {
|
||||
@@ -1075,7 +1075,7 @@ button:disabled {
|
||||
|
||||
#volume-slider:hover::-moz-range-thumb {
|
||||
transform: scale(1.3);
|
||||
box-shadow: 0 0 6px rgba(29, 185, 84, 0.4);
|
||||
box-shadow: 0 0 6px rgba(var(--copper-rgb), 0.4);
|
||||
}
|
||||
|
||||
.volume-display {
|
||||
@@ -1169,7 +1169,7 @@ button:disabled {
|
||||
.vinyl-toggle-btn.active {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
background: rgba(29, 185, 84, 0.1);
|
||||
background: rgba(var(--copper-rgb), 0.1);
|
||||
}
|
||||
|
||||
.vinyl-toggle-btn svg {
|
||||
@@ -1393,7 +1393,7 @@ button:disabled {
|
||||
border-radius: 4px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
font-family: var(--sans, inherit);
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s ease, box-shadow 0.25s ease;
|
||||
@@ -1402,7 +1402,7 @@ button:disabled {
|
||||
.audio-device-selector select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(29, 185, 84, 0.15);
|
||||
box-shadow: 0 0 0 3px rgba(var(--copper-rgb), 0.15);
|
||||
}
|
||||
|
||||
.audio-device-status {
|
||||
@@ -1836,13 +1836,18 @@ button:disabled {
|
||||
dialog {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
/* Editorial chrome to match the rest of the Studio Reference layout:
|
||||
no rounded corners, hairline border, and a copper top accent that
|
||||
lets the dialog read as a continuation of the magazine rather than
|
||||
a generic Material modal. */
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
border-top: 1px solid var(--copper);
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
max-width: 500px;
|
||||
max-width: 520px;
|
||||
width: 90%;
|
||||
margin: auto;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.8);
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.65);
|
||||
animation: dialogIn 0.25s ease-out;
|
||||
overflow: visible;
|
||||
}
|
||||
@@ -2827,7 +2832,7 @@ button.primary svg {
|
||||
|
||||
.breadcrumb-item:hover {
|
||||
color: var(--accent);
|
||||
background: rgba(29, 185, 84, 0.08);
|
||||
background: rgba(var(--copper-rgb), 0.08);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -3072,19 +3077,25 @@ button.primary svg {
|
||||
|
||||
/* Browser List View */
|
||||
.browser-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr auto auto auto auto;
|
||||
column-gap: 1.25rem;
|
||||
row-gap: 1px;
|
||||
margin-bottom: 1.5rem;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.browser-list > .browser-empty,
|
||||
.browser-list > .browser-loading {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
/* List view column header */
|
||||
.browser-list-header {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr auto auto auto auto;
|
||||
grid-template-columns: subgrid;
|
||||
grid-column: 1 / -1;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 0.688rem;
|
||||
font-weight: 600;
|
||||
@@ -3102,9 +3113,9 @@ button.primary svg {
|
||||
|
||||
.browser-list-item {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr auto auto auto auto;
|
||||
grid-template-columns: subgrid;
|
||||
grid-column: 1 / -1;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
@@ -3235,12 +3246,14 @@ button.primary svg {
|
||||
}
|
||||
|
||||
.browser-item {
|
||||
background: var(--bg-tertiary);
|
||||
/* Match the editorial card language used elsewhere on the page —
|
||||
transparent background, hairline border, copper-on-hover. */
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 10px;
|
||||
border-radius: 0;
|
||||
padding: 0.6rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
transition: border-color 0.2s ease, background 0.2s ease, transform 0.2s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -3250,6 +3263,11 @@ button.primary svg {
|
||||
animation-delay: calc(var(--item-index, 0) * 25ms);
|
||||
}
|
||||
|
||||
.browser-item:hover {
|
||||
border-color: rgba(var(--copper-rgb), 0.45);
|
||||
background: rgba(var(--copper-rgb), 0.04);
|
||||
}
|
||||
|
||||
@keyframes itemFadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
@@ -3286,14 +3304,21 @@ button.primary svg {
|
||||
height: 56px;
|
||||
font-size: 1.75rem;
|
||||
border-radius: 14px;
|
||||
background: rgba(29, 185, 84, 0.1);
|
||||
border: 1px solid rgba(29, 185, 84, 0.15);
|
||||
background: rgba(var(--copper-rgb), 0.1);
|
||||
border: 1px solid rgba(var(--copper-rgb), 0.15);
|
||||
transition: all 0.25s;
|
||||
}
|
||||
|
||||
/* Root folder SVG ships with hardcoded width=24 height=24 (browser.js folderSvg),
|
||||
which renders at ~43% of the 56px icon box. Override to fill it properly. */
|
||||
.browser-item.browser-root-folder .browser-icon > svg {
|
||||
width: 60%;
|
||||
height: 60%;
|
||||
}
|
||||
|
||||
.browser-item.browser-root-folder:hover .browser-icon {
|
||||
background: rgba(29, 185, 84, 0.18);
|
||||
border-color: rgba(29, 185, 84, 0.3);
|
||||
background: rgba(var(--copper-rgb), 0.18);
|
||||
border-color: rgba(var(--copper-rgb), 0.3);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
@@ -3638,9 +3663,12 @@ button.primary svg {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.browser-list-header {
|
||||
.browser-list {
|
||||
grid-template-columns: 32px 1fr auto auto;
|
||||
gap: 0.5rem;
|
||||
column-gap: 0.875rem;
|
||||
}
|
||||
|
||||
.browser-list-header {
|
||||
padding: 0.35rem 0.5rem;
|
||||
}
|
||||
|
||||
@@ -3649,8 +3677,6 @@ button.primary svg {
|
||||
}
|
||||
|
||||
.browser-list-item {
|
||||
grid-template-columns: 32px 1fr auto auto;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
}
|
||||
|
||||
@@ -3720,17 +3746,13 @@ button.primary svg {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.browser-list-header {
|
||||
.browser-list {
|
||||
grid-template-columns: 40px 1fr auto auto auto;
|
||||
}
|
||||
|
||||
.browser-list-header span:nth-child(3) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.browser-list-item {
|
||||
grid-template-columns: 40px 1fr auto auto auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Update Banner */
|
||||
@@ -3963,7 +3985,13 @@ html {
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.container { padding: 48px 18px 24px; }
|
||||
.container { padding: 32px 18px 20px; }
|
||||
}
|
||||
|
||||
/* Phones: trim the editorial spread further so the first viewport isn't
|
||||
90% chrome. The 56px top pad eats a third of a 360x640 screen. */
|
||||
@media (max-width: 480px) {
|
||||
.container { padding: 16px 12px 16px; }
|
||||
}
|
||||
|
||||
/* ─── Folio marks (page corners, all tabs) ────────────────── */
|
||||
@@ -7387,9 +7415,15 @@ select option {
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
/* Let the compact icon fill its thumb-wrapper (matches grid view behaviour).
|
||||
The previous 28x28 size left the icon stranded at the wrapper's top-left
|
||||
because .browser-thumb-wrapper is not a flex container. The wrapper square
|
||||
is sized by the grid; the emoji inside .browser-icon centers via the base
|
||||
flex rules. */
|
||||
.browser-container .browser-grid-compact .browser-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: 2.25rem;
|
||||
}
|
||||
.browser-container .browser-item {
|
||||
background: transparent !important;
|
||||
@@ -9287,3 +9321,321 @@ body.is-fullscreen-player .now-playing .vu-meter {
|
||||
body.is-fullscreen-player .fs-bloom #fs-bloom-art { animation: none !important; }
|
||||
:root[data-theme="light"] body.is-fullscreen-player .fs-bloom { opacity: 0.22; }
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
FOREGROUND container — editorial process plate
|
||||
════════════════════════════════════════════════════════════════ */
|
||||
.foreground-container {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
box-shadow: none;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.foreground-stage {
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
/* Match the inter-section gap used between .settings-section blocks
|
||||
in the Settings tab — keeps cadence consistent across tabs. */
|
||||
.display-container > * + * {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.foreground-card {
|
||||
position: relative;
|
||||
display: block;
|
||||
padding: clamp(24px, 3vw, 40px) clamp(24px, 3vw, 40px) 28px;
|
||||
border: 1px solid var(--rule);
|
||||
border-top: 2px solid var(--copper);
|
||||
background:
|
||||
radial-gradient(120% 80% at 0% 0%, rgba(var(--copper-rgb), 0.05), transparent 60%),
|
||||
var(--bg-paper);
|
||||
box-shadow:
|
||||
0 1px 0 var(--bg-paper),
|
||||
0 28px 60px -28px rgba(0, 0, 0, 0.45),
|
||||
0 8px 20px -10px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.foreground-card[data-fullscreen="1"] {
|
||||
border-top-color: var(--copper-hi);
|
||||
box-shadow:
|
||||
0 1px 0 var(--bg-paper),
|
||||
0 28px 60px -28px rgba(0, 0, 0, 0.55),
|
||||
0 0 0 1px rgba(var(--copper-rgb), 0.18),
|
||||
0 0 60px -12px var(--copper-glow);
|
||||
}
|
||||
|
||||
.foreground-card .fg-kicker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.32em;
|
||||
text-transform: uppercase;
|
||||
color: var(--copper);
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
.foreground-card .fg-kicker::before,
|
||||
.foreground-card .fg-kicker::after {
|
||||
content: "";
|
||||
height: 1px;
|
||||
background: var(--copper);
|
||||
opacity: 0.6;
|
||||
flex: 0 0 24px;
|
||||
}
|
||||
.foreground-card .fg-kicker::after { flex: 1 0 auto; }
|
||||
|
||||
.foreground-card .fg-process {
|
||||
font-family: var(--serif);
|
||||
font-weight: 400;
|
||||
font-size: clamp(34px, 4.4vw, 56px);
|
||||
line-height: 1.02;
|
||||
letter-spacing: -0.02em;
|
||||
font-variation-settings: 'opsz' 144;
|
||||
color: var(--ink);
|
||||
margin: 0 0 10px;
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
transition: color 180ms var(--ease, ease);
|
||||
}
|
||||
.foreground-card .fg-process:hover {
|
||||
color: var(--copper-hi);
|
||||
}
|
||||
|
||||
.foreground-card .fg-window-title {
|
||||
font-family: var(--serif);
|
||||
font-style: italic;
|
||||
font-size: 20px;
|
||||
font-weight: 300;
|
||||
color: var(--ink-soft);
|
||||
font-variation-settings: 'opsz' 60;
|
||||
margin-bottom: 22px;
|
||||
line-height: 1.35;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
word-break: break-word;
|
||||
}
|
||||
.foreground-card .fg-window-title:empty { display: none; }
|
||||
|
||||
.foreground-card .fg-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.foreground-card .fg-chips:empty { display: none; }
|
||||
|
||||
.fg-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 5px 11px;
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-soft);
|
||||
background: transparent;
|
||||
border: 1px solid var(--rule-strong);
|
||||
border-radius: 999px;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.fg-chip.fg-chip-accent {
|
||||
color: var(--copper);
|
||||
border-color: var(--copper);
|
||||
background: rgba(var(--copper-rgb), 0.07);
|
||||
}
|
||||
.fg-chip.fg-chip-mute {
|
||||
color: var(--ink-mute);
|
||||
border-color: var(--rule);
|
||||
}
|
||||
|
||||
.foreground-card .fg-details {
|
||||
display: block;
|
||||
margin: 0;
|
||||
border-top: 1px solid var(--rule);
|
||||
}
|
||||
.foreground-card .fg-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(160px, 220px) 1fr;
|
||||
gap: 24px;
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
align-items: baseline;
|
||||
min-width: 0;
|
||||
}
|
||||
.foreground-card .fg-row dt {
|
||||
font-family: var(--mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: var(--copper);
|
||||
margin: 0;
|
||||
}
|
||||
.foreground-card .fg-row dd {
|
||||
font-family: var(--serif);
|
||||
font-style: italic;
|
||||
font-size: 18px;
|
||||
color: var(--ink);
|
||||
font-variation-settings: 'opsz' 30;
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.foreground-card .fg-mono {
|
||||
font-family: var(--mono);
|
||||
font-style: normal;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--ink-soft);
|
||||
font-variant-numeric: tabular-nums;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.foreground-empty {
|
||||
padding: 60px 24px;
|
||||
text-align: center;
|
||||
color: var(--ink-mute);
|
||||
}
|
||||
.foreground-empty svg {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin-bottom: 14px;
|
||||
opacity: 0.55;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.foreground-empty p {
|
||||
font-family: var(--serif);
|
||||
font-style: italic;
|
||||
font-size: 18px;
|
||||
color: var(--ink-soft);
|
||||
margin: 0;
|
||||
}
|
||||
.foreground-empty .foreground-empty-error {
|
||||
margin-top: 10px;
|
||||
font-family: var(--mono);
|
||||
font-style: normal;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--ink-mute);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* ─── Header status badge ──────────────────────────────────── */
|
||||
.foreground-status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 32px;
|
||||
padding: 0 12px 0 10px;
|
||||
margin-right: 4px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--rule-strong);
|
||||
border-radius: 999px;
|
||||
color: var(--ink-soft);
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
cursor: pointer;
|
||||
max-width: 240px;
|
||||
transition: color 180ms ease, border-color 180ms ease, background 180ms ease;
|
||||
}
|
||||
.foreground-status-badge:hover {
|
||||
color: var(--ink);
|
||||
border-color: var(--copper);
|
||||
background: rgba(var(--copper-rgb), 0.06);
|
||||
}
|
||||
.foreground-status-badge.hidden { display: none !important; }
|
||||
|
||||
.foreground-status-badge .fg-badge-mark {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--ink-mute);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.foreground-status-badge.is-media .fg-badge-mark,
|
||||
.foreground-status-badge.is-fullscreen .fg-badge-mark {
|
||||
background: var(--copper);
|
||||
box-shadow: 0 0 8px var(--copper-glow);
|
||||
}
|
||||
.foreground-status-badge.is-fullscreen {
|
||||
border-color: var(--copper);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.foreground-status-badge .fg-badge-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 140px;
|
||||
}
|
||||
.foreground-status-badge .fg-badge-tag {
|
||||
color: var(--copper);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
font-size: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.foreground-status-badge .fg-badge-tag.hidden { display: none; }
|
||||
|
||||
/* ─── Light theme overrides ──────────────────────────────── */
|
||||
:root[data-theme="light"] .foreground-card {
|
||||
background:
|
||||
radial-gradient(120% 80% at 0% 0%, rgba(var(--copper-rgb), 0.05), transparent 60%),
|
||||
var(--bg-paper);
|
||||
box-shadow:
|
||||
0 1px 0 var(--bg-paper),
|
||||
0 22px 50px -24px rgba(26, 23, 21, 0.20),
|
||||
0 6px 16px -8px rgba(26, 23, 21, 0.12);
|
||||
}
|
||||
:root[data-theme="light"] .foreground-card[data-fullscreen="1"] {
|
||||
box-shadow:
|
||||
0 1px 0 var(--bg-paper),
|
||||
0 22px 50px -24px rgba(26, 23, 21, 0.28),
|
||||
0 0 0 1px rgba(var(--copper-rgb), 0.20),
|
||||
0 0 50px -12px var(--copper-glow);
|
||||
}
|
||||
:root[data-theme="light"] .foreground-status-badge {
|
||||
border-color: rgba(26, 23, 21, 0.18);
|
||||
}
|
||||
:root[data-theme="light"] .foreground-status-badge:hover {
|
||||
background: rgba(var(--copper-rgb), 0.08);
|
||||
}
|
||||
|
||||
/* ─── Mobile breakpoint ──────────────────────────────────── */
|
||||
@media (max-width: 720px) {
|
||||
.foreground-card {
|
||||
padding: 22px 18px 20px;
|
||||
}
|
||||
.foreground-card .fg-process {
|
||||
font-size: 30px;
|
||||
}
|
||||
.foreground-card .fg-window-title {
|
||||
font-size: 16px;
|
||||
}
|
||||
.foreground-card .fg-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 4px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
.foreground-card .fg-row dd {
|
||||
font-size: 16px;
|
||||
}
|
||||
.foreground-status-badge {
|
||||
max-width: 160px;
|
||||
}
|
||||
.foreground-status-badge .fg-badge-name {
|
||||
max-width: 80px;
|
||||
}
|
||||
.foreground-status-badge .fg-badge-tag {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,15 +26,15 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="mini-controls">
|
||||
<button class="mini-control-btn mini-nav-btn" onclick="previousTrack()" data-i18n-title="player.previous" title="Previous">
|
||||
<button class="mini-control-btn mini-nav-btn" data-onclick="previousTrack()" data-i18n-title="player.previous" title="Previous">
|
||||
<svg viewBox="0 0 24 24"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
|
||||
</button>
|
||||
<button class="mini-control-btn" onclick="togglePlayPause()" id="mini-btn-play-pause" title="Play/Pause">
|
||||
<button class="mini-control-btn" data-onclick="togglePlayPause()" id="mini-btn-play-pause" title="Play/Pause">
|
||||
<svg viewBox="0 0 24 24" id="mini-play-pause-icon">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="mini-control-btn mini-nav-btn" onclick="nextTrack()" data-i18n-title="player.next" title="Next">
|
||||
<button class="mini-control-btn mini-nav-btn" data-onclick="nextTrack()" data-i18n-title="player.next" title="Next">
|
||||
<svg viewBox="0 0 24 24"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -48,7 +48,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="mini-volume-container">
|
||||
<button class="mini-control-btn" onclick="toggleMute()" id="mini-btn-mute" title="Mute">
|
||||
<button class="mini-control-btn" data-onclick="toggleMute()" id="mini-btn-mute" title="Mute">
|
||||
<svg viewBox="0 0 24 24" id="mini-mute-icon">
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
|
||||
</svg>
|
||||
@@ -67,7 +67,7 @@
|
||||
<h2 data-i18n="app.title">Media Server</h2>
|
||||
<p data-i18n="auth.message">Enter your API token to connect to the media server.</p>
|
||||
<input type="text" id="token-input" data-i18n-placeholder="auth.placeholder" placeholder="Enter API Token" autocomplete="off">
|
||||
<button class="btn-connect" onclick="authenticate()" data-i18n="auth.connect">Connect</button>
|
||||
<button class="btn-connect" data-onclick="authenticate()" data-i18n="auth.connect">Connect</button>
|
||||
<div class="help-text">
|
||||
<p data-i18n="auth.help">To get your token, run:</p>
|
||||
<code>media-server --show-token</code>
|
||||
@@ -91,23 +91,23 @@
|
||||
<a class="header-btn" href="/docs" target="_blank" title="API Documentation" aria-label="API Documentation">
|
||||
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm-1 2l5 5h-5V4zM6 20V4h5v7h7v9H6zm2-4h8v2H8v-2zm0-3h8v2H8v-2z"/></svg>
|
||||
</a>
|
||||
<button class="header-btn" onclick="showAboutDialog()" data-i18n-title="about.button_title" title="About" aria-label="About">
|
||||
<button class="header-btn" data-onclick="showAboutDialog()" data-i18n-title="about.button_title" title="About" aria-label="About">
|
||||
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M11 7h2v2h-2V7zm0 4h2v6h-2v-6zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/></svg>
|
||||
</button>
|
||||
<div class="accent-picker">
|
||||
<button class="header-btn" onclick="toggleAccentPicker()" title="Accent color" aria-label="Accent color">
|
||||
<button class="header-btn" data-onclick="toggleAccentPicker()" title="Accent color" aria-label="Accent color">
|
||||
<span class="accent-dot" id="accentDot"></span>
|
||||
</button>
|
||||
<div class="accent-picker-dropdown" id="accentDropdown"></div>
|
||||
</div>
|
||||
<button class="header-btn" onclick="toggleDynamicBackground()" data-i18n-title="player.background" title="Dynamic background" aria-label="Dynamic background" id="bgToggle">
|
||||
<button class="header-btn" data-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="togglePlayerFullscreen()" data-i18n-title="player.fullscreen" title="Fullscreen player" aria-label="Fullscreen player" id="fullscreenToggle">
|
||||
<button class="header-btn" data-onclick="togglePlayerFullscreen()" data-i18n-title="player.fullscreen" title="Fullscreen player" aria-label="Fullscreen player" id="fullscreenToggle">
|
||||
<svg id="fullscreen-icon-enter" viewBox="0 0 24 24"><path fill="currentColor" d="M5 5h5v2H7v3H5V5zm9 0h5v5h-2V7h-3V5zm0 14v-2h3v-3h2v5h-5zM5 14h2v3h3v2H5v-5z"/></svg>
|
||||
<svg id="fullscreen-icon-exit" viewBox="0 0 24 24" style="display:none"><path fill="currentColor" d="M8 8H5v2H3V6a1 1 0 0 1 1-1h4v3zm8 0h3v2h2V6a1 1 0 0 0-1-1h-4v3zm0 8h3v-2h2v4a1 1 0 0 1-1 1h-4v-3zM8 16H5v-2H3v4a1 1 0 0 0 1 1h4v-3z"/></svg>
|
||||
</button>
|
||||
<button class="header-btn" onclick="toggleTheme()" data-i18n-title="player.theme" title="Toggle theme" aria-label="Toggle theme" id="theme-toggle">
|
||||
<button class="header-btn" data-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;">
|
||||
<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>
|
||||
@@ -115,12 +115,12 @@
|
||||
<path d="M9 2c-1.05 0-2.05.16-3 .46 4.06 1.27 7 5.06 7 9.54 0 4.48-2.94 8.27-7 9.54.95.3 1.95.46 3 .46 5.52 0 10-4.48 10-10S14.52 2 9 2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<select id="locale-select" class="header-locale" onchange="changeLocale()" title="Change language">
|
||||
<select id="locale-select" class="header-locale" data-onchange="changeLocale()" title="Change language">
|
||||
<option value="en">EN</option>
|
||||
<option value="ru">RU</option>
|
||||
</select>
|
||||
<span class="header-toolbar-sep"></span>
|
||||
<button class="header-btn header-btn-logout" onclick="clearToken()" data-i18n-title="auth.logout.title" title="Clear saved token" aria-label="Logout">
|
||||
<button class="header-btn header-btn-logout" data-onclick="clearToken()" data-i18n-title="auth.logout.title" title="Clear saved token" aria-label="Logout">
|
||||
<svg viewBox="0 0 24 24"><path d="M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5zM4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4V5z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -136,29 +136,29 @@
|
||||
<!-- Connection Banner -->
|
||||
<div class="connection-banner hidden" id="connectionBanner">
|
||||
<span id="connectionBannerText"></span>
|
||||
<button class="connection-banner-btn" id="connectionBannerBtn" onclick="manualReconnect()" style="display: none;" data-i18n="connection.reconnect">Reconnect</button>
|
||||
<button class="connection-banner-btn" id="connectionBannerBtn" data-onclick="manualReconnect()" style="display: none;" data-i18n="connection.reconnect">Reconnect</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Bar (editorial: numbered, italic active) -->
|
||||
<div class="tab-bar" id="tabBar" role="tablist">
|
||||
<div class="tab-indicator" id="tabIndicator"></div>
|
||||
<button class="tab-btn active" data-tab="player" onclick="switchTab('player')" role="tab" aria-selected="true" aria-controls="panel-player" tabindex="0">
|
||||
<button class="tab-btn active" data-tab="player" data-onclick="switchTab('player')" role="tab" aria-selected="true" aria-controls="panel-player" tabindex="0">
|
||||
<span class="tab-num">01</span>
|
||||
<span data-i18n="tab.player">Now Spinning</span>
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="display" onclick="switchTab('display')" role="tab" aria-selected="false" aria-controls="panel-display" tabindex="-1">
|
||||
<button class="tab-btn" data-tab="display" data-onclick="switchTab('display')" role="tab" aria-selected="false" aria-controls="panel-display" tabindex="-1">
|
||||
<span class="tab-num">02</span>
|
||||
<span data-i18n="tab.display">Display</span>
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="browser" onclick="switchTab('browser')" role="tab" aria-selected="false" aria-controls="panel-browser" tabindex="-1">
|
||||
<button class="tab-btn" data-tab="browser" data-onclick="switchTab('browser')" role="tab" aria-selected="false" aria-controls="panel-browser" tabindex="-1">
|
||||
<span class="tab-num">03</span>
|
||||
<span data-i18n="tab.browser">Library</span>
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="quick-actions" onclick="switchTab('quick-actions')" role="tab" aria-selected="false" aria-controls="panel-quick-actions" tabindex="-1">
|
||||
<button class="tab-btn" data-tab="quick-actions" data-onclick="switchTab('quick-actions')" role="tab" aria-selected="false" aria-controls="panel-quick-actions" tabindex="-1">
|
||||
<span class="tab-num">04</span>
|
||||
<span data-i18n="tab.quick_access">Quick Access</span>
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="settings" onclick="switchTab('settings')" role="tab" aria-selected="false" aria-controls="panel-settings" tabindex="-1">
|
||||
<button class="tab-btn" data-tab="settings" data-onclick="switchTab('settings')" role="tab" aria-selected="false" aria-controls="panel-settings" tabindex="-1">
|
||||
<span class="tab-num">05</span>
|
||||
<span data-i18n="tab.settings">Settings</span>
|
||||
</button>
|
||||
@@ -172,7 +172,7 @@
|
||||
<span class="fs-chrome-sep">·</span>
|
||||
<span class="fs-chrome-kicker" data-i18n="player.kicker">Now Playing</span>
|
||||
</div>
|
||||
<button class="fs-chrome-exit" onclick="togglePlayerFullscreen()" data-i18n-title="player.fullscreen.exit" title="Exit fullscreen" aria-label="Exit fullscreen">
|
||||
<button class="fs-chrome-exit" data-onclick="togglePlayerFullscreen()" data-i18n-title="player.fullscreen.exit" title="Exit fullscreen" aria-label="Exit fullscreen">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M8 8H5v2H3V6a1 1 0 0 1 1-1h4v3zm8 0h3v2h2V6a1 1 0 0 0-1-1h-4v3zm0 8h3v-2h2v4a1 1 0 0 1-1 1h-4v-3zM8 16H5v-2H3v4a1 1 0 0 0 1 1h4v-3z"/></svg>
|
||||
<span data-i18n="player.fullscreen.exit_short">Exit</span>
|
||||
<kbd class="fs-chrome-kbd">ESC</kbd>
|
||||
@@ -267,13 +267,13 @@
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button class="btn-trans" onclick="previousTrack()" data-i18n-title="player.previous" title="Previous" id="btn-previous">
|
||||
<button class="btn-trans" data-onclick="previousTrack()" data-i18n-title="player.previous" title="Previous" id="btn-previous">
|
||||
<svg viewBox="0 0 24 24"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
|
||||
</button>
|
||||
<button class="btn-trans primary" onclick="togglePlayPause()" data-i18n-title="player.play" title="Play/Pause" id="btn-play-pause">
|
||||
<button class="btn-trans primary" data-onclick="togglePlayPause()" data-i18n-title="player.play" title="Play/Pause" id="btn-play-pause">
|
||||
<svg viewBox="0 0 24 24" id="play-pause-icon"><path d="M8 5v14l11-7z"/></svg>
|
||||
</button>
|
||||
<button class="btn-trans" onclick="nextTrack()" data-i18n-title="player.next" title="Next" id="btn-next">
|
||||
<button class="btn-trans" data-onclick="nextTrack()" data-i18n-title="player.next" title="Next" id="btn-next">
|
||||
<svg viewBox="0 0 24 24"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
|
||||
</button>
|
||||
|
||||
@@ -287,7 +287,7 @@
|
||||
</div>
|
||||
<!-- Volume control: mute + slim slider, integrated -->
|
||||
<div class="vu-volume">
|
||||
<button class="mute-btn" onclick="toggleMute()" data-i18n-title="player.mute" title="Mute" id="btn-mute">
|
||||
<button class="mute-btn" data-onclick="toggleMute()" data-i18n-title="player.mute" title="Mute" id="btn-mute">
|
||||
<svg viewBox="0 0 24 24" id="mute-icon">
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
|
||||
</svg>
|
||||
@@ -301,7 +301,7 @@
|
||||
<!-- Hidden but functional: legacy display + visualizer toggle. -->
|
||||
<div class="visually-hidden">
|
||||
<div id="volume-display">50%</div>
|
||||
<button onclick="toggleVisualizer()" id="visualizerToggle" data-i18n-title="player.visualizer" title="Audio visualizer" style="display:none">
|
||||
<button data-onclick="toggleVisualizer()" id="visualizerToggle" data-i18n-title="player.visualizer" title="Audio visualizer" style="display:none">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M3 18h2v-8H3v8zm4 0h2V6H7v12zm4 0h2V2h-2v16zm4 0h2v-6h-2v6zm4 0h2V9h-2v9z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -318,34 +318,34 @@
|
||||
<div class="browser-toolbar" id="browserToolbar">
|
||||
<div class="browser-toolbar-left">
|
||||
<div class="view-toggle">
|
||||
<button class="view-toggle-btn active" id="viewGridBtn" onclick="setViewMode('grid')" data-i18n-title="browser.view_grid" title="Grid view">
|
||||
<button class="view-toggle-btn active" id="viewGridBtn" data-onclick="setViewMode('grid')" data-i18n-title="browser.view_grid" title="Grid view">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M3 3h8v8H3V3zm0 10h8v8H3v-8zm10-10h8v8h-8V3zm0 10h8v8h-8v-8z"/></svg>
|
||||
</button>
|
||||
<button class="view-toggle-btn" id="viewCompactBtn" onclick="setViewMode('compact')" data-i18n-title="browser.view_compact" title="Compact view">
|
||||
<button class="view-toggle-btn" id="viewCompactBtn" data-onclick="setViewMode('compact')" data-i18n-title="browser.view_compact" title="Compact view">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M2 2h5v5H2V2zm0 8h5v5H2v-5zm0 8h5v5H2v-5zm7-16h5v5H9V2zm0 8h5v5H9v-5zm0 8h5v5H9v-5zm7-16h5v5h-5V2zm0 8h5v5h-5v-5zm0 8h5v5h-5v-5z"/></svg>
|
||||
</button>
|
||||
<button class="view-toggle-btn" id="viewListBtn" onclick="setViewMode('list')" data-i18n-title="browser.view_list" title="List view">
|
||||
<button class="view-toggle-btn" id="viewListBtn" data-onclick="setViewMode('list')" data-i18n-title="browser.view_list" title="List view">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M3 4h18v2H3V4zm0 7h18v2H3v-2zm0 7h18v2H3v-2z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<button class="view-toggle-btn browser-refresh-btn" id="refreshBtn" onclick="refreshBrowser()" data-i18n-title="browser.refresh" title="Refresh">
|
||||
<button class="view-toggle-btn browser-refresh-btn" id="refreshBtn" data-onclick="refreshBrowser()" data-i18n-title="browser.refresh" title="Refresh">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M17.65 6.35A7.958 7.958 0 0012 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0112 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
|
||||
</button>
|
||||
<button class="browser-play-all-btn" id="playAllBtn" onclick="playAllFolder()" data-i18n-title="browser.play_all" title="Play All" style="display: none;">
|
||||
<button class="browser-play-all-btn" id="playAllBtn" data-onclick="playAllFolder()" data-i18n-title="browser.play_all" title="Play All" style="display: none;">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M8 5v14l11-7z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="browser-search-wrapper" id="browserSearchWrapper" style="display: none;">
|
||||
<svg class="browser-search-icon" viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0016 9.5 6.5 6.5 0 109.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
|
||||
<input type="text" id="browserSearchInput" class="browser-search-input" data-i18n-placeholder="browser.search" placeholder="Search..." oninput="onBrowserSearch()">
|
||||
<button class="browser-search-clear" id="browserSearchClear" onclick="clearBrowserSearch()" style="display: none;">
|
||||
<input type="text" id="browserSearchInput" class="browser-search-input" data-i18n-placeholder="browser.search" placeholder="Search..." data-oninput="onBrowserSearch()">
|
||||
<button class="browser-search-clear" id="browserSearchClear" data-onclick="clearBrowserSearch()" style="display: none;">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="browser-toolbar-right">
|
||||
<label class="items-per-page-label">
|
||||
<span data-i18n="browser.items_per_page">Items per page:</span>
|
||||
<select id="itemsPerPageSelect" onchange="onItemsPerPageChanged()">
|
||||
<select id="itemsPerPageSelect" data-onchange="onItemsPerPageChanged()">
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100" selected>100</option>
|
||||
@@ -366,13 +366,13 @@
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="pagination" id="browserPagination" style="display: none;">
|
||||
<button id="prevPage" onclick="previousPage()" data-i18n="browser.previous">Previous</button>
|
||||
<button id="prevPage" data-onclick="previousPage()" data-i18n="browser.previous">Previous</button>
|
||||
<div class="pagination-center">
|
||||
<span data-i18n="browser.page">Page</span>
|
||||
<input type="number" id="pageInput" class="page-input" min="1" value="1" onchange="goToPage()">
|
||||
<input type="number" id="pageInput" class="page-input" min="1" value="1" data-onchange="goToPage()">
|
||||
<span id="pageTotal">/ 1</span>
|
||||
</div>
|
||||
<button id="nextPage" onclick="nextPage()" data-i18n="browser.next">Next</button>
|
||||
<button id="nextPage" data-onclick="nextPage()" data-i18n="browser.next">Next</button>
|
||||
<span class="pagination-showing" id="paginationShowing"></span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -398,7 +398,7 @@
|
||||
<div class="audio-device-selector">
|
||||
<label>
|
||||
<span data-i18n="settings.audio.device">Loopback Device</span>
|
||||
<select id="audioDeviceSelect" onchange="onAudioDeviceChanged()">
|
||||
<select id="audioDeviceSelect" data-onchange="onAudioDeviceChanged()">
|
||||
<option value="" data-i18n="settings.audio.auto">Auto-detect</option>
|
||||
</select>
|
||||
</label>
|
||||
@@ -434,7 +434,7 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="add-card" onclick="showAddFolderDialog()">
|
||||
<div class="add-card" data-onclick="showAddFolderDialog()">
|
||||
<span class="add-card-icon">+</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -464,7 +464,7 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="add-card" onclick="showAddScriptDialog()">
|
||||
<div class="add-card" data-onclick="showAddScriptDialog()">
|
||||
<span class="add-card-icon">+</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -496,7 +496,7 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="add-card" onclick="showAddLinkDialog()">
|
||||
<div class="add-card" data-onclick="showAddLinkDialog()">
|
||||
<span class="add-card-icon">+</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -522,20 +522,20 @@
|
||||
<td colspan="4" class="empty-state">
|
||||
<div class="empty-state-illustration">
|
||||
<svg viewBox="0 0 64 64"><circle cx="32" cy="32" r="24"/><path d="M32 20v12l8 8"/></svg>
|
||||
<p>No callbacks configured. Click "Add" to create one.</p>
|
||||
<p data-i18n="callbacks.empty">No callbacks configured. Click "Add" to create one.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="add-card" onclick="showAddCallbackDialog()">
|
||||
<div class="add-card" data-onclick="showAddCallbackDialog()">
|
||||
<span class="add-card-icon">+</span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Display Control Section -->
|
||||
<!-- Display Control Section (monitors first, foreground overview below) -->
|
||||
<div class="display-container" data-tab-content="display" role="tabpanel" id="panel-display">
|
||||
<div class="display-monitors" id="displayMonitors">
|
||||
<div class="empty-state-illustration">
|
||||
@@ -543,6 +543,12 @@
|
||||
<p data-i18n="display.loading">Loading monitors...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="foreground-stage" id="foregroundStage">
|
||||
<div class="empty-state-illustration">
|
||||
<svg viewBox="0 0 64 64"><rect x="6" y="12" width="52" height="36" rx="3" fill="none" stroke="currentColor" stroke-width="2"/><line x1="6" y1="20" x2="58" y2="20" stroke="currentColor" stroke-width="2"/><circle cx="11" cy="16" r="1.4" fill="currentColor"/><circle cx="15" cy="16" r="1.4" fill="currentColor"/><circle cx="19" cy="16" r="1.4" fill="currentColor"/></svg>
|
||||
<p data-i18n="foreground.loading">Waiting for foreground signal…</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -551,7 +557,7 @@
|
||||
<div class="dialog-header">
|
||||
<h3 id="dialogTitle" data-i18n="scripts.dialog.add">Add Script</h3>
|
||||
</div>
|
||||
<form id="scriptForm" onsubmit="saveScript(event)">
|
||||
<form id="scriptForm" data-onsubmit="saveScript(event)">
|
||||
<div class="dialog-body">
|
||||
<input type="hidden" id="scriptOriginalName">
|
||||
<input type="hidden" id="scriptIsEdit">
|
||||
@@ -593,13 +599,13 @@
|
||||
<div class="params-section">
|
||||
<div class="params-header">
|
||||
<span data-i18n="scripts.field.parameters">Parameters</span>
|
||||
<button type="button" class="btn-small" onclick="addParameterRow()" data-i18n="scripts.params.add">+ Add</button>
|
||||
<button type="button" class="btn-small" data-onclick="addParameterRow()" data-i18n="scripts.params.add">+ Add</button>
|
||||
</div>
|
||||
<div id="scriptParamsContainer"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button type="button" class="btn-secondary" onclick="closeScriptDialog()" data-i18n="scripts.button.cancel">Cancel</button>
|
||||
<button type="button" class="btn-secondary" data-onclick="closeScriptDialog()" data-i18n="scripts.button.cancel">Cancel</button>
|
||||
<button type="submit" class="btn-primary" data-i18n="scripts.button.save">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -608,14 +614,14 @@
|
||||
<!-- Script Parameters Input Dialog (shown before executing scripts with params) -->
|
||||
<dialog id="scriptParamsDialog">
|
||||
<div class="dialog-header">
|
||||
<h3 id="scriptParamsDialogTitle">Execute Script</h3>
|
||||
<h3 id="scriptParamsDialogTitle" data-i18n="scripts.params.execute">Execute Script</h3>
|
||||
</div>
|
||||
<form id="scriptParamsForm" onsubmit="submitScriptWithParams(event)">
|
||||
<form id="scriptParamsForm" data-onsubmit="submitScriptWithParams(event)">
|
||||
<div class="dialog-body">
|
||||
<div id="scriptParamsInputs"></div>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button type="button" class="btn-secondary" onclick="closeScriptParamsDialog()" data-i18n="scripts.button.cancel">Cancel</button>
|
||||
<button type="button" class="btn-secondary" data-onclick="closeScriptParamsDialog()" data-i18n="scripts.button.cancel">Cancel</button>
|
||||
<button type="submit" class="btn-primary" data-i18n="scripts.params.execute">Execute</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -626,7 +632,7 @@
|
||||
<div class="dialog-header">
|
||||
<h3 id="callbackDialogTitle" data-i18n="callbacks.dialog.add">Add Callback</h3>
|
||||
</div>
|
||||
<form id="callbackForm" onsubmit="saveCallback(event)">
|
||||
<form id="callbackForm" data-onsubmit="saveCallback(event)">
|
||||
<div class="dialog-body">
|
||||
<input type="hidden" id="callbackIsEdit">
|
||||
|
||||
@@ -664,7 +670,7 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button type="button" class="btn-secondary" onclick="closeCallbackDialog()" data-i18n="callbacks.button.cancel">Cancel</button>
|
||||
<button type="button" class="btn-secondary" data-onclick="closeCallbackDialog()" data-i18n="callbacks.button.cancel">Cancel</button>
|
||||
<button type="submit" class="btn-primary" data-i18n="callbacks.button.save">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -675,7 +681,7 @@
|
||||
<div class="dialog-header">
|
||||
<h3 id="linkDialogTitle" data-i18n="links.dialog.add">Add Link</h3>
|
||||
</div>
|
||||
<form id="linkForm" onsubmit="saveLink(event)">
|
||||
<form id="linkForm" data-onsubmit="saveLink(event)">
|
||||
<div class="dialog-body">
|
||||
<input type="hidden" id="linkOriginalName">
|
||||
<input type="hidden" id="linkIsEdit">
|
||||
@@ -710,7 +716,7 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button type="button" class="btn-secondary" onclick="closeLinkDialog()" data-i18n="links.button.cancel">Cancel</button>
|
||||
<button type="button" class="btn-secondary" data-onclick="closeLinkDialog()" data-i18n="links.button.cancel">Cancel</button>
|
||||
<button type="submit" class="btn-primary" data-i18n="links.button.save">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -737,7 +743,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button type="button" class="btn-secondary" onclick="closeExecutionDialog()" data-i18n="scripts.execution.close">Close</button>
|
||||
<button type="button" class="btn-secondary" data-onclick="closeExecutionDialog()" data-i18n="scripts.execution.close">Close</button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
@@ -746,7 +752,7 @@
|
||||
<div class="dialog-header">
|
||||
<h3 id="folderDialogTitle" data-i18n="browser.folder_dialog.title_add">Add Media Folder</h3>
|
||||
</div>
|
||||
<form id="folderForm" onsubmit="saveFolder(event)">
|
||||
<form id="folderForm" data-onsubmit="saveFolder(event)">
|
||||
<div class="dialog-body">
|
||||
<input type="hidden" id="folderIsEdit">
|
||||
<input type="hidden" id="folderOriginalId">
|
||||
@@ -773,7 +779,7 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button type="button" class="btn-secondary" onclick="closeFolderDialog()" data-i18n="browser.folder_dialog.cancel">Cancel</button>
|
||||
<button type="button" class="btn-secondary" data-onclick="closeFolderDialog()" data-i18n="browser.folder_dialog.cancel">Cancel</button>
|
||||
<button type="submit" class="btn-primary" data-i18n="browser.folder_dialog.save">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -813,7 +819,7 @@
|
||||
</ul>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button type="button" class="btn-secondary" onclick="closeAboutDialog()" data-i18n="dialog.close">Close</button>
|
||||
<button type="button" class="btn-secondary" data-onclick="closeAboutDialog()" data-i18n="dialog.close">Close</button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
|
||||
@@ -74,6 +74,10 @@ import {
|
||||
toggleDynamicBackground, applyDynamicBackground, updateBackgroundColors,
|
||||
} from './background.js';
|
||||
|
||||
import {
|
||||
updateForegroundUI, loadForegroundProcess,
|
||||
} from './foreground.js';
|
||||
|
||||
// ============================================================
|
||||
// Register late-bound callbacks for core's updateAllText()
|
||||
// ============================================================
|
||||
@@ -136,6 +140,8 @@ Object.assign(window, {
|
||||
onAudioDeviceChanged,
|
||||
// About
|
||||
showAboutDialog, closeAboutDialog,
|
||||
// Foreground
|
||||
loadForegroundProcess,
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
@@ -159,10 +165,72 @@ HTMLDialogElement.prototype.showModal = function (...args) {
|
||||
return result;
|
||||
};
|
||||
|
||||
// CSP-safe replacement for inline on* handlers. HTML uses data-onclick,
|
||||
// data-onchange, data-oninput, data-onsubmit with simple call expressions
|
||||
// like "fn()", "fn('arg')", "fn(event)". We parse those at startup and
|
||||
// attach proper addEventListener calls so script-src 'self' stays strict.
|
||||
const INLINE_HANDLER_EVENTS = {
|
||||
'data-onclick': 'click',
|
||||
'data-onchange': 'change',
|
||||
'data-oninput': 'input',
|
||||
'data-onsubmit': 'submit',
|
||||
};
|
||||
|
||||
function parseInlineHandlerArg(token) {
|
||||
const t = token.trim();
|
||||
if (t === '') return { kind: 'empty' };
|
||||
if (t === 'event') return { kind: 'event' };
|
||||
if (t === 'true') return { kind: 'literal', value: true };
|
||||
if (t === 'false') return { kind: 'literal', value: false };
|
||||
if (t === 'null') return { kind: 'literal', value: null };
|
||||
if (/^-?\d+(\.\d+)?$/.test(t)) return { kind: 'literal', value: Number(t) };
|
||||
if ((t.startsWith("'") && t.endsWith("'")) || (t.startsWith('"') && t.endsWith('"'))) {
|
||||
return { kind: 'literal', value: t.slice(1, -1) };
|
||||
}
|
||||
console.warn('inline-handler: unsupported arg token', token);
|
||||
return { kind: 'literal', value: undefined };
|
||||
}
|
||||
|
||||
function compileInlineHandler(expr) {
|
||||
const m = expr.match(/^\s*([A-Za-z_$][\w$]*)\s*\((.*)\)\s*;?\s*$/s);
|
||||
if (!m) {
|
||||
console.warn('inline-handler: unparsable expression', expr);
|
||||
return null;
|
||||
}
|
||||
const fnName = m[1];
|
||||
const argsRaw = m[2].trim();
|
||||
const argTokens = argsRaw === '' ? [] : argsRaw.split(',').map(s => s.trim());
|
||||
const parsedArgs = argTokens.map(parseInlineHandlerArg);
|
||||
return function (event) {
|
||||
const fn = window[fnName];
|
||||
if (typeof fn !== 'function') {
|
||||
console.error('inline-handler: missing global function', fnName);
|
||||
return;
|
||||
}
|
||||
const args = parsedArgs.map(a => a.kind === 'event' ? event : a.value);
|
||||
return fn.apply(this, args);
|
||||
};
|
||||
}
|
||||
|
||||
function wireInlineHandlers(root) {
|
||||
for (const [attr, eventName] of Object.entries(INLINE_HANDLER_EVENTS)) {
|
||||
const nodes = root.querySelectorAll(`[${attr}]`);
|
||||
for (const el of nodes) {
|
||||
const expr = el.getAttribute(attr);
|
||||
const handler = compileInlineHandler(expr);
|
||||
if (handler) el.addEventListener(eventName, handler);
|
||||
el.removeAttribute(attr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('DOMContentLoaded', async () => {
|
||||
// Cache DOM references
|
||||
cacheDom();
|
||||
|
||||
// Wire CSP-safe inline-handler stand-ins from index.html
|
||||
wireInlineHandlers(document);
|
||||
|
||||
// Initialize theme and accent color
|
||||
initTheme();
|
||||
initAccentColor();
|
||||
|
||||
@@ -66,12 +66,14 @@ function showRootFolders() {
|
||||
// Hide search at root level
|
||||
showBrowserSearch(false);
|
||||
|
||||
// Render breadcrumb with just "Home" (not clickable at root)
|
||||
// Render breadcrumb with just "Home" (already at root — not interactive).
|
||||
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>';
|
||||
root.setAttribute('aria-current', 'page');
|
||||
root.setAttribute('aria-label', t('browser.home') || 'Home');
|
||||
root.innerHTML = '<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>';
|
||||
breadcrumb.appendChild(root);
|
||||
|
||||
// Hide play all button and pagination
|
||||
@@ -133,8 +135,10 @@ function showRootFolders() {
|
||||
}
|
||||
|
||||
async function browsePath(folderId, path, offset = 0, nocache = false) {
|
||||
// Clear search when navigating
|
||||
// Clear search when navigating; bump browse generation so in-flight
|
||||
// thumbnail fetches from the previous folder can be discarded.
|
||||
showBrowserSearch(false);
|
||||
bumpBrowseGen();
|
||||
|
||||
try {
|
||||
if (!hasCredentials()) return;
|
||||
@@ -195,10 +199,13 @@ function renderBreadcrumbs(currentPathStr, parentPath) {
|
||||
const parts = (currentPathStr || '').split('/').filter(p => p);
|
||||
let path = '/';
|
||||
|
||||
// Home link (back to folder list)
|
||||
const home = document.createElement('span');
|
||||
// Home link (back to folder list) — use a real <button> so it's
|
||||
// keyboard-focusable and reachable by screen readers.
|
||||
const home = document.createElement('button');
|
||||
home.type = 'button';
|
||||
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.setAttribute('aria-label', t('browser.home') || 'Home');
|
||||
home.innerHTML = '<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>';
|
||||
home.onclick = () => showRootFolders();
|
||||
breadcrumb.appendChild(home);
|
||||
|
||||
@@ -512,16 +519,34 @@ function formatBitrate(bps) {
|
||||
return Math.round(bps / 1000) + ' kbps';
|
||||
}
|
||||
|
||||
// Bump this whenever the user changes folder/path so in-flight fetches from
|
||||
// the previous view can be ignored when they finally resolve.
|
||||
let _browseGen = 0;
|
||||
function bumpBrowseGen() { return ++_browseGen; }
|
||||
function currentBrowseGen() { return _browseGen; }
|
||||
|
||||
function buildRelativeFilePath(relativePath, fileName) {
|
||||
const base = (relativePath === '/' || relativePath === '') ? '' : relativePath.replace(/\/$/, '');
|
||||
return base + '/' + fileName;
|
||||
}
|
||||
|
||||
async function loadThumbnail(imgElement, fileName) {
|
||||
const myGen = currentBrowseGen();
|
||||
const folderId = currentFolderId;
|
||||
const relPath = buildRelativeFilePath(currentPath, fileName);
|
||||
const cacheKey = `${folderId}|${relPath}`;
|
||||
try {
|
||||
if (!hasCredentials()) return;
|
||||
|
||||
const absolutePath = buildAbsolutePath(currentFolderId, currentPath, fileName);
|
||||
// Note: the imgElement is intentionally NOT in the DOM yet when
|
||||
// renderBrowserGrid/renderBrowserList call us — it's still inside a
|
||||
// detached wrapper. Don't bail on isConnected here; rely on the
|
||||
// post-await checks below, which correctly catch navigation away.
|
||||
|
||||
// Check cache first
|
||||
if (thumbnailCache.has(absolutePath)) {
|
||||
const cachedUrl = thumbnailCache.get(absolutePath);
|
||||
if (thumbnailCache.has(cacheKey)) {
|
||||
const cachedUrl = thumbnailCache.get(cacheKey);
|
||||
imgElement.onload = () => {
|
||||
if (!imgElement.isConnected) return;
|
||||
imgElement.classList.remove('loading');
|
||||
imgElement.classList.add('loaded');
|
||||
};
|
||||
@@ -529,17 +554,24 @@ async function loadThumbnail(imgElement, fileName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const encodedPath = encodeURIComponent(absolutePath);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
folder_id: folderId,
|
||||
path: relPath,
|
||||
size: 'medium',
|
||||
});
|
||||
const response = await fetch(
|
||||
`/api/browser/thumbnail?path=${encodedPath}&size=medium`,
|
||||
`/api/browser/thumbnail?${params.toString()}`,
|
||||
{ headers: getAuthHeaders() }
|
||||
);
|
||||
|
||||
// Drop the response if the user has since navigated away.
|
||||
if (myGen !== currentBrowseGen() || !imgElement.isConnected) return;
|
||||
|
||||
if (response.status === 200) {
|
||||
const blob = await response.blob();
|
||||
if (myGen !== currentBrowseGen() || !imgElement.isConnected) return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
thumbnailCache.set(absolutePath, url);
|
||||
thumbnailCache.set(cacheKey, url);
|
||||
|
||||
// Evict oldest entries when cache exceeds limit
|
||||
if (thumbnailCache.size > THUMBNAIL_CACHE_MAX) {
|
||||
@@ -548,13 +580,11 @@ async function loadThumbnail(imgElement, fileName) {
|
||||
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
|
||||
if (imgElement.src && imgElement.src.startsWith('blob:')) {
|
||||
let isCached = false;
|
||||
for (const cachedUrl of thumbnailCache.values()) {
|
||||
@@ -564,8 +594,8 @@ async function loadThumbnail(imgElement, fileName) {
|
||||
}
|
||||
imgElement.src = url;
|
||||
} else {
|
||||
// Fallback to icon (204 = no thumbnail available)
|
||||
const parent = imgElement.parentElement;
|
||||
if (!parent) return;
|
||||
const isList = parent.classList.contains('browser-list-icon');
|
||||
imgElement.remove();
|
||||
if (isList) {
|
||||
@@ -579,7 +609,7 @@ async function loadThumbnail(imgElement, fileName) {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading thumbnail:', error);
|
||||
imgElement.classList.remove('loading');
|
||||
if (imgElement.isConnected) imgElement.classList.remove('loading');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -601,12 +631,12 @@ async function playMediaFile(fileName) {
|
||||
try {
|
||||
if (!hasCredentials()) return;
|
||||
|
||||
const absolutePath = buildAbsolutePath(currentFolderId, currentPath, fileName);
|
||||
const relativePath = buildRelativeFilePath(currentPath, fileName);
|
||||
|
||||
const response = await fetch('/api/browser/play', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
||||
body: JSON.stringify({ path: absolutePath })
|
||||
body: JSON.stringify({ folder_id: currentFolderId, path: relativePath })
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to play file');
|
||||
|
||||
@@ -81,7 +81,7 @@ async function _loadCallbacksTableImpl() {
|
||||
`).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>';
|
||||
tbody.innerHTML = `<tr><td colspan="4" class="empty-state" style="color: var(--error);">${escapeHtml(t('callbacks.msg.load_failed'))}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ export async function showEditCallbackDialog(callbackName) {
|
||||
const callback = callbacksList.find(c => c.name === callbackName);
|
||||
|
||||
if (!callback) {
|
||||
showToast('Callback not found', 'error');
|
||||
showToast(t('callbacks.msg.not_found'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ export async function showEditCallbackDialog(callbackName) {
|
||||
dialog.showModal();
|
||||
} catch (error) {
|
||||
console.error('Error loading callback for edit:', error);
|
||||
showToast('Failed to load callback details', 'error');
|
||||
showToast(t('callbacks.msg.load_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,9 +175,10 @@ export async function saveCallback(event) {
|
||||
shell: true
|
||||
};
|
||||
|
||||
const encodedName = encodeURIComponent(callbackName);
|
||||
const endpoint = isEdit ?
|
||||
`/api/callbacks/update/${callbackName}` :
|
||||
`/api/callbacks/create/${callbackName}`;
|
||||
`/api/callbacks/update/${encodedName}` :
|
||||
`/api/callbacks/create/${encodedName}`;
|
||||
|
||||
const method = isEdit ? 'PUT' : 'POST';
|
||||
|
||||
@@ -191,16 +192,16 @@ export async function saveCallback(event) {
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
showToast(`Callback ${isEdit ? 'updated' : 'created'} successfully`, 'success');
|
||||
showToast(t(isEdit ? 'callbacks.msg.updated' : 'callbacks.msg.created'), 'success');
|
||||
callbackFormDirty = false;
|
||||
closeCallbackDialog();
|
||||
loadCallbacksTable();
|
||||
} else {
|
||||
showToast(result.detail || `Failed to ${isEdit ? 'update' : 'create'} callback`, 'error');
|
||||
showToast(result.detail || t(isEdit ? 'callbacks.msg.update_failed' : 'callbacks.msg.create_failed'), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving callback:', error);
|
||||
showToast(`Error ${isEdit ? 'updating' : 'creating'} callback`, 'error');
|
||||
showToast(t(isEdit ? 'callbacks.msg.update_failed' : 'callbacks.msg.create_failed'), 'error');
|
||||
} finally {
|
||||
if (submitBtn) submitBtn.disabled = false;
|
||||
}
|
||||
@@ -212,7 +213,7 @@ export async function deleteCallbackConfirm(callbackName) {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/callbacks/delete/${callbackName}`, {
|
||||
const response = await fetch(`/api/callbacks/delete/${encodeURIComponent(callbackName)}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
@@ -220,13 +221,13 @@ export async function deleteCallbackConfirm(callbackName) {
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
showToast('Callback deleted successfully', 'success');
|
||||
showToast(t('callbacks.msg.deleted'), 'success');
|
||||
loadCallbacksTable();
|
||||
} else {
|
||||
showToast(result.detail || 'Failed to delete callback', 'error');
|
||||
showToast(result.detail || t('callbacks.msg.delete_failed'), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting callback:', error);
|
||||
showToast('Error deleting callback', 'error');
|
||||
showToast(t('callbacks.msg.delete_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,6 +155,7 @@ export const VOLUME_RELEASE_DELAY_MS = 500;
|
||||
// Shared state (accessed across multiple modules)
|
||||
export let ws = null;
|
||||
export function setWs(value) { ws = value; }
|
||||
export function getWs() { return ws; }
|
||||
export let currentState = 'idle';
|
||||
export function setCurrentState(value) { currentState = value; }
|
||||
export let currentDuration = 0;
|
||||
@@ -513,6 +514,12 @@ export function setVolume(volume) {
|
||||
sendCommand('volume', { volume: volume });
|
||||
}
|
||||
}
|
||||
// Reset the de-dupe cache whenever the server reports a fresh volume value
|
||||
// (e.g., another client moved the slider). Otherwise the user can end up
|
||||
// unable to "set volume back to the value we last sent" after a remote change.
|
||||
export function notifyRemoteVolume(volume) {
|
||||
lastSentVolume = volume;
|
||||
}
|
||||
|
||||
export function toggleMute() {
|
||||
sendCommand('mute');
|
||||
@@ -536,23 +543,68 @@ function _persistMdiCache() {
|
||||
try { localStorage.setItem('mdiIconCache', JSON.stringify(mdiIconCache)); } catch {}
|
||||
}
|
||||
|
||||
// Strict iconify MDI slug — used to reject anything that could be path-traversal
|
||||
// or query injection before we even hit the network.
|
||||
const MDI_SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||
|
||||
function sanitizeSvg(rawSvg) {
|
||||
// Parse the SVG and strip anything that could execute script. Anything
|
||||
// unparseable returns null so callers fall back to the placeholder.
|
||||
try {
|
||||
const doc = new DOMParser().parseFromString(rawSvg, 'image/svg+xml');
|
||||
const root = doc.documentElement;
|
||||
if (!root || root.tagName.toLowerCase() !== 'svg' || root.querySelector('parsererror')) {
|
||||
return null;
|
||||
}
|
||||
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
|
||||
const toRemove = [];
|
||||
let node = walker.currentNode;
|
||||
while (node) {
|
||||
const tag = node.tagName?.toLowerCase();
|
||||
if (tag === 'script' || tag === 'foreignobject') {
|
||||
toRemove.push(node);
|
||||
} else if (node.attributes) {
|
||||
for (const attr of Array.from(node.attributes)) {
|
||||
const name = attr.name.toLowerCase();
|
||||
if (name.startsWith('on') ||
|
||||
((name === 'href' || name === 'xlink:href') &&
|
||||
/^\s*(javascript|data):/i.test(attr.value))) {
|
||||
node.removeAttribute(attr.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
node = walker.nextNode();
|
||||
}
|
||||
toRemove.forEach((el) => el.remove());
|
||||
return root.outerHTML;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const PLACEHOLDER_SVG = '<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>';
|
||||
|
||||
export async function fetchMdiIcon(iconName) {
|
||||
const name = iconName.replace(/^mdi:/, '');
|
||||
const name = String(iconName || '').replace(/^mdi:/, '');
|
||||
if (!MDI_SLUG_RE.test(name)) return PLACEHOLDER_SVG;
|
||||
if (mdiIconCache[name]) return mdiIconCache[name];
|
||||
|
||||
try {
|
||||
const response = await fetch(`https://api.iconify.design/mdi/${name}.svg?width=16&height=16`);
|
||||
const response = await fetch(`https://api.iconify.design/mdi/${encodeURIComponent(name)}.svg?width=16&height=16`);
|
||||
if (response.ok) {
|
||||
const svg = await response.text();
|
||||
mdiIconCache[name] = svg;
|
||||
_persistMdiCache();
|
||||
return svg;
|
||||
const raw = await response.text();
|
||||
const safe = sanitizeSvg(raw);
|
||||
if (safe) {
|
||||
mdiIconCache[name] = safe;
|
||||
_persistMdiCache();
|
||||
return safe;
|
||||
}
|
||||
}
|
||||
} 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>';
|
||||
return PLACEHOLDER_SVG;
|
||||
}
|
||||
|
||||
export async function resolveMdiIcons(container) {
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
// ============================================================
|
||||
// Foreground: Currently-focused desktop process card (rendered at
|
||||
// the top of the Display tab)
|
||||
// ============================================================
|
||||
|
||||
import { t } from './core.js';
|
||||
|
||||
let latestForeground = null;
|
||||
let agoTickTimer = null;
|
||||
|
||||
function escapeHtml(s) {
|
||||
if (s === null || s === undefined) return '';
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function formatAgo(epoch) {
|
||||
if (!epoch) return '';
|
||||
const now = Date.now() / 1000;
|
||||
const diff = Math.max(0, now - epoch);
|
||||
if (diff < 60) {
|
||||
return t('foreground.ago.seconds', { n: Math.floor(diff) });
|
||||
}
|
||||
if (diff < 3600) {
|
||||
return t('foreground.ago.minutes', { n: Math.floor(diff / 60) });
|
||||
}
|
||||
if (diff < 86400) {
|
||||
const h = Math.floor(diff / 3600);
|
||||
const m = Math.floor((diff % 3600) / 60);
|
||||
return t('foreground.ago.hours', { n: h, m: m });
|
||||
}
|
||||
return t('foreground.ago.days', { n: Math.floor(diff / 86400) });
|
||||
}
|
||||
|
||||
function formatGeometry(g) {
|
||||
if (!g) return '—';
|
||||
const w = g.width ?? (g.right - g.left);
|
||||
const h = g.height ?? (g.bottom - g.top);
|
||||
return `${w}×${h} @ (${g.left}, ${g.top})`;
|
||||
}
|
||||
|
||||
function truncatePath(p, max = 64) {
|
||||
if (!p) return '';
|
||||
if (p.length <= max) return p;
|
||||
// Keep the tail (filename) visible — that's the part the user cares about.
|
||||
return '…' + p.slice(-(max - 1));
|
||||
}
|
||||
|
||||
function renderEmpty(message, errorMsg) {
|
||||
const stage = document.getElementById('foregroundStage');
|
||||
if (!stage) return;
|
||||
stage.innerHTML = `
|
||||
<div class="empty-state-illustration foreground-empty">
|
||||
<svg viewBox="0 0 64 64"><rect x="6" y="12" width="52" height="36" rx="3" fill="none" stroke="currentColor" stroke-width="2"/><line x1="6" y1="20" x2="58" y2="20" stroke="currentColor" stroke-width="2"/><circle cx="11" cy="16" r="1.4" fill="currentColor"/><circle cx="15" cy="16" r="1.4" fill="currentColor"/><circle cx="19" cy="16" r="1.4" fill="currentColor"/></svg>
|
||||
<p>${escapeHtml(message)}</p>
|
||||
${errorMsg ? `<p class="foreground-empty-error">${escapeHtml(errorMsg)}</p>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderTile(data) {
|
||||
const stage = document.getElementById('foregroundStage');
|
||||
if (!stage) return;
|
||||
|
||||
const procName = data.process_name || '—';
|
||||
const winTitle = data.window_title || '';
|
||||
const execPath = data.executable_path || '';
|
||||
const pid = data.pid ?? '—';
|
||||
const startedEpoch = data.started_at;
|
||||
const startedAgo = startedEpoch ? formatAgo(startedEpoch) : '—';
|
||||
const startedAbs = startedEpoch
|
||||
? new Date(startedEpoch * 1000).toLocaleString()
|
||||
: '';
|
||||
const geom = formatGeometry(data.window_geometry);
|
||||
const platform = data.platform || '—';
|
||||
const monitorId = data.monitor_id;
|
||||
|
||||
// Chips: only render ones that apply
|
||||
const chips = [];
|
||||
if (data.is_fullscreen) {
|
||||
chips.push(`<span class="fg-chip fg-chip-accent">${escapeHtml(t('foreground.fullscreen'))}</span>`);
|
||||
} else if (!data.is_minimized) {
|
||||
chips.push(`<span class="fg-chip">${escapeHtml(t('foreground.windowed'))}</span>`);
|
||||
}
|
||||
if (data.is_minimized) {
|
||||
chips.push(`<span class="fg-chip fg-chip-mute">${escapeHtml(t('foreground.minimized'))}</span>`);
|
||||
}
|
||||
if (monitorId !== null && monitorId !== undefined) {
|
||||
chips.push(`<span class="fg-chip">${escapeHtml(t('foreground.monitor', { n: monitorId + 1 }))}</span>`);
|
||||
}
|
||||
if (data.is_browser) {
|
||||
chips.push(`<span class="fg-chip fg-chip-mute">${escapeHtml(t('foreground.browser'))}</span>`);
|
||||
}
|
||||
|
||||
// Optional browser-only detail rows (page title + URL when available)
|
||||
const browserRows = [];
|
||||
if (data.is_browser) {
|
||||
if (data.browser_page_title) {
|
||||
browserRows.push(`
|
||||
<div class="fg-row">
|
||||
<dt>${escapeHtml(t('foreground.page_title'))}</dt>
|
||||
<dd title="${escapeHtml(data.browser_page_title)}">${escapeHtml(data.browser_page_title)}</dd>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
if (data.browser_url) {
|
||||
browserRows.push(`
|
||||
<div class="fg-row">
|
||||
<dt>${escapeHtml(t('foreground.url'))}</dt>
|
||||
<dd title="${escapeHtml(data.browser_url)}"><span class="fg-mono">${escapeHtml(truncatePath(data.browser_url, 80))}</span></dd>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
stage.innerHTML = `
|
||||
<article class="foreground-card" data-fullscreen="${data.is_fullscreen ? '1' : '0'}">
|
||||
<div class="fg-kicker">
|
||||
<span data-i18n="foreground.kicker">Foreground</span>
|
||||
</div>
|
||||
<h1 class="fg-process" title="${escapeHtml(procName)}">${escapeHtml(procName)}</h1>
|
||||
<div class="fg-window-title" title="${escapeHtml(winTitle)}">${escapeHtml(winTitle)}</div>
|
||||
|
||||
<div class="fg-chips">${chips.join('')}</div>
|
||||
|
||||
<dl class="fg-details">
|
||||
${browserRows.join('')}
|
||||
<div class="fg-row">
|
||||
<dt>${escapeHtml(t('foreground.executable'))}</dt>
|
||||
<dd title="${escapeHtml(execPath)}"><span class="fg-mono">${escapeHtml(truncatePath(execPath))}</span></dd>
|
||||
</div>
|
||||
<div class="fg-row">
|
||||
<dt>${escapeHtml(t('foreground.pid'))}</dt>
|
||||
<dd><span class="fg-mono">${escapeHtml(String(pid))}</span></dd>
|
||||
</div>
|
||||
<div class="fg-row">
|
||||
<dt>${escapeHtml(t('foreground.started'))}</dt>
|
||||
<dd title="${escapeHtml(startedAbs)}"><span class="fg-ago" data-started="${startedEpoch ?? ''}">${escapeHtml(startedAgo)}</span></dd>
|
||||
</div>
|
||||
<div class="fg-row">
|
||||
<dt>${escapeHtml(t('foreground.geometry'))}</dt>
|
||||
<dd><span class="fg-mono">${escapeHtml(geom)}</span></dd>
|
||||
</div>
|
||||
<div class="fg-row">
|
||||
<dt>${escapeHtml(t('foreground.platform'))}</dt>
|
||||
<dd>${escapeHtml(platform)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
function startAgoTicker() {
|
||||
if (agoTickTimer) return;
|
||||
agoTickTimer = setInterval(() => {
|
||||
const el = document.querySelector('.fg-ago[data-started]');
|
||||
if (!el) return;
|
||||
const epoch = parseFloat(el.getAttribute('data-started'));
|
||||
if (!epoch) return;
|
||||
el.textContent = formatAgo(epoch);
|
||||
}, 15000);
|
||||
}
|
||||
|
||||
export function updateForegroundUI(data) {
|
||||
latestForeground = data;
|
||||
|
||||
if (!data || data.available === false) {
|
||||
const errMsg = data && data.error ? data.error : '';
|
||||
renderEmpty(t('foreground.unavailable'), errMsg);
|
||||
} else if (!data.process_name && !data.pid) {
|
||||
renderEmpty(t('foreground.no_process'), '');
|
||||
} else {
|
||||
renderTile(data);
|
||||
startAgoTicker();
|
||||
}
|
||||
}
|
||||
|
||||
export function loadForegroundProcess() {
|
||||
// Push-only — just render the cached state. If nothing has arrived
|
||||
// yet, leave the loading placeholder visible.
|
||||
if (latestForeground !== null) {
|
||||
updateForegroundUI(latestForeground);
|
||||
}
|
||||
}
|
||||
@@ -148,16 +148,19 @@ export async function loadDisplayMonitors() {
|
||||
|
||||
let powerBtn = '';
|
||||
if (monitor.power_supported) {
|
||||
// Inline onclick with string-interpolated monitor.name is a DOM-XSS
|
||||
// foot-gun if the OS ever reports a name containing quotes / angle
|
||||
// brackets. Use a delegated click handler bound to data-* attrs.
|
||||
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')}">
|
||||
data-action="toggle-power" data-monitor-id="${monitor.id}"
|
||||
title="${escapeHtml(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 detailsHtml = details ? `<span class="display-monitor-details">${escapeHtml(details)}</span>` : '';
|
||||
const primaryBadge = monitor.is_primary
|
||||
? `<span class="display-primary-badge" title="${t('display.primary')}" aria-label="${t('display.primary')}">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
|
||||
@@ -282,7 +285,7 @@ export async function loadDisplayMonitors() {
|
||||
<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"><span class="display-monitor-name-text">${monitor.name}</span>${primaryBadge}</span>
|
||||
<span class="display-monitor-name"><span class="display-monitor-name-text">${escapeHtml(monitor.name)}</span>${primaryBadge}</span>
|
||||
${detailsHtml}
|
||||
</div>
|
||||
${powerBtn}
|
||||
@@ -303,6 +306,11 @@ export async function loadDisplayMonitors() {
|
||||
container.appendChild(card);
|
||||
});
|
||||
|
||||
// Bind a single delegated click handler for the power buttons.
|
||||
// Avoids inline onclick="..." with interpolated monitor data.
|
||||
container.removeEventListener('click', _onPowerButtonClick);
|
||||
container.addEventListener('click', _onPowerButtonClick);
|
||||
|
||||
// Enhance every tuning <select> with an IconSelect now that the
|
||||
// cards are in the DOM (IconSelect needs offsetParent + sibling).
|
||||
pendingIconSelects.forEach(({ kind, monitorId, items }) => {
|
||||
@@ -441,7 +449,14 @@ export async function onDisplayPictureModeChange(monitorId, codeRaw) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleDisplayPower(monitorId, monitorName) {
|
||||
function _onPowerButtonClick(event) {
|
||||
const btn = event.target.closest('[data-action="toggle-power"]');
|
||||
if (!btn) return;
|
||||
const id = Number(btn.dataset.monitorId);
|
||||
if (Number.isFinite(id)) toggleDisplayPower(id);
|
||||
}
|
||||
|
||||
export async function toggleDisplayPower(monitorId) {
|
||||
const btn = document.getElementById(`power-btn-${monitorId}`);
|
||||
const isOn = btn && btn.classList.contains('on');
|
||||
const newState = !isOn;
|
||||
@@ -459,13 +474,13 @@ export async function toggleDisplayPower(monitorId, monitorName) {
|
||||
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');
|
||||
showToast(t(newState ? 'display.msg.power_on' : 'display.msg.power_off'), 'success');
|
||||
} else {
|
||||
showToast('Failed to change monitor power', 'error');
|
||||
showToast(t('display.msg.power_failed'), 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to set display power:', e);
|
||||
showToast('Failed to change monitor power', 'error');
|
||||
showToast(t('display.msg.power_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -562,7 +577,7 @@ async function _loadLinksTableImpl() {
|
||||
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>';
|
||||
tbody.innerHTML = `<tr><td colspan="4" class="empty-state" style="color: var(--error);">${escapeHtml(t('links.msg.load_failed'))}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -665,9 +680,10 @@ export async function saveLink(event) {
|
||||
description: document.getElementById('linkDescription').value || ''
|
||||
};
|
||||
|
||||
const encodedName = encodeURIComponent(linkName);
|
||||
const endpoint = isEdit ?
|
||||
`/api/links/update/${linkName}` :
|
||||
`/api/links/create/${linkName}`;
|
||||
`/api/links/update/${encodedName}` :
|
||||
`/api/links/create/${encodedName}`;
|
||||
|
||||
const method = isEdit ? 'PUT' : 'POST';
|
||||
|
||||
@@ -701,7 +717,7 @@ export async function deleteLinkConfirm(linkName) {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/links/delete/${linkName}`, {
|
||||
const response = await fetch(`/api/links/delete/${encodeURIComponent(linkName)}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
@@ -8,11 +8,12 @@ import {
|
||||
ws, currentState, setCurrentState, currentDuration, setCurrentDuration,
|
||||
currentPosition, setCurrentPosition, isUserAdjustingVolume,
|
||||
lastStatus, setLastStatus, currentPlayState, setCurrentPlayState,
|
||||
POSITION_INTERPOLATION_MS, seek,
|
||||
POSITION_INTERPOLATION_MS, seek, notifyRemoteVolume,
|
||||
getAuthHeaders, hasCredentials,
|
||||
} from './core.js';
|
||||
import { updateBackgroundColors } from './background.js';
|
||||
import { loadDisplayMonitors } from './links.js';
|
||||
import { loadForegroundProcess } from './foreground.js';
|
||||
import { IconSelect } from './icon-select.js';
|
||||
|
||||
// Tab management
|
||||
@@ -75,6 +76,7 @@ export function switchTab(tabName) {
|
||||
|
||||
if (tabName === 'display') {
|
||||
loadDisplayMonitors();
|
||||
loadForegroundProcess();
|
||||
}
|
||||
|
||||
localStorage.setItem('activeTab', tabName);
|
||||
@@ -687,54 +689,49 @@ export async function onAudioDeviceChanged() {
|
||||
|
||||
let lastArtworkKey = null;
|
||||
let currentArtworkBlobUrl = null;
|
||||
let artworkFetchGen = 0;
|
||||
let artworkAbort = null;
|
||||
let lastPositionUpdate = 0;
|
||||
let lastPositionValue = 0;
|
||||
let interpolationInterval = null;
|
||||
|
||||
export function setupProgressDrag(bar, fill) {
|
||||
let dragging = false;
|
||||
|
||||
// Listeners are attached on mousedown and removed on mouseup so the
|
||||
// document doesn't carry per-progress-bar move handlers for the entire
|
||||
// session (especially expensive on mobile).
|
||||
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 updatePreview(percent) {
|
||||
fill.style.width = (percent * 100) + '%';
|
||||
}
|
||||
|
||||
function handleStart(clientX) {
|
||||
function pointerStart(getX, moveEvent, endEvent, getMoveX, getEndX) {
|
||||
if (currentDuration <= 0) return;
|
||||
dragging = true;
|
||||
bar.classList.add('dragging');
|
||||
updatePreview(getPercent(clientX));
|
||||
}
|
||||
updatePreview(getPercent(getX));
|
||||
|
||||
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);
|
||||
function onMove(e) { updatePreview(getPercent(getMoveX(e))); }
|
||||
function onEnd(e) {
|
||||
document.removeEventListener(moveEvent, onMove);
|
||||
document.removeEventListener(endEvent, onEnd);
|
||||
bar.classList.remove('dragging');
|
||||
const clientX = getEndX(e);
|
||||
if (clientX !== undefined) seek(getPercent(clientX) * currentDuration);
|
||||
}
|
||||
document.addEventListener(moveEvent, onMove);
|
||||
document.addEventListener(endEvent, onEnd);
|
||||
}
|
||||
|
||||
bar.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
pointerStart(e.clientX, 'mousemove', 'mouseup',
|
||||
(ev) => ev.clientX, (ev) => ev.clientX);
|
||||
});
|
||||
bar.addEventListener('touchstart', (e) => {
|
||||
pointerStart(e.touches[0].clientX, 'touchmove', 'touchend',
|
||||
(ev) => ev.touches[0].clientX,
|
||||
(ev) => ev.changedTouches?.[0]?.clientX);
|
||||
}, { passive: true });
|
||||
|
||||
bar.addEventListener('click', (e) => {
|
||||
if (currentDuration > 0) {
|
||||
@@ -811,28 +808,35 @@ export function updateUI(status) {
|
||||
lastArtworkKey = artworkKey;
|
||||
const placeholderArt = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Cpath fill='%236a6a6a' opacity='0.35' 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";
|
||||
// Cancel any in-flight artwork fetch and bump the generation so a
|
||||
// late response from a previous track cannot overwrite the new one.
|
||||
if (artworkAbort) {
|
||||
try { artworkAbort.abort(); } catch { /* ignore */ }
|
||||
}
|
||||
const myGen = ++artworkFetchGen;
|
||||
artworkAbort = new AbortController();
|
||||
|
||||
if (artworkSource) {
|
||||
// No cache-buster: when album_art_url is unchanged the
|
||||
// browser can reuse the decoded bitmap. The artworkKey gate
|
||||
// already skips fetches when the user hasn't switched tracks.
|
||||
fetch('/api/media/artwork', {
|
||||
headers: getAuthHeaders()
|
||||
headers: getAuthHeaders(),
|
||||
signal: artworkAbort.signal,
|
||||
})
|
||||
.then(r => r.ok ? r.blob() : null)
|
||||
.then(blob => {
|
||||
if (!blob) return;
|
||||
if (!blob || myGen !== artworkFetchGen) return;
|
||||
const oldBlobUrl = currentArtworkBlobUrl;
|
||||
const url = URL.createObjectURL(blob);
|
||||
currentArtworkBlobUrl = url;
|
||||
swapArtworkSrc(dom.albumArt, url);
|
||||
if (dom.miniAlbumArt.src !== url) dom.miniAlbumArt.src = url;
|
||||
if (dom.albumArtGlow && dom.albumArtGlow.src !== url) dom.albumArtGlow.src = url;
|
||||
// Mirror to fullscreen bloom directly — drops the
|
||||
// MutationObserver fan-out path.
|
||||
syncFullscreenBloomArt(url);
|
||||
if (oldBlobUrl) setTimeout(() => URL.revokeObjectURL(oldBlobUrl), 1000);
|
||||
})
|
||||
.catch(err => console.error('Artwork fetch failed:', err));
|
||||
.catch(err => {
|
||||
if (err && err.name === 'AbortError') return;
|
||||
console.error('Artwork fetch failed:', err);
|
||||
});
|
||||
} else {
|
||||
if (currentArtworkBlobUrl) {
|
||||
URL.revokeObjectURL(currentArtworkBlobUrl);
|
||||
@@ -858,6 +862,9 @@ export function updateUI(status) {
|
||||
}
|
||||
|
||||
if (!isUserAdjustingVolume) {
|
||||
// Re-seed the throttling cache so a future call to setVolume() with
|
||||
// the previously-sent value still propagates after an external change.
|
||||
notifyRemoteVolume(status.volume);
|
||||
dom.volumeSlider.value = status.volume;
|
||||
dom.volumeDisplay.textContent = `${status.volume}%`;
|
||||
dom.miniVolumeSlider.value = status.volume;
|
||||
|
||||
@@ -150,7 +150,7 @@ async function executeScript(scriptName, buttonElement) {
|
||||
|
||||
async function _doExecuteScript(scriptName, params) {
|
||||
try {
|
||||
const response = await fetch(`/api/scripts/execute/${scriptName}`, {
|
||||
const response = await fetch(`/api/scripts/execute/${encodeURIComponent(scriptName)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
||||
body: JSON.stringify({ params })
|
||||
@@ -393,7 +393,7 @@ async function _loadScriptsTableImpl() {
|
||||
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>';
|
||||
tbody.innerHTML = `<tr><td colspan="5" class="empty-state" style="color: var(--error);">${escapeHtml(t('scripts.msg.load_failed'))}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -433,7 +433,7 @@ export async function showEditScriptDialog(scriptName) {
|
||||
const script = scriptsList.find(s => s.name === scriptName);
|
||||
|
||||
if (!script) {
|
||||
showToast('Script not found', 'error');
|
||||
showToast(t('scripts.msg.not_found'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -470,7 +470,7 @@ export async function showEditScriptDialog(scriptName) {
|
||||
dialog.showModal();
|
||||
} catch (error) {
|
||||
console.error('Error loading script for edit:', error);
|
||||
showToast('Failed to load script details', 'error');
|
||||
showToast(t('scripts.msg.load_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -508,9 +508,10 @@ export async function saveScript(event) {
|
||||
parameters: _collectParameterDefinitions(),
|
||||
};
|
||||
|
||||
const encodedName = encodeURIComponent(scriptName);
|
||||
const endpoint = isEdit ?
|
||||
`/api/scripts/update/${scriptName}` :
|
||||
`/api/scripts/create/${scriptName}`;
|
||||
`/api/scripts/update/${encodedName}` :
|
||||
`/api/scripts/create/${encodedName}`;
|
||||
|
||||
const method = isEdit ? 'PUT' : 'POST';
|
||||
|
||||
@@ -524,15 +525,15 @@ export async function saveScript(event) {
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
showToast(`Script ${isEdit ? 'updated' : 'created'} successfully`, 'success');
|
||||
showToast(t(isEdit ? 'scripts.msg.updated' : 'scripts.msg.created'), 'success');
|
||||
scriptFormDirty = false;
|
||||
closeScriptDialog();
|
||||
} else {
|
||||
showToast(result.detail || `Failed to ${isEdit ? 'update' : 'create'} script`, 'error');
|
||||
showToast(result.detail || t(isEdit ? 'scripts.msg.update_failed' : 'scripts.msg.create_failed'), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving script:', error);
|
||||
showToast(`Error ${isEdit ? 'updating' : 'creating'} script`, 'error');
|
||||
showToast(t(isEdit ? 'scripts.msg.update_failed' : 'scripts.msg.create_failed'), 'error');
|
||||
} finally {
|
||||
if (submitBtn) submitBtn.disabled = false;
|
||||
}
|
||||
@@ -544,7 +545,7 @@ export async function deleteScriptConfirm(scriptName) {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/scripts/delete/${scriptName}`, {
|
||||
const response = await fetch(`/api/scripts/delete/${encodeURIComponent(scriptName)}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
@@ -552,13 +553,13 @@ export async function deleteScriptConfirm(scriptName) {
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
showToast('Script deleted successfully', 'success');
|
||||
showToast(t('scripts.msg.deleted'), 'success');
|
||||
} else {
|
||||
showToast(result.detail || 'Failed to delete script', 'error');
|
||||
showToast(result.detail || t('scripts.msg.delete_failed'), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting script:', error);
|
||||
showToast('Error deleting script', 'error');
|
||||
showToast(t('scripts.msg.delete_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -581,23 +582,23 @@ function showExecutionResult(name, result, type = 'script') {
|
||||
const outputPre = document.getElementById('executionOutput');
|
||||
const errorPre = document.getElementById('executionError');
|
||||
|
||||
title.textContent = `Execution Result: ${name}`;
|
||||
title.textContent = `${t('execution.result')}: ${name}`;
|
||||
|
||||
const success = result.success && result.exit_code === 0;
|
||||
const statusClass = success ? 'success' : 'error';
|
||||
const statusText = success ? 'Success' : 'Failed';
|
||||
const statusText = t(success ? 'execution.success' : 'execution.failed');
|
||||
|
||||
statusDiv.innerHTML = `
|
||||
<div class="status-item ${statusClass}">
|
||||
<label>Status</label>
|
||||
<value>${statusText}</value>
|
||||
<label>${escapeHtml(t('execution.status'))}</label>
|
||||
<value>${escapeHtml(statusText)}</value>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<label>Exit Code</label>
|
||||
<label>${escapeHtml(t('execution.exit_code'))}</label>
|
||||
<value>${result.exit_code !== undefined ? result.exit_code : 'N/A'}</value>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<label>Duration</label>
|
||||
<label>${escapeHtml(t('execution.duration'))}</label>
|
||||
<value>${result.execution_time !== undefined && result.execution_time !== null ? result.execution_time.toFixed(3) + 's' : 'N/A'}</value>
|
||||
</div>
|
||||
`;
|
||||
@@ -606,7 +607,7 @@ function showExecutionResult(name, result, type = 'script') {
|
||||
if (result.stdout && result.stdout.trim()) {
|
||||
outputPre.textContent = result.stdout;
|
||||
} else {
|
||||
outputPre.textContent = '(no output)';
|
||||
outputPre.textContent = t('execution.no_output');
|
||||
outputPre.style.fontStyle = 'italic';
|
||||
outputPre.style.color = 'var(--text-secondary)';
|
||||
}
|
||||
@@ -642,11 +643,11 @@ async function _executeScriptDebugWithParams(scriptName, params) {
|
||||
const title = document.getElementById('executionDialogTitle');
|
||||
const statusDiv = document.getElementById('executionStatus');
|
||||
|
||||
title.textContent = `Executing: ${scriptName}`;
|
||||
title.textContent = `${t('execution.executing')}: ${scriptName}`;
|
||||
statusDiv.innerHTML = `
|
||||
<div class="status-item">
|
||||
<label>Status</label>
|
||||
<value><span class="loading-spinner"></span> Running...</value>
|
||||
<label>${escapeHtml(t('execution.status'))}</label>
|
||||
<value><span class="loading-spinner"></span> ${escapeHtml(t('execution.running'))}</value>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('outputSection').style.display = 'none';
|
||||
@@ -813,11 +814,11 @@ export async function executeCallbackDebug(callbackName) {
|
||||
const title = document.getElementById('executionDialogTitle');
|
||||
const statusDiv = document.getElementById('executionStatus');
|
||||
|
||||
title.textContent = `Executing: ${callbackName}`;
|
||||
title.textContent = `${t('execution.executing')}: ${callbackName}`;
|
||||
statusDiv.innerHTML = `
|
||||
<div class="status-item">
|
||||
<label>Status</label>
|
||||
<value><span class="loading-spinner"></span> Running...</value>
|
||||
<label>${escapeHtml(t('execution.status'))}</label>
|
||||
<value><span class="loading-spinner"></span> ${escapeHtml(t('execution.running'))}</value>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('outputSection').style.display = 'none';
|
||||
@@ -826,7 +827,7 @@ export async function executeCallbackDebug(callbackName) {
|
||||
dialog.showModal();
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/callbacks/execute/${callbackName}`, {
|
||||
const response = await fetch(`/api/callbacks/execute/${encodeURIComponent(callbackName)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// ============================================================
|
||||
|
||||
import {
|
||||
dom, t, showToast, setWs,
|
||||
dom, t, setWs, getWs,
|
||||
WS_BACKOFF_BASE_MS, WS_BACKOFF_MAX_MS,
|
||||
WS_MAX_RECONNECT_ATTEMPTS, WS_PING_INTERVAL_MS,
|
||||
authRequired, showUpdateBanner,
|
||||
@@ -12,10 +12,29 @@ import { updateUI, setFrequencyData, stopPositionInterpolation, loadAudioDevices
|
||||
import { loadScripts, loadScriptsTable, displayQuickAccess } from './scripts.js';
|
||||
import { loadCallbacksTable } from './callbacks.js';
|
||||
import { loadHeaderLinks, loadLinksTable } from './links.js';
|
||||
import { updateForegroundUI } from './foreground.js';
|
||||
|
||||
let reconnectTimeout = null;
|
||||
let pingInterval = null;
|
||||
let wsReconnectAttempts = 0;
|
||||
// Track the ping interval against the socket that owns it so we never leak
|
||||
// a timer if connectWebSocket() is called while a previous socket is still
|
||||
// alive. The pair is wiped on close to avoid double-clear races.
|
||||
let activeSocket = null;
|
||||
let activePingInterval = null;
|
||||
|
||||
function clearReconnect() {
|
||||
if (reconnectTimeout) {
|
||||
clearTimeout(reconnectTimeout);
|
||||
reconnectTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearPing() {
|
||||
if (activePingInterval) {
|
||||
clearInterval(activePingInterval);
|
||||
activePingInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function showAuthForm(errorMessage = '') {
|
||||
const overlay = document.getElementById('auth-overlay');
|
||||
@@ -47,19 +66,24 @@ export function authenticate() {
|
||||
|
||||
export function clearToken() {
|
||||
localStorage.removeItem('media_server_token');
|
||||
// Access ws via import
|
||||
import('./core.js').then(core => {
|
||||
if (core.ws) {
|
||||
core.ws.close();
|
||||
}
|
||||
});
|
||||
const current = getWs();
|
||||
if (current) {
|
||||
try { current.close(1000, 'token cleared'); } catch { /* ignore */ }
|
||||
}
|
||||
showAuthForm(t('auth.cleared'));
|
||||
}
|
||||
|
||||
export function connectWebSocket(token) {
|
||||
if (pingInterval) {
|
||||
clearInterval(pingInterval);
|
||||
pingInterval = null;
|
||||
// Always cancel a pending reconnect first — otherwise a user-triggered
|
||||
// reconnect can race a scheduled one and create two live sockets.
|
||||
clearReconnect();
|
||||
clearPing();
|
||||
|
||||
// Close any previous socket cleanly before opening a new one.
|
||||
const previous = activeSocket;
|
||||
activeSocket = null;
|
||||
if (previous && previous.readyState <= WebSocket.OPEN) {
|
||||
try { previous.close(1000, 'reconnecting'); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
@@ -67,6 +91,7 @@ export function connectWebSocket(token) {
|
||||
const wsUrl = token ? `${wsBase}?token=${encodeURIComponent(token)}` : wsBase;
|
||||
|
||||
const newWs = new WebSocket(wsUrl);
|
||||
activeSocket = newWs;
|
||||
setWs(newWs);
|
||||
|
||||
newWs.onopen = () => {
|
||||
@@ -84,10 +109,18 @@ export function connectWebSocket(token) {
|
||||
};
|
||||
|
||||
newWs.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(event.data);
|
||||
} catch (err) {
|
||||
console.warn('Ignoring malformed WebSocket frame:', err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'status' || msg.type === 'status_update') {
|
||||
updateUI(msg.data);
|
||||
} else if (msg.type === 'foreground' || msg.type === 'foreground_update') {
|
||||
updateForegroundUI(msg.data);
|
||||
} else if (msg.type === 'scripts_changed') {
|
||||
console.log('Scripts changed, reloading...');
|
||||
loadScripts();
|
||||
@@ -116,6 +149,13 @@ export function connectWebSocket(token) {
|
||||
updateConnectionStatus(false);
|
||||
stopPositionInterpolation();
|
||||
|
||||
// Drop this socket's ping interval. Guard so we don't kill a newer
|
||||
// socket's interval if reconnect already started.
|
||||
if (activeSocket === newWs) {
|
||||
clearPing();
|
||||
activeSocket = null;
|
||||
}
|
||||
|
||||
if (event.code === 4001) {
|
||||
localStorage.removeItem('media_server_token');
|
||||
showAuthForm(t('auth.invalid'));
|
||||
@@ -133,7 +173,9 @@ export function connectWebSocket(token) {
|
||||
showConnectionBanner(t('connection.reconnecting').replace('{attempt}', wsReconnectAttempts), false);
|
||||
}
|
||||
|
||||
clearReconnect();
|
||||
reconnectTimeout = setTimeout(() => {
|
||||
reconnectTimeout = null;
|
||||
const savedToken = localStorage.getItem('media_server_token');
|
||||
if (savedToken || !authRequired) {
|
||||
connectWebSocket(savedToken || '');
|
||||
@@ -145,9 +187,10 @@ export function connectWebSocket(token) {
|
||||
}
|
||||
};
|
||||
|
||||
pingInterval = setInterval(() => {
|
||||
if (newWs && newWs.readyState === WebSocket.OPEN) {
|
||||
newWs.send(JSON.stringify({ type: 'ping' }));
|
||||
clearPing();
|
||||
activePingInterval = setInterval(() => {
|
||||
if (newWs.readyState === WebSocket.OPEN) {
|
||||
try { newWs.send(JSON.stringify({ type: 'ping' })); } catch { /* ignore */ }
|
||||
}
|
||||
}, WS_PING_INTERVAL_MS);
|
||||
}
|
||||
@@ -182,3 +225,23 @@ export function manualReconnect() {
|
||||
connectWebSocket(savedToken || '');
|
||||
}
|
||||
}
|
||||
|
||||
// When the browser regains connectivity or the tab becomes visible again,
|
||||
// drop the backoff and reconnect immediately rather than waiting out the
|
||||
// current timer.
|
||||
function reconnectIfNeeded() {
|
||||
const current = activeSocket;
|
||||
if (current && (current.readyState === WebSocket.OPEN || current.readyState === WebSocket.CONNECTING)) {
|
||||
return;
|
||||
}
|
||||
const savedToken = localStorage.getItem('media_server_token');
|
||||
if (savedToken || !authRequired) {
|
||||
wsReconnectAttempts = 0;
|
||||
connectWebSocket(savedToken || '');
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('online', reconnectIfNeeded);
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden) reconnectIfNeeded();
|
||||
});
|
||||
|
||||
@@ -174,6 +174,18 @@
|
||||
"display.msg.color_failed": "Failed to apply color preset",
|
||||
"display.msg.mode_changed": "Picture mode applied",
|
||||
"display.msg.mode_failed": "Failed to apply picture mode",
|
||||
"display.msg.power_on": "Monitor turned on",
|
||||
"display.msg.power_off": "Monitor turned off",
|
||||
"display.msg.power_failed": "Failed to change monitor power",
|
||||
"execution.result": "Execution Result",
|
||||
"execution.executing": "Executing",
|
||||
"execution.status": "Status",
|
||||
"execution.exit_code": "Exit Code",
|
||||
"execution.duration": "Duration",
|
||||
"execution.success": "Success",
|
||||
"execution.failed": "Failed",
|
||||
"execution.running": "Running...",
|
||||
"execution.no_output": "(no output)",
|
||||
"browser.title": "Media Browser",
|
||||
"browser.home": "Home",
|
||||
"browser.manage_folders": "Manage Folders",
|
||||
@@ -280,5 +292,29 @@
|
||||
"about.source_code": "Source Code",
|
||||
"dialog.close": "Close",
|
||||
"update.available": "Update available: v{version}",
|
||||
"update.view_release": "View Release"
|
||||
"update.view_release": "View Release",
|
||||
"tab.foreground": "Foreground",
|
||||
"foreground.kicker": "Foreground",
|
||||
"foreground.loading": "Waiting for foreground signal…",
|
||||
"foreground.no_process": "No foreground process",
|
||||
"foreground.unavailable": "Foreground tracking unavailable on this platform",
|
||||
"foreground.process": "Process",
|
||||
"foreground.window_title": "Window title",
|
||||
"foreground.executable": "Executable",
|
||||
"foreground.pid": "PID",
|
||||
"foreground.monitor": "Monitor {n}",
|
||||
"foreground.started": "Started",
|
||||
"foreground.geometry": "Geometry",
|
||||
"foreground.platform": "Platform",
|
||||
"foreground.fullscreen": "Fullscreen",
|
||||
"foreground.minimized": "Minimized",
|
||||
"foreground.windowed": "Windowed",
|
||||
"foreground.browser": "Browser",
|
||||
"foreground.page_title": "Page title",
|
||||
"foreground.url": "URL",
|
||||
"foreground.badge.title": "View foreground process",
|
||||
"foreground.ago.seconds": "{n}s ago",
|
||||
"foreground.ago.minutes": "{n}m ago",
|
||||
"foreground.ago.hours": "{n}h {m}m ago",
|
||||
"foreground.ago.days": "{n}d ago"
|
||||
}
|
||||
|
||||
@@ -174,6 +174,18 @@
|
||||
"display.msg.color_failed": "Не удалось применить цветовую температуру",
|
||||
"display.msg.mode_changed": "Режим изображения применён",
|
||||
"display.msg.mode_failed": "Не удалось применить режим изображения",
|
||||
"display.msg.power_on": "Монитор включён",
|
||||
"display.msg.power_off": "Монитор выключен",
|
||||
"display.msg.power_failed": "Не удалось переключить питание монитора",
|
||||
"execution.result": "Результат выполнения",
|
||||
"execution.executing": "Выполняется",
|
||||
"execution.status": "Статус",
|
||||
"execution.exit_code": "Код выхода",
|
||||
"execution.duration": "Длительность",
|
||||
"execution.success": "Успешно",
|
||||
"execution.failed": "Ошибка",
|
||||
"execution.running": "Выполняется...",
|
||||
"execution.no_output": "(нет вывода)",
|
||||
"browser.title": "Медиа Браузер",
|
||||
"browser.home": "Главная",
|
||||
"browser.manage_folders": "Управление папками",
|
||||
@@ -280,5 +292,29 @@
|
||||
"about.source_code": "Исходный код",
|
||||
"dialog.close": "Закрыть",
|
||||
"update.available": "Доступно обновление: v{version}",
|
||||
"update.view_release": "Перейти к релизу"
|
||||
"update.view_release": "Перейти к релизу",
|
||||
"tab.foreground": "Активное окно",
|
||||
"foreground.kicker": "Активное окно",
|
||||
"foreground.loading": "Ожидание сигнала об активном окне…",
|
||||
"foreground.no_process": "Активное окно не определено",
|
||||
"foreground.unavailable": "Отслеживание активного окна недоступно",
|
||||
"foreground.process": "Процесс",
|
||||
"foreground.window_title": "Заголовок окна",
|
||||
"foreground.executable": "Путь к программе",
|
||||
"foreground.pid": "PID",
|
||||
"foreground.monitor": "Монитор {n}",
|
||||
"foreground.started": "Запущено",
|
||||
"foreground.geometry": "Геометрия",
|
||||
"foreground.platform": "Платформа",
|
||||
"foreground.fullscreen": "Полноэкранный",
|
||||
"foreground.minimized": "Свёрнут",
|
||||
"foreground.windowed": "Оконный",
|
||||
"foreground.browser": "Браузер",
|
||||
"foreground.page_title": "Заголовок страницы",
|
||||
"foreground.url": "URL",
|
||||
"foreground.badge.title": "Открыть активное окно",
|
||||
"foreground.ago.seconds": "{n} с назад",
|
||||
"foreground.ago.minutes": "{n} мин назад",
|
||||
"foreground.ago.hours": "{n} ч {m} мин назад",
|
||||
"foreground.ago.days": "{n} дн назад"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Minimal service worker for PWA installability.
|
||||
// Minimal service worker for PWA installability only.
|
||||
// This app requires a live WebSocket connection, so offline caching is not useful.
|
||||
// All fetch requests are passed through to the network.
|
||||
// We intentionally do NOT register a `fetch` handler — a pass-through handler
|
||||
// forces every navigation through the SW for no benefit and breaks the
|
||||
// browser's normal HTTP cache + error semantics.
|
||||
|
||||
self.addEventListener('install', () => {
|
||||
self.skipWaiting();
|
||||
@@ -9,7 +11,3 @@ self.addEventListener('install', () => {
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
event.respondWith(fetch(event.request));
|
||||
});
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "media-server-frontend",
|
||||
"version": "0.2.3",
|
||||
"version": "0.2.5",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "media-server-frontend",
|
||||
"version": "0.2.3",
|
||||
"version": "0.2.5",
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.27.4"
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "media-server-frontend",
|
||||
"version": "0.2.3",
|
||||
"version": "0.2.5",
|
||||
"private": true,
|
||||
"description": "Frontend build tooling for media server WebUI",
|
||||
"scripts": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "media-server"
|
||||
version = "0.2.3"
|
||||
version = "0.2.5"
|
||||
description = "REST API server for controlling system-wide media playback"
|
||||
readme = "README.md"
|
||||
license = { text = "MIT" }
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Smoke tests for the foreground tracker.
|
||||
|
||||
The OS-specific probe code is hard to mock end-to-end inside a CI container,
|
||||
so these tests focus on the platform-agnostic surface: the dataclass shape,
|
||||
TTL caching, and graceful fallback when the platform probe raises. The
|
||||
Windows/Linux/macOS probes themselves are exercised through manual runs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from media_server.services import foreground_service as fg
|
||||
|
||||
|
||||
def setup_function(_):
|
||||
fg.reset_cache()
|
||||
|
||||
|
||||
def test_unavailable_default_shape():
|
||||
info = fg.ForegroundInfo(available=False)
|
||||
d = info.to_dict()
|
||||
assert d["available"] is False
|
||||
assert d["pid"] is None
|
||||
assert d["process_name"] is None
|
||||
assert d["is_fullscreen"] is False
|
||||
assert "platform" in d
|
||||
|
||||
|
||||
def test_cache_returns_same_instance(monkeypatch):
|
||||
calls = {"n": 0}
|
||||
|
||||
def fake_probe():
|
||||
calls["n"] += 1
|
||||
return fg.ForegroundInfo(available=True, pid=42, process_name="x.exe")
|
||||
|
||||
monkeypatch.setattr(fg, "_probe", fake_probe)
|
||||
|
||||
a = fg.get_foreground_info()
|
||||
b = fg.get_foreground_info()
|
||||
assert a is b
|
||||
assert calls["n"] == 1
|
||||
|
||||
|
||||
def test_cache_force_refresh(monkeypatch):
|
||||
calls = {"n": 0}
|
||||
|
||||
def fake_probe():
|
||||
calls["n"] += 1
|
||||
return fg.ForegroundInfo(available=True, pid=calls["n"])
|
||||
|
||||
monkeypatch.setattr(fg, "_probe", fake_probe)
|
||||
|
||||
fg.get_foreground_info()
|
||||
fg.get_foreground_info(force_refresh=True)
|
||||
assert calls["n"] == 2
|
||||
|
||||
|
||||
def test_cache_ttl_expiry(monkeypatch):
|
||||
calls = {"n": 0}
|
||||
|
||||
def fake_probe():
|
||||
calls["n"] += 1
|
||||
return fg.ForegroundInfo(available=True, pid=calls["n"])
|
||||
|
||||
monkeypatch.setattr(fg, "_probe", fake_probe)
|
||||
monkeypatch.setattr(fg, "_CACHE_TTL", 0.0)
|
||||
# Re-bind the cache's TTL by exercising it twice with TTL 0.
|
||||
fg.get_foreground_info()
|
||||
fg.get_foreground_info()
|
||||
assert calls["n"] == 2
|
||||
|
||||
|
||||
def test_probe_crash_returns_unavailable(monkeypatch):
|
||||
def boom():
|
||||
raise RuntimeError("kaboom")
|
||||
|
||||
# Force every platform branch to call our crashing probe.
|
||||
monkeypatch.setattr(fg, "_probe_windows", boom)
|
||||
monkeypatch.setattr(fg, "_probe_linux", boom)
|
||||
monkeypatch.setattr(fg, "_probe_macos", boom)
|
||||
|
||||
info = fg._probe()
|
||||
assert info.available is False
|
||||
assert info.error and "kaboom" in info.error
|
||||
Reference in New Issue
Block a user