Compare commits

...

14 Commits

Author SHA1 Message Date
alexei.dolgolyov 450f9fe1ee chore: release v0.2.7
Lint & Test / test (push) Has been skipped
Release / create-release (push) Successful in 3s
Release / build-linux (push) Successful in 30s
Release / build-windows (push) Successful in 52s
2026-05-19 01:34:36 +03:00
alexei.dolgolyov e1c8474271 fix(csp): wire display sliders and accent picker without inline on*
Lint & Test / test (push) Successful in 10s
The display brightness/contrast sliders and the accent color picker
rendered dynamic HTML with inline oninput/onchange/onclick attributes,
which are blocked by the script-src 'self' CSP — so display settings
were silently un-clickable from the WebUI.

Replace the inline attributes with data-* markers, then attach proper
event listeners after innerHTML (delegated on the container for the
slider rows, direct for the accent dropdown).
2026-05-19 01:17:47 +03:00
alexei.dolgolyov fe82836f4d chore: release v0.2.6
Lint & Test / test (push) Has been skipped
Release / create-release (push) Successful in 3s
Release / build-linux (push) Successful in 30s
Release / build-windows (push) Successful in 55s
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-05-18 03:19:07 +03:00
alexei.dolgolyov eeab9b2a26 style: sort Xlib import in foreground_service
Resolves the ruff I001 warning introduced by 61cdce9.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-05-18 03:19:02 +03:00
alexei.dolgolyov 61cdce9b60 feat(foreground): track topmost process + browser page title
Lint & Test / test (push) Failing after 8s
Adds cross-platform foreground-window tracking and exposes it over REST
(/api/foreground) and the existing WebSocket feed.

- foreground_service.py: Windows probe via ctypes (HANDLE-correct argtypes
  to avoid 64-bit handle truncation); macOS via AppKit; Linux via Xlib
  (Wayland returns unavailable). TTL cache + per-platform fallback.
- browser_url_service.py: when foreground is a recognised browser, extract
  the page title from the window title (browser-name suffix stripped) and
  surface `is_browser` + `browser_page_title`. Optional UIA-based URL
  extraction behind MEDIA_SERVER_BROWSER_UIA env flag (off by default —
  Chromium browsers keep their accessibility tree dormant otherwise).
- websocket_manager: poll foreground every 1s inside the existing status
  loop, broadcast `foreground` on connect and `foreground_update` on
  change. Diff only on user-visible fields to avoid geometry spam.
- WebUI: new editorial card rendered under the monitor list on the
  Display tab — process name, window title, fullscreen/minimized/monitor
  chips, browser block when applicable, exe path, PID, started-ago,
  geometry, platform. 16px inter-section gap matches Settings cadence.
- i18n: 25 new keys added to both en.json and ru.json.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-05-18 03:11:59 +03:00
alexei.dolgolyov 0cf49deac0 fix(config): secure-by-default loopback bind and startup-error logging
- Default `host: 127.0.0.1` in config.example.yaml; require explicit
  api_tokens or `allow_lan_without_auth: true` before binding LAN.
- Mirror pre-uvicorn fatal errors to startup-errors.log in the config
  dir so silent boot failures via wscript/pythonw are diagnosable.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-05-18 03:11:08 +03:00
alexei.dolgolyov 527f3d0aa4 chore: release v0.2.5
Lint & Test / test (push) Has been skipped
Release / create-release (push) Successful in 4s
Release / build-linux (push) Successful in 44s
Release / build-windows (push) Successful in 1m9s
2026-05-16 20:16:45 +03:00
alexei.dolgolyov 982dda42ac fix(browser): align list columns via subgrid and fix icon sizing
Lint & Test / test (push) Successful in 10s
- Switch .browser-list to CSS grid + subgrid so header and rows share
  the same column track widths, eliminating misaligned columns when
  content widths differ between rows.
- Apply matching responsive column overrides at the parent grid level.
- Override root-folder SVG sizing (hardcoded 24x24 in browser.js) so it
  fills the 56px icon box instead of rendering at ~43%.
- Make compact grid icon fill its thumb wrapper so the emoji centers
  instead of being stranded in the top-left corner.
- Remove premature isConnected bail in loadThumbnail; the img element
  is intentionally detached when called from renderBrowserGrid/List.
  Post-await checks already handle navigation-away correctly.
2026-05-16 20:12:39 +03:00
alexei.dolgolyov eaeebb64cd fix(csp): replace inline on* handlers with data-on* + JS wiring
Lint & Test / test (push) Successful in 38s
The strict `script-src 'self'` CSP blocks inline onclick/onchange/oninput/
onsubmit attribute evaluation, breaking every button and form in the UI.

- Rename all 53 inline handler attributes in index.html to data-on*
- Add wireInlineHandlers() in app.js that parses each data-on* expression
  on DOMContentLoaded and attaches a proper addEventListener calling the
  matching window-global function. Supports no-arg, string/number/bool/null
  literals, and the `event` token.

CSP stays strict; no unsafe-inline or unsafe-hashes needed.
2026-05-16 18:35:51 +03:00
alexei.dolgolyov bcc6d40ed7 fix: comprehensive security, bug, performance, and UI/UX audit
Lint & Test / test (push) Successful in 20s
Security
- Default bind 127.0.0.1; first-run bootstrap generates random api_token
  and refuses to bind non-loopback without auth unless explicitly opted in
- Path-traversal hardened: BrowserService.validate_path rejects absolute
  paths, drive letters, UNC, NUL bytes. /api/browser/{play,metadata,
  thumbnail} now require folder_id and a folder-relative path
- Pydantic validators on links: http(s) URLs only, mdi:<slug> icons only
- Scripts/callbacks/links create/update/delete gated by *_management flags
- Strict CSP, 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
- config.yaml writes atomic (tmp + os.replace) and 0o600 on POSIX
- Subprocesses spawned in their own process group / new session so timeout
  kills the whole tree (Windows CREATE_NEW_PROCESS_GROUP, POSIX
  start_new_session=True)
- Frontend XSS: monitor name + details escapeHtml'd; power button moved to
  delegated data-action handler; remote MDI SVGs parsed and sanitized
  (strip script/foreignObject/on*/javascript: hrefs) before innerHTML
- All dynamic URL segments now wrapped in encodeURIComponent

Bugs
- WebSocket reconnect: close previous socket before opening new, clear
  ping interval per-socket, clear reconnectTimeout up-front, retry on
  online/visibilitychange, try/catch JSON.parse
- Artwork fetch race: AbortController + generation guard
- _broadcast_after_open: initialize status, swallow per-poll errors,
  background tasks tracked in a strong-ref set with done-callback cleanup
- Audio analyzer: sticky _unavailable flag prevents infinite start/stop
  spin when no loopback device exists; cleared by set_device()
- Volume short-circuit cache invalidated when server reports remote volume
- Browser thumbnail race: per-folder generation counter + isConnected
  checks; aborts in-flight fetches on navigation
- Track-skip uses cached title instead of full WinRT status round-trip

Performance
- Linux MPRIS/pactl and /api/display DDC-CI handlers wrapped in
  asyncio.to_thread so blocking IO never stalls the event loop
- browse_directory moved off the event loop (SMB shares could freeze it)
- Windows status poll caches one asyncio loop per worker thread via
  threading.local instead of new_event_loop/close on every 0.5s tick
- broadcast() serializes JSON once and uses send_text to all clients
- Hourly thumbnail cache cleanup scheduled in lifespan (was never invoked
  — cache grew unbounded)
- Progress drag listeners attached only while dragging

Quality
- All asyncio.get_event_loop() in coroutines → get_running_loop()
- ThreadPoolExecutors shut down cleanly during lifespan teardown
- config_manager dedup: 12 near-identical methods collapsed onto generic
  _upsert/_delete helpers (~290 lines removed)
- Service worker no longer pass-throughs every fetch
- M3U playlist written via NamedTemporaryFile (no fixed-path symlink
  clobber race)
- __version__ now prefers live pyproject.toml in dev checkouts so
  pip install -e . users see the source-of-truth version, not the stale
  package-metadata version baked in at install time

UI/UX (Studio Reference)
- Green leftover focus rings (rgba(29,185,84,...)) all replaced with
  copper accent (rgba(var(--copper-rgb),...))
- Dialogs: square corners, copper top hairline, unified with editorial
  chrome
- .browser-item: transparent with copper hover border (was filled card)
- Audio device select uses var(--sans) instead of generic system font
- Mobile container padding tuned for ≤480px screens
- Breadcrumb home is a real <button> with aria-label; aria-current on root
- i18n: filled display.msg.power_*, execution.*, scripts.params.execute,
  callbacks.empty in both en + ru
2026-05-16 13:22:46 +03:00
alexei.dolgolyov 770bba7e60 chore: release v0.2.4
Lint & Test / test (push) Has been skipped
Release / create-release (push) Successful in 3s
Release / build-linux (push) Successful in 23s
Release / build-windows (push) Successful in 50s
2026-05-15 14:50:28 +03:00
alexei.dolgolyov d1f621f0b4 fix(displays): verify DDC/CI writes and trust capability string for picture mode
Lint & Test / test (push) Successful in 10s
DDC/CI writes are fire-and-forget at the protocol level: a successful send
does not mean the monitor honored the value. Many monitors (LG ultrawides
in particular) silently drop writes for VCP codes whose registers exist
but whose feature isn't really implemented in firmware.

- New _verify_after_set helper polls readback after every DDC/CI write and
  reports {success: false} when the monitor didn't apply the value. Wired
  into set_contrast, set_input_source, set_color_preset, set_picture_mode.
  Input source uses a longer settle window since switching can briefly
  disrupt the DDC/CI link.

- Picture mode (VCP 0xDC) now requires the capability string to declare
  supported codes under cmds[0xDC]. Without that declaration we treat the
  feature as unsupported even when reads succeed - the LG case where reads
  return a stuck value and every write is silently ignored.
2026-05-15 14:45:40 +03:00
alexei.dolgolyov 6120625fa9 chore(scripts): harden restart-server.ps1 against installer vs dev launches
Lint & Test / test (push) Successful in 13s
The previous version only killed processes named 'media-server', which
silently missed the installer-bundled process (which runs as plain
python.exe via media-server.bat). The new version:

- Kills whatever currently owns the listen port, regardless of process name
- Supports -Mode auto|dev|installer; auto-detects based on whether the
  installer launcher exists in %LOCALAPPDATA%\Media Server
- Verifies the port is listening after start
- Merges registry PATH so newly-installed dev tools are visible

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:28:14 +03:00
alexei.dolgolyov 57fdeb70fb feat(displays): expose DDC/CI contrast, input source, color preset, picture mode
Backend (routes/display.py, services/display_service.py):
- Probe DDC/CI capabilities per monitor at enumeration time
- New endpoints POST /api/display/{contrast,input_source,color_preset,picture_mode}/{id}
- Picture mode goes through raw VCP 0xDC since monitorcontrol has no
  high-level wrapper; labels follow MCCS spec with vendor-friendly fallbacks
- Each capability reports a *_supported flag so the UI can hide rows that
  the hardware does not advertise

Frontend (links.js, app.js, styles.css, locales):
- Monitor cards grow a contrast slider (same editorial copper treatment
  as brightness) and a "PICTURE TUNING" section beneath
- Picture tuning uses the IconSelect widget (matching the audio device
  selector): per-port icons (HDMI, DisplayPort, DVI, VGA, USB-C),
  thermometer for color temps, per-mode icons (movie reel, gamepad,
  ball, etc.) for picture modes
- Humanizers turn SHOUT_CASE enum names into readable labels
  (COLOR_TEMP_6500K -> "6500 K", HDMI1 -> "HDMI 1")
- 14 new i18n keys per locale (en/ru)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:28:04 +03:00
42 changed files with 3859 additions and 1036 deletions
+3 -10
View File
@@ -1,14 +1,8 @@
## v0.2.3 (2026-05-01) ## v0.2.7 (2026-05-19)
### UI / Player
- 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))
### Bug Fixes ### 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)) - **Display tab sliders + accent picker now respond to clicks/drags:** Both the brightness and contrast sliders on the Display tab, and the accent-color picker in the header, were rendering dynamic HTML with inline `oninput` / `onchange` / `onclick` attributes — every one of which the server's strict `script-src 'self'` CSP silently dropped. The result: brightness and contrast couldn't be changed from the WebUI at all, and picking a custom accent did nothing. Replaced the inline attributes with `data-*` markers and wired proper `addEventListener` calls (delegated on the slider container, direct on the accent dropdown), so the controls work under the strict CSP without any `unsafe-inline` / `unsafe-hashes` relaxation. ([e1c8474](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/e1c8474))
--- ---
@@ -17,7 +11,6 @@
| Hash | Message | Author | | 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 | | [e1c8474](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/e1c8474) | fix(csp): wire display sliders and accent picker without inline on* | 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 |
</details> </details>
+10 -3
View File
@@ -1,7 +1,13 @@
# Media Server Configuration # Media Server Configuration
# Copy this file to config.yaml and customize as needed. # 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 # API Tokens - Multiple tokens with friendly labels
# This allows you to identify which client is making requests in the logs # This allows you to identify which client is making requests in the logs
@@ -11,8 +17,9 @@
# web_ui: "your-web-ui-token-here" # web_ui: "your-web-ui-token-here"
# Server settings # Server settings
host: "0.0.0.0" host: "127.0.0.1"
port: 8765 port: 8765
# allow_lan_without_auth: true # uncomment + change host to 0.0.0.0 for LAN-open mode
# Custom scripts # Custom scripts
scripts: scripts:
+17 -3
View File
@@ -1,18 +1,32 @@
"""Media Server - REST API for controlling system media playback.""" """Media Server - REST API for controlling system media playback."""
import re
from importlib.metadata import PackageNotFoundError, version from importlib.metadata import PackageNotFoundError, version
from pathlib import Path from pathlib import Path
_VERSION_RE = re.compile(r'^version\s*=\s*"([^"]+)"', re.MULTILINE)
def _detect_version() -> str: 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: try:
return version("media-server") return version("media-server")
except PackageNotFoundError: except PackageNotFoundError:
pass pass
# 2. VERSION file written by build scripts (production builds) # 3. VERSION file written by build scripts (production builds).
# Located at install root, two levels up from this package
version_file = Path(__file__).resolve().parent.parent.parent / "VERSION" version_file = Path(__file__).resolve().parent.parent.parent / "VERSION"
if version_file.is_file(): if version_file.is_file():
return version_file.read_text().strip() return version_file.read_text().strip()
+69 -22
View File
@@ -1,6 +1,8 @@
"""Configuration management for the media server.""" """Configuration management for the media server."""
import logging
import os import os
import secrets
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -8,6 +10,8 @@ import yaml
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
logger = logging.getLogger(__name__)
class MediaFolderConfig(BaseModel): class MediaFolderConfig(BaseModel):
"""Configuration for a media folder.""" """Configuration for a media folder."""
@@ -81,8 +85,35 @@ class Settings(BaseSettings):
) )
# Server settings # 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") 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) # Authentication (empty = auth disabled, anyone can access the API)
api_tokens: dict[str, str] = Field( api_tokens: dict[str, str] = Field(
@@ -218,21 +249,25 @@ def get_config_dir() -> Path:
def generate_default_config(path: Optional[Path] = None) -> 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: if path is None:
path = get_config_dir() / "config.yaml" path = get_config_dir() / "config.yaml"
default_token = secrets.token_urlsafe(32)
config = { config = {
"host": "0.0.0.0", "host": "127.0.0.1",
"port": 8765, "port": 8765,
# "api_tokens": { "api_tokens": {
# "default": "your-secret-token-here", "default": default_token,
# }, },
"poll_interval": 1.0, "poll_interval": 1.0,
"log_level": "INFO", "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": { "scripts": {
"example_script": { "example_script": {
"command": "echo Hello from Media Server!", "command": "echo Hello from Media Server!",
@@ -240,26 +275,38 @@ def generate_default_config(path: Optional[Path] = None) -> Path:
"timeout": 10, "timeout": 10,
"shell": True, "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) path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as f: _write_yaml_atomic(path, config)
yaml.dump(config, f, default_flow_style=False, sort_keys=False) _restrict_config_perms(path)
logger.info("Generated default config at %s", path)
logger.info("API token (label=default): %s", default_token)
return path 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 # Global settings instance
settings = Settings.load_from_yaml() settings = Settings.load_from_yaml()
+147 -402
View File
@@ -1,52 +1,50 @@
"""Thread-safe configuration file manager for runtime script updates.""" """Thread-safe configuration file manager for runtime updates."""
import logging import logging
import os import os
import threading import threading
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Any, Optional
import yaml 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__) logger = logging.getLogger(__name__)
class ConfigManager: 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): 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._lock = threading.Lock()
self._config_path = config_path or self._find_config_path() self._config_path = config_path or self._find_config_path()
logger.info(f"ConfigManager initialized with path: {self._config_path}") logger.info(f"ConfigManager initialized with path: {self._config_path}")
def _find_config_path(self) -> Path: @staticmethod
"""Find the active config file path. 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: if os.name == "nt":
Path to the config file.
Raises:
FileNotFoundError: If no config file is found.
"""
# Same search logic as Settings.load_from_yaml()
search_paths = [
Path("config.yaml"),
Path("config.yml"),
]
# Add platform-specific config directory
if os.name == "nt": # Windows
appdata = os.environ.get("APPDATA", "") appdata = os.environ.get("APPDATA", "")
if appdata: if appdata:
search_paths.append(Path(appdata) / "media-server" / "config.yaml") 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.home() / ".config" / "media-server" / "config.yaml")
search_paths.append(Path("/etc/media-server/config.yaml")) search_paths.append(Path("/etc/media-server/config.yaml"))
@@ -54,7 +52,6 @@ class ConfigManager:
if search_path.exists(): if search_path.exists():
return search_path return search_path
# If not found, use the default location
if os.name == "nt": if os.name == "nt":
default_path = Path(os.environ.get("APPDATA", "")) / "media-server" / "config.yaml" default_path = Path(os.environ.get("APPDATA", "")) / "media-server" / "config.yaml"
else: else:
@@ -63,422 +60,170 @@ class ConfigManager:
logger.warning(f"No config file found, using default path: {default_path}") logger.warning(f"No config file found, using default path: {default_path}")
return default_path return default_path
def add_script(self, name: str, config: ScriptConfig) -> None: def _load(self) -> dict[str, Any]:
"""Add a new script to config. """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: def _save(self, data: dict[str, Any]) -> None:
name: Script name (must be unique). """Atomically write the config YAML and lock down its permissions."""
config: Script configuration. self._config_path.parent.mkdir(parents=True, exist_ok=True)
_write_yaml_atomic(self._config_path, data)
_restrict_config_perms(self._config_path)
Raises: # --- Generic per-section CRUD --------------------------------------
ValueError: If script already exists.
IOError: If config file cannot be written. 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: with self._lock:
# Read YAML data = self._load()
if not self._config_path.exists(): existing = data.get(section, {})
data = {} if require_absent and key in existing:
else: raise ValueError(f"{section[:-1].title()} '{key}' already exists")
with open(self._config_path, "r", encoding="utf-8") as f: if require_present and (not isinstance(existing, dict) or key not in existing):
data = yaml.safe_load(f) or {} raise ValueError(f"{section[:-1].title()} '{key}' does not exist")
# Check if script already exists if not isinstance(existing, dict):
if "scripts" in data and name in data["scripts"]: existing = {}
raise ValueError(f"Script '{name}' already exists") existing[key] = value.model_dump(exclude_none=True)
data[section] = existing
# Add script self._save(data)
if "scripts" not in data:
data["scripts"] = {}
data["scripts"][name] = config.model_dump(exclude_none=True)
# Write YAML if in_memory_target is not None:
self._config_path.parent.mkdir(parents=True, exist_ok=True) in_memory_target[key] = value
with open(self._config_path, "w", encoding="utf-8") as f: logger.info(f"{section[:-1].title()} '{key}' {verb} in config")
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
# Update in-memory settings def _delete(
settings.scripts[name] = config 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: def update_script(self, name: str, config: ScriptConfig) -> None:
"""Update an existing script. self._upsert(
"scripts", name, config,
Args: require_present=True,
name: Script name. in_memory_target=settings.scripts,
config: New script configuration. verb="updated",
)
Raises:
ValueError: If script does not exist.
IOError: If config file cannot be written.
"""
with self._lock:
# Read YAML
if not self._config_path.exists():
raise ValueError(f"Config file not found: {self._config_path}")
with open(self._config_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
# Check if script exists
if "scripts" not in data or name not in data["scripts"]:
raise ValueError(f"Script '{name}' does not exist")
# Update script
data["scripts"][name] = config.model_dump(exclude_none=True)
# Write YAML
with open(self._config_path, "w", encoding="utf-8") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
# Update in-memory settings
settings.scripts[name] = config
logger.info(f"Script '{name}' updated in config")
def delete_script(self, name: str) -> None: def delete_script(self, name: str) -> None:
"""Delete a script from config. self._delete("scripts", name, in_memory_target=settings.scripts)
Args: # --- Callbacks -----------------------------------------------------
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")
def add_callback(self, name: str, config: CallbackConfig) -> None: def add_callback(self, name: str, config: CallbackConfig) -> None:
"""Add a new callback to config. self._upsert(
"callbacks", name, config,
Args: require_absent=True,
name: Callback name (must be unique). in_memory_target=settings.callbacks,
config: Callback configuration. verb="added",
)
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")
def update_callback(self, name: str, config: CallbackConfig) -> None: def update_callback(self, name: str, config: CallbackConfig) -> None:
"""Update an existing callback. self._upsert(
"callbacks", name, config,
Args: require_present=True,
name: Callback name. in_memory_target=settings.callbacks,
config: New callback configuration. verb="updated",
)
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")
def delete_callback(self, name: str) -> None: def delete_callback(self, name: str) -> None:
"""Delete a callback from config. self._delete("callbacks", name, in_memory_target=settings.callbacks)
Args: # --- Media folders -------------------------------------------------
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")
def add_media_folder(self, folder_id: str, config: MediaFolderConfig) -> None: def add_media_folder(self, folder_id: str, config: MediaFolderConfig) -> None:
"""Add a new media folder to config. self._upsert(
"media_folders", folder_id, config,
Args: require_absent=True,
folder_id: Folder ID (must be unique). in_memory_target=settings.media_folders,
config: Media folder configuration. verb="added",
)
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")
def update_media_folder(self, folder_id: str, config: MediaFolderConfig) -> None: def update_media_folder(self, folder_id: str, config: MediaFolderConfig) -> None:
"""Update an existing media folder. self._upsert(
"media_folders", folder_id, config,
Args: require_present=True,
folder_id: Folder ID. in_memory_target=settings.media_folders,
config: New media folder configuration. verb="updated",
)
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")
def delete_media_folder(self, folder_id: str) -> None: 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: # --- Links ---------------------------------------------------------
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")
def add_link(self, name: str, config: LinkConfig) -> None: def add_link(self, name: str, config: LinkConfig) -> None:
"""Add a new link to config.""" self._upsert(
with self._lock: "links", name, config,
if not self._config_path.exists(): require_absent=True,
data = {} in_memory_target=settings.links,
else: verb="added",
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")
def update_link(self, name: str, config: LinkConfig) -> None: def update_link(self, name: str, config: LinkConfig) -> None:
"""Update an existing link.""" self._upsert(
with self._lock: "links", name, config,
if not self._config_path.exists(): require_present=True,
raise ValueError(f"Config file not found: {self._config_path}") in_memory_target=settings.links,
verb="updated",
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")
def delete_link(self, name: str) -> None: 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: with self._lock:
if not self._config_path.exists(): data = self._load()
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 {}
if value is None: if value is None:
data.pop(key, None) data.pop(key, None)
else: else:
data[key] = value data[key] = value
self._save(data)
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
if hasattr(settings, key): if hasattr(settings, key):
setattr(settings, key, value) setattr(settings, key, value)
logger.info("Setting '%s' updated to: %s", key, value) logger.info("Setting '%s' updated to: %s", key, value)
# Global config manager instance
config_manager = ConfigManager() config_manager = ConfigManager()
+110 -13
View File
@@ -22,6 +22,7 @@ from .routes import (
browser_router, browser_router,
callbacks_router, callbacks_router,
display_router, display_router,
foreground_router,
health_router, health_router,
links_router, links_router,
media_router, media_router,
@@ -63,10 +64,10 @@ async def lifespan(app: FastAPI):
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.info(f"Media Server starting on {settings.host}:{settings.port}") 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: if settings.api_tokens:
for label, token in settings.api_tokens.items(): labels = ", ".join(settings.api_tokens.keys())
logger.info(f"API Token [{label}]: {token[:8]}...") logger.info(f"Authentication enabled. Tokens configured: [{labels}]")
else: else:
logger.warning("No API tokens configured — authentication is DISABLED") 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 # Store globally so health endpoint can access cached result
app.state.update_checker = update_checker 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) # Register audio visualizer (capture starts on-demand when clients subscribe)
analyzer = None analyzer = None
if settings.visualizer_enabled: if settings.visualizer_enabled:
@@ -109,6 +128,13 @@ async def lifespan(app: FastAPI):
if update_checker is not None: if update_checker is not None:
await update_checker.stop() await update_checker.stop()
# Cancel periodic thumbnail cleanup
cleanup_task.cancel()
try:
await cleanup_task
except asyncio.CancelledError:
pass
# Stop audio visualizer # Stop audio visualizer
await ws_manager.stop_audio_monitor() await ws_manager.stop_audio_monitor()
if analyzer and analyzer.running: if analyzer and analyzer.running:
@@ -117,6 +143,13 @@ async def lifespan(app: FastAPI):
# Stop WebSocket status monitor # Stop WebSocket status monitor
await ws_manager.stop_status_monitor() await ws_manager.stop_status_monitor()
# 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 # Clean up platform-specific resources
import platform as _platform import platform as _platform
if _platform.system() == "Windows": if _platform.system() == "Windows":
@@ -138,16 +171,43 @@ def create_app() -> FastAPI:
# Compress responses > 1KB # Compress responses > 1KB
app.add_middleware(GZipMiddleware, minimum_size=1000) app.add_middleware(GZipMiddleware, minimum_size=1000)
# Add CORS middleware for cross-origin requests # CORS — restrict to same-origin by default; users that integrate the API
# Token auth is via Authorization header, not cookies, so credentials are not needed # 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( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=cors_origins,
allow_credentials=False, allow_credentials=False,
allow_methods=["*"], allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["*"], 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 # Add token logging middleware
@app.middleware("http") @app.middleware("http")
async def token_logging_middleware(request: Request, call_next): 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(browser_router)
app.include_router(callbacks_router) app.include_router(callbacks_router)
app.include_router(display_router) app.include_router(display_router)
app.include_router(foreground_router)
app.include_router(health_router) app.include_router(health_router)
app.include_router(links_router) app.include_router(links_router)
app.include_router(media_router) app.include_router(media_router)
@@ -247,7 +308,8 @@ def main():
if args.generate_config: if args.generate_config:
config_path = generate_default_config() config_path = generate_default_config()
print(f"Configuration file generated at: {config_path}") 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 return
if args.show_token: if args.show_token:
@@ -260,18 +322,53 @@ def main():
print("\nAuthentication is DISABLED (no tokens configured)") print("\nAuthentication is DISABLED (no tokens configured)")
return 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 # Check if port is available before starting
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
try: try:
sock.bind((args.host if args.host != "0.0.0.0" else "127.0.0.1", args.port)) sock.bind((args.host if args.host != "0.0.0.0" else "127.0.0.1", args.port))
except OSError: except OSError:
print( _fatal(
f"ERROR: Port {args.port} is already in use. " f"ERROR: Port {args.port} is already in use. "
f"Another instance of Media Server may be running.\n" f"Another instance of Media Server may be running.\n"
f"Stop the other process or use --port to pick a different port.", f"Stop the other process or use --port to pick a different port."
file=sys.stderr,
) )
sys.exit(1)
from .tray import PYSTRAY_AVAILABLE, TrayManager from .tray import PYSTRAY_AVAILABLE, TrayManager
+2
View File
@@ -4,6 +4,7 @@ from .audio import router as audio_router
from .browser import router as browser_router from .browser import router as browser_router
from .callbacks import router as callbacks_router from .callbacks import router as callbacks_router
from .display import router as display_router from .display import router as display_router
from .foreground import router as foreground_router
from .health import router as health_router from .health import router as health_router
from .links import router as links_router from .links import router as links_router
from .media import router as media_router from .media import router as media_router
@@ -14,6 +15,7 @@ __all__ = [
"browser_router", "browser_router",
"callbacks_router", "callbacks_router",
"display_router", "display_router",
"foreground_router",
"health_router", "health_router",
"links_router", "links_router",
"media_router", "media_router",
+100 -80
View File
@@ -23,6 +23,17 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/browser", tags=["browser"]) 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: def _require_folder_management() -> None:
"""Raise 403 if media folder management is disabled in config.""" """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. Fires as a background task so the HTTP response returns immediately.
""" """
status = None
try: try:
interval = 0.3 interval = 0.3
elapsed = 0.0 elapsed = 0.0
while elapsed < max_wait: while elapsed < max_wait:
await asyncio.sleep(interval) await asyncio.sleep(interval)
elapsed += 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"): if status.state in ("playing", "paused"):
break break
if status is None:
return
status_dict = status.model_dump() status_dict = status.model_dump()
await ws_manager.broadcast({"type": "status", "data": status_dict}) await ws_manager.broadcast({"type": "status", "data": status_dict})
logger.info(f"Broadcasted status update after opening: {label}") logger.info(f"Broadcasted status update after opening: {label}")
@@ -74,9 +92,14 @@ class FolderUpdateRequest(BaseModel):
class PlayRequest(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): class PlayFolderRequest(BaseModel):
@@ -128,8 +151,10 @@ async def create_folder(
""" """
_require_folder_management() _require_folder_management()
try: try:
# Validate folder_id format (alphanumeric and underscore only) # Validate folder_id format (alphanumeric and underscore only).
if not request.folder_id.replace("_", "").isalnum(): # 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( raise HTTPException(
status_code=400, status_code=400,
detail="Folder ID must contain only alphanumeric characters and underscores", detail="Folder ID must contain only alphanumeric characters and underscores",
@@ -277,13 +302,15 @@ async def browse(
# URL decode the path # URL decode the path
decoded_path = unquote(path) decoded_path = unquote(path)
# Browse directory # Browse directory in a thread — iterdir() + stat() can block on
result = BrowserService.browse_directory( # network shares for many seconds; never run on the event loop.
folder_id=folder_id, result = await asyncio.to_thread(
path=decoded_path, BrowserService.browse_directory,
offset=offset, folder_id,
limit=limit, decoded_path,
nocache=nocache, offset,
limit,
nocache,
) )
return result return result
@@ -307,41 +334,40 @@ async def browse(
# Metadata Endpoint # Metadata Endpoint
@router.get("/metadata") @router.get("/metadata")
async def 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), _: str = Depends(verify_token),
): ):
"""Get metadata for a media file. """Get metadata for a media file inside a configured media folder.
Args: 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: Returns:
Media file metadata. Media file metadata.
Raises:
HTTPException: If file not found or metadata extraction fails.
""" """
try: try:
# URL decode the path
decoded_path = unquote(path) decoded_path = unquote(path)
file_path = Path(decoded_path) file_path = BrowserService.validate_path(folder_id, decoded_path)
if not file_path.exists():
raise HTTPException(status_code=404, detail="File not found")
if not file_path.is_file(): if not file_path.is_file():
raise HTTPException(status_code=400, detail="Path is not a 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_running_loop()
loop = asyncio.get_event_loop()
metadata = await loop.run_in_executor( metadata = await loop.run_in_executor(
None, None,
MetadataService.extract_metadata, MetadataService.extract_metadata,
file_path, file_path,
) )
return metadata 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: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@@ -352,59 +378,47 @@ async def get_metadata(
# Thumbnail Endpoint # Thumbnail Endpoint
@router.get("/thumbnail") @router.get("/thumbnail")
async def 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"'), size: str = Query(default="medium", description='Thumbnail size: "small" or "medium"'),
_: str = Depends(verify_token), _: str = Depends(verify_token),
): ):
"""Get thumbnail for a media file. """Get thumbnail for a media file inside a configured media folder."""
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.
"""
try: try:
# URL decode the path
decoded_path = unquote(path) decoded_path = unquote(path)
file_path = Path(decoded_path) file_path = BrowserService.validate_path(folder_id, decoded_path)
if not file_path.exists():
raise HTTPException(status_code=404, detail="File not found")
if not file_path.is_file(): if not file_path.is_file():
raise HTTPException(status_code=400, detail="Path is not a 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"): if size not in ("small", "medium"):
size = "medium" size = "medium"
# Get thumbnail
thumbnail_data = await ThumbnailService.get_thumbnail(file_path, size) thumbnail_data = await ThumbnailService.get_thumbnail(file_path, size)
if thumbnail_data is None: if thumbnail_data is None:
return Response(status_code=204) return Response(status_code=204)
# Calculate ETag (hash of path + mtime)
import hashlib import hashlib
stat = file_path.stat() stat = file_path.stat()
etag_data = f"{file_path}:{stat.st_mtime}:{size}".encode() etag_data = f"{file_path}:{stat.st_mtime}:{size}".encode()
etag = hashlib.md5(etag_data).hexdigest() etag = hashlib.md5(etag_data).hexdigest()
# Return image with caching headers
return Response( return Response(
content=thumbnail_data, content=thumbnail_data,
media_type="image/jpeg", media_type="image/jpeg",
headers={ headers={
"ETag": f'"{etag}"', "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: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@@ -420,44 +434,37 @@ async def play_file(
): ):
"""Open a media file with the default system player. """Open a media file with the default system player.
Args: Requires both ``folder_id`` and a folder-relative ``path``; the resolved
request: Play request with file path. file must live inside the configured media folder and be a recognized
media file. This prevents arbitrary OS-handler invocation (e.g.,
Returns: ``os.startfile`` on Windows ``.lnk``/UNC paths).
Success message.
Raises:
HTTPException: If file not found or playback fails.
""" """
try: try:
file_path = Path(request.path) decoded_path = unquote(request.path)
file_path = BrowserService.validate_path(request.folder_id, decoded_path)
# Validate file exists
if not file_path.exists():
raise HTTPException(status_code=404, detail="File not found")
if not file_path.is_file(): if not file_path.is_file():
raise HTTPException(status_code=400, detail="Path is not a 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): if not BrowserService.is_media_file(file_path):
raise HTTPException(status_code=400, detail="File is not a media file") raise HTTPException(status_code=400, detail="File is not a media file")
# Get media controller and open file
controller = get_media_controller() controller = get_media_controller()
success = await controller.open_file(str(file_path)) success = await controller.open_file(str(file_path))
if not success: if not success:
raise HTTPException(status_code=500, detail="Failed to open file") raise HTTPException(status_code=500, detail="Failed to open file")
# Poll until player registers with media session API (up to 2s) _spawn_background(_broadcast_after_open(controller, file_path.name))
asyncio.create_task(_broadcast_after_open(controller, file_path.name))
return { return {
"success": True, "success": True,
"message": f"Playing {file_path.name}", "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: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@@ -489,26 +496,38 @@ async def play_folder(
if not full_path.is_dir(): if not full_path.is_dir():
raise HTTPException(status_code=400, detail="Path is not a directory") raise HTTPException(status_code=400, detail="Path is not a directory")
# Collect all media files sorted by name def _scan(directory: Path) -> list[Path]:
media_files = sorted( return sorted(
[f for f in full_path.iterdir() if f.is_file() and BrowserService.is_media_file(f)], (
key=lambda f: f.name.lower(), 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: if not media_files:
raise HTTPException(status_code=404, detail="No media files found in this folder") raise HTTPException(status_code=404, detail="No media files found in this folder")
# Generate M3U playlist with absolute paths and EXTINF entries # Generate M3U playlist with absolute paths and EXTINF entries.
# Written to local temp dir to avoid extra SMB file handle on network shares # Use NamedTemporaryFile to get a fresh per-call path — prevents
# Uses utf-8-sig (BOM) so players detect encoding properly # symlink-clobber races between concurrent /play-folder requests
# and any local user pre-creating a fixed temp filename.
lines = ["#EXTM3U"] lines = ["#EXTM3U"]
for f in media_files: for f in media_files:
lines.append(f"#EXTINF:-1,{f.stem}") lines.append(f"#EXTINF:-1,{f.stem}")
lines.append(str(f)) 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" with tempfile.NamedTemporaryFile(
playlist_path.write_text(m3u_content, encoding="utf-8-sig") 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 # Open playlist with default player
controller = get_media_controller() controller = get_media_controller()
@@ -517,8 +536,9 @@ async def play_folder(
if not success: if not success:
raise HTTPException(status_code=500, detail="Failed to open playlist") raise HTTPException(status_code=500, detail="Failed to open playlist")
# Poll until player registers with media session API (up to 2s) _spawn_background(
asyncio.create_task(_broadcast_after_open(controller, f"playlist ({len(media_files)} files)")) _broadcast_after_open(controller, f"playlist ({len(media_files)} files)")
)
return { return {
"success": True, "success": True,
+27 -4
View File
@@ -3,6 +3,7 @@
import asyncio import asyncio
import logging import logging
import subprocess import subprocess
import sys
import time import time
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from typing import Any from typing import Any
@@ -21,6 +22,22 @@ logger = logging.getLogger(__name__)
_callback_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="callback") _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): class CallbackInfo(BaseModel):
"""Information about a configured callback.""" """Information about a configured callback."""
@@ -131,7 +148,7 @@ async def execute_callback(
try: try:
# Execute in dedicated thread pool to not block the default executor # 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( result = await loop.run_in_executor(
_callback_executor, _callback_executor,
lambda: _run_callback( lambda: _run_callback(
@@ -178,6 +195,11 @@ def _run_callback(
Dict with exit_code, stdout, stderr, execution_time Dict with exit_code, stdout, stderr, execution_time
""" """
start_time = time.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: try:
result = subprocess.run( result = subprocess.run(
command, command,
@@ -186,6 +208,7 @@ def _run_callback(
capture_output=True, capture_output=True,
text=True, text=True,
timeout=timeout, timeout=timeout,
**popen_kwargs,
) )
execution_time = time.time() - start_time execution_time = time.time() - start_time
return { return {
@@ -230,7 +253,7 @@ async def create_callback(
Raises: Raises:
HTTPException: If callback already exists or name is invalid. HTTPException: If callback already exists or name is invalid.
""" """
# Validate name _require_callbacks_management()
_validate_callback_name(callback_name) _validate_callback_name(callback_name)
# Check if callback already exists # Check if callback already exists
@@ -278,7 +301,7 @@ async def update_callback(
Raises: Raises:
HTTPException: If callback does not exist. HTTPException: If callback does not exist.
""" """
# Validate name _require_callbacks_management()
_validate_callback_name(callback_name) _validate_callback_name(callback_name)
# Check if callback exists # Check if callback exists
@@ -324,7 +347,7 @@ async def delete_callback(
Raises: Raises:
HTTPException: If callback does not exist. HTTPException: If callback does not exist.
""" """
# Validate name _require_callbacks_management()
_validate_callback_name(callback_name) _validate_callback_name(callback_name)
# Check if callback exists # Check if callback exists
+80 -6
View File
@@ -1,5 +1,6 @@
"""Display brightness and power control API endpoints.""" """Display brightness, power, contrast, input-source, color-preset and picture-mode API."""
import asyncio
import logging import logging
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
@@ -9,6 +10,10 @@ from ..auth import verify_token
from ..services.display_service import ( from ..services.display_service import (
list_monitors, list_monitors,
set_brightness, set_brightness,
set_color_preset,
set_contrast,
set_input_source,
set_picture_mode,
set_power, set_power,
) )
@@ -25,12 +30,37 @@ class PowerRequest(BaseModel):
on: bool on: bool
class ContrastRequest(BaseModel):
contrast: int = Field(ge=0, le=100)
class InputSourceRequest(BaseModel):
source: str
class ColorPresetRequest(BaseModel):
preset: str
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") @router.get("/monitors")
async def get_monitors( async def get_monitors(
refresh: bool = False, _: str = Depends(verify_token) refresh: bool = False,
rediscover: bool = False,
_: str = Depends(verify_token),
) -> list[dict]: ) -> list[dict]:
"""List all connected monitors with brightness and power info.""" """List all connected monitors with their reported DDC/CI capabilities."""
monitors = list_monitors(force_refresh=refresh) monitors = await asyncio.to_thread(
list_monitors, force_refresh=refresh, rediscover=rediscover
)
logger.debug("Found %d monitors", len(monitors)) logger.debug("Found %d monitors", len(monitors))
return [m.to_dict() for m in monitors] return [m.to_dict() for m in monitors]
@@ -40,7 +70,7 @@ async def set_monitor_brightness(
monitor_id: int, request: BrightnessRequest, _: str = Depends(verify_token) monitor_id: int, request: BrightnessRequest, _: str = Depends(verify_token)
) -> dict: ) -> dict:
"""Set brightness for a specific monitor.""" """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: if success:
logger.info("Set monitor %d brightness to %d", monitor_id, request.brightness) logger.info("Set monitor %d brightness to %d", monitor_id, request.brightness)
return {"success": success} return {"success": success}
@@ -52,7 +82,51 @@ async def set_monitor_power(
) -> dict: ) -> dict:
"""Turn a monitor on or off.""" """Turn a monitor on or off."""
action = "on" if request.on else "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: if success:
logger.info("Set monitor %d power %s", monitor_id, action) logger.info("Set monitor %d power %s", monitor_id, action)
return {"success": success} return {"success": success}
@router.post("/contrast/{monitor_id}")
async def set_monitor_contrast(
monitor_id: int, request: ContrastRequest, _: str = Depends(verify_token)
) -> dict:
"""Set DDC/CI contrast for a specific monitor."""
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}
@router.post("/input_source/{monitor_id}")
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 = 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}
@router.post("/color_preset/{monitor_id}")
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 = 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}
@router.post("/picture_mode/{monitor_id}")
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 = 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}
+26
View File
@@ -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()
+49 -13
View File
@@ -3,9 +3,10 @@
import logging import logging
import re import re
from typing import Any from typing import Any
from urllib.parse import urlparse
from fastapi import APIRouter, Depends, HTTPException, status 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 ..auth import verify_token
from ..config import LinkConfig, settings from ..config import LinkConfig, settings
@@ -15,6 +16,35 @@ from ..services.websocket_manager import ws_manager
router = APIRouter(prefix="/api/links", tags=["links"]) router = APIRouter(prefix="/api/links", tags=["links"])
logger = logging.getLogger(__name__) 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): class LinkInfo(BaseModel):
"""Information about a configured link.""" """Information about a configured link."""
@@ -29,22 +59,25 @@ class LinkInfo(BaseModel):
class LinkCreateRequest(BaseModel): class LinkCreateRequest(BaseModel):
"""Request model for creating or updating a link.""" """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')") icon: str = Field(default="mdi:link", description="MDI icon name (e.g., 'mdi:led-strip-variant')")
label: str = Field(default="", description="Tooltip text") label: str = Field(default="", description="Tooltip text", max_length=128)
description: str = Field(default="", description="Optional description") 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: def _validate_link_name(name: str) -> None:
"""Validate link name. """Validate link name."""
if not re.match(r"^[a-zA-Z0-9_]+$", name):
Args:
name: Link name to validate.
Raises:
HTTPException: If name is invalid.
"""
if not re.match(r'^[a-zA-Z0-9_]+$', name):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Link name must contain only letters, numbers, and underscores", detail="Link name must contain only letters, numbers, and underscores",
@@ -90,6 +123,7 @@ async def create_link(
Returns: Returns:
Success response with link name. Success response with link name.
""" """
_require_links_management()
_validate_link_name(link_name) _validate_link_name(link_name)
if link_name in settings.links: if link_name in settings.links:
@@ -129,6 +163,7 @@ async def update_link(
Returns: Returns:
Success response with link name. Success response with link name.
""" """
_require_links_management()
_validate_link_name(link_name) _validate_link_name(link_name)
if link_name not in settings.links: if link_name not in settings.links:
@@ -166,6 +201,7 @@ async def delete_link(
Returns: Returns:
Success response with link name. Success response with link name.
""" """
_require_links_management()
_validate_link_name(link_name) _validate_link_name(link_name)
if link_name not in settings.links: if link_name not in settings.links:
+2 -2
View File
@@ -27,7 +27,7 @@ def _run_callback(callback_name: str) -> None:
try: try:
callback = settings.callbacks[callback_name] callback = settings.callbacks[callback_name]
loop = asyncio.get_event_loop() loop = asyncio.get_running_loop()
result = await loop.run_in_executor( result = await loop.run_in_executor(
None, None,
lambda: _run_script( 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.""" """List available loopback audio devices for the visualizer."""
from ..services.audio_analyzer import AudioAnalyzer 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) return await loop.run_in_executor(None, AudioAnalyzer.list_loopback_devices)
+32 -5
View File
@@ -2,8 +2,10 @@
import asyncio import asyncio
import logging import logging
import os
import re import re
import subprocess import subprocess
import sys
import time import time
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from typing import Any from typing import Any
@@ -23,6 +25,22 @@ _script_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="script"
logger = logging.getLogger(__name__) 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): class ScriptExecuteRequest(BaseModel):
"""Request model for script execution with optional parameters.""" """Request model for script execution with optional parameters."""
@@ -233,7 +251,7 @@ async def execute_script(
try: try:
# Execute in dedicated thread pool to not block the default executor # 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( result = await loop.run_in_executor(
_script_executor, _script_executor,
lambda: _run_script( lambda: _run_script(
@@ -285,8 +303,16 @@ def _run_script(
start_time = time.time() start_time = time.time()
env = None env = None
if extra_env: if extra_env:
import os
env = {**os.environ, **extra_env} 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: try:
result = subprocess.run( result = subprocess.run(
command, command,
@@ -296,6 +322,7 @@ def _run_script(
text=True, text=True,
timeout=timeout, timeout=timeout,
env=env, env=env,
**popen_kwargs,
) )
execution_time = time.time() - start_time execution_time = time.time() - start_time
return { return {
@@ -455,7 +482,7 @@ async def create_script(
Raises: Raises:
HTTPException: If script already exists or name is invalid. HTTPException: If script already exists or name is invalid.
""" """
# Validate name _require_scripts_management()
_validate_script_name(script_name) _validate_script_name(script_name)
# Check if script already exists # Check if script already exists
@@ -511,7 +538,7 @@ async def update_script(
Raises: Raises:
HTTPException: If script does not exist. HTTPException: If script does not exist.
""" """
# Validate name _require_scripts_management()
_validate_script_name(script_name) _validate_script_name(script_name)
# Check if script exists # Check if script exists
@@ -565,7 +592,7 @@ async def delete_script(
Raises: Raises:
HTTPException: If script does not exist. HTTPException: If script does not exist.
""" """
# Validate name _require_scripts_management()
_validate_script_name(script_name) _validate_script_name(script_name)
# Check if script exists # Check if script exists
+13
View File
@@ -72,6 +72,11 @@ class AudioAnalyzer:
self._lifecycle_lock = threading.Lock() self._lifecycle_lock = threading.Lock()
self._data: dict | None = None self._data: dict | None = None
self._current_device_name: str | None = None self._current_device_name: str | None = None
# 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. # Generation counter — bumped each time _data is refreshed.
# Lets the broadcast loop dedupe without comparing dict identity # Lets the broadcast loop dedupe without comparing dict identity
# (which is fragile because we always allocate a new dict). # (which is fragile because we always allocate a new dict).
@@ -123,6 +128,10 @@ class AudioAnalyzer:
return True return True
if not self.available: if not self.available:
return False 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 # Reset AGC envelope so a long silent gap between sessions
# doesn't make the first new transients clip at the ceiling. # doesn't make the first new transients clip at the ceiling.
@@ -235,6 +244,9 @@ class AudioAnalyzer:
self.device_name = device_name self.device_name = device_name
self._current_device_name = None 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: if was_running:
return self.start() return self.start()
@@ -269,6 +281,7 @@ class AudioAnalyzer:
if device is None: if device is None:
logger.warning("No loopback audio device found - visualizer disabled") logger.warning("No loopback audio device found - visualizer disabled")
self._running = False self._running = False
self._unavailable = True
return return
interval = 1.0 / self.target_fps interval = 1.0 / self.target_fps
+20 -6
View File
@@ -63,14 +63,28 @@ class BrowserService:
if not base_path.is_dir(): if not base_path.is_dir():
raise ValueError(f"Media folder path is not a directory: {base_path}") raise ValueError(f"Media folder path is not a directory: {base_path}")
# Handle relative vs absolute paths # Reject absolute paths, drive letters, UNC paths, and NUL bytes outright.
if requested_path.startswith("/") or requested_path.startswith("\\"): # Only true folder-relative paths are accepted.
# Relative to folder root (remove leading slash) if "\x00" in requested_path:
requested_path = requested_path.lstrip("/\\") 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 # Build and resolve full path
if requested_path: if cleaned:
full_path = (base_path / requested_path).resolve() full_path = (base_path / cleaned).resolve()
else: else:
full_path = base_path 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",
" - MicrosoftEdge",
" - 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
+403 -22
View File
@@ -1,4 +1,4 @@
"""Display brightness and power control service.""" """Display brightness, power, contrast, input-source and color-preset control."""
import ctypes import ctypes
import ctypes.wintypes import ctypes.wintypes
@@ -6,10 +6,33 @@ import logging
import platform import platform
import struct import struct
import time import time
from dataclasses import dataclass from dataclasses import dataclass, field
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# VCP 0xDC "Display Application" — picture / scene mode.
# Vendors deviate from the MCCS spec, but these labels match the standard
# meanings and cover what most monitors report through their capability
# string. Unknown codes fall back to "Mode <n>".
PICTURE_MODE_VCP = 0xDC
PICTURE_MODE_LABELS: dict[int, str] = {
0x00: "Default",
0x01: "Standalone",
0x02: "Mixed",
0x03: "Productivity",
0x04: "Movie",
0x05: "Game",
0x06: "Sports",
0x07: "Professional",
0x08: "Standard",
0x09: "Default",
0x0A: "Movie (Reduced Effects)",
0x0B: "Movie (Enhanced)",
0x0C: "User 1",
0x0D: "User 2",
0x0E: "User 3",
}
_sbc = None _sbc = None
_monitorcontrol = None _monitorcontrol = None
@@ -32,7 +55,7 @@ def _load_monitorcontrol():
import monitorcontrol import monitorcontrol
_monitorcontrol = monitorcontrol _monitorcontrol = monitorcontrol
except ImportError: except ImportError:
logger.warning("monitorcontrol not installed - display power control unavailable") logger.warning("monitorcontrol not installed - DDC/CI control unavailable")
return _monitorcontrol return _monitorcontrol
@@ -64,6 +87,18 @@ class MonitorInfo:
manufacturer: str = "" manufacturer: str = ""
resolution: str | None = None resolution: str | None = None
is_primary: bool = False is_primary: bool = False
contrast: int | None = None
contrast_supported: bool = False
input_source: str | None = None
available_input_sources: list[str] = field(default_factory=list)
input_source_supported: bool = False
color_preset: str | None = None
available_color_presets: list[str] = field(default_factory=list)
color_preset_supported: bool = False
picture_mode: str | None = None
picture_mode_code: int | None = None
available_picture_modes: list[dict] = field(default_factory=list)
picture_mode_supported: bool = False
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { return {
@@ -76,6 +111,18 @@ class MonitorInfo:
"manufacturer": self.manufacturer, "manufacturer": self.manufacturer,
"resolution": self.resolution, "resolution": self.resolution,
"is_primary": self.is_primary, "is_primary": self.is_primary,
"contrast": self.contrast,
"contrast_supported": self.contrast_supported,
"input_source": self.input_source,
"available_input_sources": self.available_input_sources,
"input_source_supported": self.input_source_supported,
"color_preset": self.color_preset,
"available_color_presets": self.available_color_presets,
"color_preset_supported": self.color_preset_supported,
"picture_mode": self.picture_mode,
"picture_mode_code": self.picture_mode_code,
"available_picture_modes": self.available_picture_modes,
"picture_mode_supported": self.picture_mode_supported,
} }
@@ -137,17 +184,183 @@ def _mark_primary(monitors: list[MonitorInfo]) -> None:
monitors[0].is_primary = True monitors[0].is_primary = True
# Cache for monitor list # Short TTL cache of the assembled monitor list (full response).
_monitor_cache: list[MonitorInfo] | None = None _monitor_cache: list[MonitorInfo] | None = None
_cache_time: float = 0 _cache_time: float = 0
_CACHE_TTL = 5.0 # seconds _CACHE_TTL = 5.0 # seconds
# Per-monitor cache of static capabilities (option lists + support flags).
# DDC/CI capability discovery is the slow part — it only changes when a
# monitor is replaced or rewired, so we probe it once per monitor and reuse
# it across refreshes. Cleared on explicit `rediscover` or when the monitor
# count changes (cheap stale-detection for hot-plug events).
_static_cache: dict[int, dict] = {}
_static_cache_monitor_count: int = -1
def list_monitors(force_refresh: bool = False) -> list[MonitorInfo]:
"""List all connected monitors with their current brightness."""
global _monitor_cache, _cache_time
if not force_refresh and _monitor_cache is not None and (time.time() - _cache_time) < _CACHE_TTL: def _enum_name(value, enum_cls=None) -> str | None:
"""Best-effort name for an enum or raw int returned by monitorcontrol.
monitorcontrol's getters sometimes hand back raw ints when the monitor
reports a value the library wraps incompletely. Re-map those through the
matching enum class so HA selects still receive symbolic option names.
"""
if value is None:
return None
name = getattr(value, "name", None)
if name:
return name
if enum_cls is not None:
try:
return enum_cls(value).name
except (ValueError, KeyError):
pass
return str(value)
def _probe_static_open(mon, mc, monitor_id: int) -> dict:
"""Probe per-monitor static capabilities.
Must be called inside an open `with mon:` DDC/CI context. Tries each
feature once to confirm the monitor responds, and enumerates option
lists from the capability string. Heavy: this is what the cache is for.
"""
static = {
"contrast_supported": False,
"input_source_supported": False,
"available_input_sources": [],
"color_preset_supported": False,
"available_color_presets": [],
"picture_mode_supported": False,
"available_picture_modes": [],
}
try:
caps = mon.get_vcp_capabilities() or {}
except Exception as e:
caps = {}
logger.debug("Monitor %d: get_vcp_capabilities failed: %s", monitor_id, e)
try:
mon.get_contrast()
static["contrast_supported"] = True
except Exception as e:
logger.debug("Monitor %d: contrast unsupported: %s", monitor_id, e)
try:
mon.get_input_source()
static["input_source_supported"] = True
except Exception as e:
logger.debug("Monitor %d: input_source unsupported: %s", monitor_id, e)
inputs = caps.get("inputs") or []
input_enum = mc.InputSource if mc else None
static["available_input_sources"] = [
n for n in (_enum_name(s, input_enum) for s in inputs) if n is not None
]
try:
mon.get_color_preset()
static["color_preset_supported"] = True
if mc is not None:
static["available_color_presets"] = [p.name for p in mc.ColorPreset]
except Exception as e:
logger.debug("Monitor %d: color_preset unsupported: %s", monitor_id, e)
# Picture / scene mode (VCP 0xDC). Trickier than color preset because
# many monitors (LG ultrawides included) respond to READS but silently
# drop every WRITE - they implement the register but not the feature.
# The capability string is the most reliable signal: a monitor that
# really implements picture mode declares its supported codes under
# cmds[0xDC]. If 0xDC isn't declared, treat the feature as unsupported
# to avoid exposing a non-functional select.
cmds = caps.get("cmds") or {}
declared = cmds.get(PICTURE_MODE_VCP)
if declared:
try:
mon.vcp.get_vcp_feature(PICTURE_MODE_VCP)
static["picture_mode_supported"] = True
static["available_picture_modes"] = [
{"code": c, "label": PICTURE_MODE_LABELS.get(c, f"Mode {c}")}
for c in sorted(declared)
]
except Exception as e:
logger.debug("Monitor %d: picture_mode declared but unreadable: %s", monitor_id, e)
else:
logger.debug("Monitor %d: picture_mode (VCP 0xDC) not declared in capability string", monitor_id)
return static
def _probe_dynamic_open(mon, mc, monitor_id: int, static: dict) -> dict:
"""Read current values for features known to be supported.
Must be called inside an open `with mon:` context. Skips reads for
unsupported features (saves one I2C roundtrip each), so the warm path
only touches features the monitor actually responds to.
"""
dynamic = {
"power_on": True,
"contrast": None,
"input_source": None,
"color_preset": None,
"picture_mode": None,
"picture_mode_code": None,
}
try:
dynamic["power_on"] = mon.get_power_mode() == mc.PowerMode.on
except Exception as e:
logger.debug("Monitor %d: power readback failed: %s", monitor_id, e)
if static.get("contrast_supported"):
try:
dynamic["contrast"] = mon.get_contrast()
except Exception as e:
logger.debug("Monitor %d: contrast readback failed: %s", monitor_id, e)
if static.get("input_source_supported"):
try:
src = mon.get_input_source()
dynamic["input_source"] = _enum_name(src, mc.InputSource if mc else None)
except Exception as e:
logger.debug("Monitor %d: input_source readback failed: %s", monitor_id, e)
if static.get("color_preset_supported"):
try:
preset = mon.get_color_preset()
dynamic["color_preset"] = _enum_name(preset, mc.ColorPreset if mc else None)
except Exception as e:
logger.debug("Monitor %d: color_preset readback failed: %s", monitor_id, e)
if static.get("picture_mode_supported"):
try:
current, _maximum = mon.vcp.get_vcp_feature(PICTURE_MODE_VCP)
dynamic["picture_mode_code"] = current
dynamic["picture_mode"] = PICTURE_MODE_LABELS.get(current, f"Mode {current}")
except Exception as e:
logger.debug("Monitor %d: picture_mode readback failed: %s", monitor_id, e)
return dynamic
def list_monitors(force_refresh: bool = False, rediscover: bool = False) -> list[MonitorInfo]:
"""List all connected monitors with their current state.
Args:
force_refresh: bypass the short TTL response cache.
rediscover: also drop the per-monitor static capability cache, so the
next probe re-runs DDC/CI capability discovery. Use after hot-plug
or when a monitor's reported capabilities change.
"""
global _monitor_cache, _cache_time, _static_cache_monitor_count
if (
not force_refresh
and not rediscover
and _monitor_cache is not None
and (time.time() - _cache_time) < _CACHE_TTL
):
return _monitor_cache return _monitor_cache
sbc = _load_sbc() sbc = _load_sbc()
@@ -159,7 +372,13 @@ def list_monitors(force_refresh: bool = False) -> list[MonitorInfo]:
info_list = sbc.list_monitors_info() info_list = sbc.list_monitors_info()
brightnesses = sbc.get_brightness() brightnesses = sbc.get_brightness()
# Get DDC/CI monitors for power state # Invalidate the static cache on explicit rediscover OR on topology
# change (hot-plug / disconnect). Both indicate the cached probe is
# potentially stale.
if rediscover or len(info_list) != _static_cache_monitor_count:
_static_cache.clear()
_static_cache_monitor_count = len(info_list)
mc = _load_monitorcontrol() mc = _load_monitorcontrol()
ddc_monitors = [] ddc_monitors = []
if mc: if mc:
@@ -181,25 +400,44 @@ def list_monitors(force_refresh: bool = False) -> list[MonitorInfo]:
edid = info.get("edid", "") edid = info.get("edid", "")
resolution = _parse_edid_resolution(edid) if edid else None resolution = _parse_edid_resolution(edid) if edid else None
# Read power state via DDC/CI static: dict = {}
power_on = True dynamic: dict = {}
# Open the DDC handle ONCE; do static probe (if needed) + dynamic
# readback inside the same context. Opening the handle is the
# expensive part — keep both phases under one open.
if power_supported and i < len(ddc_monitors): if power_supported and i < len(ddc_monitors):
try: try:
with ddc_monitors[i] as mon: with ddc_monitors[i] as mon:
power_mode = mon.get_power_mode() if i not in _static_cache:
power_on = power_mode == mc.PowerMode.on _static_cache[i] = _probe_static_open(mon, mc, i)
except Exception: static = _static_cache[i]
pass dynamic = _probe_dynamic_open(mon, mc, i, static)
except Exception as e:
logger.debug("Monitor %d: DDC/CI session failed: %s", i, e)
static = _static_cache.get(i, {})
monitors.append(MonitorInfo( monitors.append(MonitorInfo(
id=i, id=i,
name=name, name=name,
brightness=brightness, brightness=brightness,
power_supported=power_supported, power_supported=power_supported,
power_on=power_on, power_on=dynamic.get("power_on", True),
model=model, model=model,
manufacturer=manufacturer, manufacturer=manufacturer,
resolution=resolution, resolution=resolution,
contrast=dynamic.get("contrast"),
contrast_supported=static.get("contrast_supported", False),
input_source=dynamic.get("input_source"),
available_input_sources=static.get("available_input_sources", []),
input_source_supported=static.get("input_source_supported", False),
color_preset=dynamic.get("color_preset"),
available_color_presets=static.get("available_color_presets", []),
color_preset_supported=static.get("color_preset_supported", False),
picture_mode=dynamic.get("picture_mode"),
picture_mode_code=dynamic.get("picture_mode_code"),
available_picture_modes=static.get("available_picture_modes", []),
picture_mode_supported=static.get("picture_mode_supported", False),
)) ))
except Exception as e: except Exception as e:
logger.error("Failed to enumerate monitors: %s", e) logger.error("Failed to enumerate monitors: %s", e)
@@ -234,9 +472,7 @@ def set_brightness(monitor_id: int, value: int) -> bool:
value = max(0, min(100, value)) value = max(0, min(100, value))
try: try:
sbc.set_brightness(value, display=monitor_id) sbc.set_brightness(value, display=monitor_id)
# Invalidate cache _invalidate_cache()
global _monitor_cache
_monitor_cache = None
return True return True
except Exception as e: except Exception as e:
logger.error("Failed to set brightness for monitor %d: %s", monitor_id, e) logger.error("Failed to set brightness for monitor %d: %s", monitor_id, e)
@@ -262,10 +498,155 @@ def set_power(monitor_id: int, on: bool) -> bool:
else: else:
monitor.set_power_mode(mc.PowerMode.off_soft) monitor.set_power_mode(mc.PowerMode.off_soft)
# Invalidate cache _invalidate_cache()
global _monitor_cache
_monitor_cache = None
return True return True
except Exception as e: except Exception as e:
logger.error("Failed to set power for monitor %d: %s", monitor_id, e) logger.error("Failed to set power for monitor %d: %s", monitor_id, e)
return False return False
def _verify_after_set(getter, expected, *, retries: int = 3, delay: float = 0.1) -> bool:
"""Poll a DDC/CI getter to confirm the monitor actually applied a write.
DDC/CI writes are fire-and-forget at the protocol level: a successful
send does not mean the monitor honored the value. Many monitors silently
drop writes for codes their firmware doesn't really implement (LG's
ColorPreset / Picture Mode are common offenders). Without this check the
API would report `success: true` while the monitor sat unchanged.
Compares both raw and `.value` forms so enum/int mismatches don't flag a
spurious failure.
"""
expected_int = getattr(expected, "value", expected)
for _ in range(retries):
time.sleep(delay)
try:
actual = getter()
except Exception:
continue
actual_int = getattr(actual, "value", actual)
if actual == expected or actual_int == expected_int:
return True
return False
def set_contrast(monitor_id: int, value: int) -> bool:
"""Set contrast for a specific monitor (0-100) via DDC/CI."""
mc = _load_monitorcontrol()
if mc is None:
return False
value = max(0, min(100, value))
try:
ddc_monitors = mc.get_monitors()
if monitor_id >= len(ddc_monitors):
return False
with ddc_monitors[monitor_id] as monitor:
monitor.set_contrast(value)
if not _verify_after_set(monitor.get_contrast, value):
logger.warning("Monitor %d: contrast %d not applied", monitor_id, value)
return False
_invalidate_cache()
return True
except Exception as e:
logger.error("Failed to set contrast for monitor %d: %s", monitor_id, e)
return False
def set_input_source(monitor_id: int, source: str) -> bool:
"""Set the DDC/CI input source by enum name (e.g. 'HDMI1', 'DP1')."""
mc = _load_monitorcontrol()
if mc is None:
return False
try:
target = mc.InputSource[source]
except KeyError:
logger.error("Unknown input source: %s", source)
return False
try:
ddc_monitors = mc.get_monitors()
if monitor_id >= len(ddc_monitors):
return False
with ddc_monitors[monitor_id] as monitor:
monitor.set_input_source(target)
# Source switches can briefly disrupt the DDC/CI link; allow a
# longer settle window before declaring failure.
if not _verify_after_set(monitor.get_input_source, target, retries=5, delay=0.2):
logger.warning("Monitor %d: input source %s not applied", monitor_id, source)
return False
_invalidate_cache()
return True
except Exception as e:
logger.error("Failed to set input source for monitor %d: %s", monitor_id, e)
return False
def set_color_preset(monitor_id: int, preset: str) -> bool:
"""Set the DDC/CI color preset by enum name (e.g. 'COLOR_TEMP_6500K')."""
mc = _load_monitorcontrol()
if mc is None:
return False
try:
target = mc.ColorPreset[preset]
except KeyError:
logger.error("Unknown color preset: %s", preset)
return False
try:
ddc_monitors = mc.get_monitors()
if monitor_id >= len(ddc_monitors):
return False
with ddc_monitors[monitor_id] as monitor:
monitor.set_color_preset(target)
if not _verify_after_set(monitor.get_color_preset, target):
logger.warning(
"Monitor %d: color preset %s not applied (monitor silently rejected)",
monitor_id, preset,
)
return False
_invalidate_cache()
return True
except Exception as e:
logger.error("Failed to set color preset for monitor %d: %s", monitor_id, e)
return False
def set_picture_mode(monitor_id: int, code: int) -> bool:
"""Set the DDC/CI picture/scene mode (VCP 0xDC) by raw code."""
mc = _load_monitorcontrol()
if mc is None:
return False
if not 0 <= code <= 255:
logger.error("Picture mode code %d out of range", code)
return False
try:
ddc_monitors = mc.get_monitors()
if monitor_id >= len(ddc_monitors):
return False
with ddc_monitors[monitor_id] as monitor:
monitor.vcp.set_vcp_feature(PICTURE_MODE_VCP, code)
# Raw VCP read returns (current, maximum) — only compare current.
def _read_picture_mode():
current, _ = monitor.vcp.get_vcp_feature(PICTURE_MODE_VCP)
return current
if not _verify_after_set(_read_picture_mode, code):
logger.warning(
"Monitor %d: picture mode code %d not applied (monitor silently rejected)",
monitor_id, code,
)
return False
_invalidate_cache()
return True
except Exception as e:
logger.error("Failed to set picture mode for monitor %d: %s", monitor_id, e)
return False
def _invalidate_cache() -> None:
global _monitor_cache
_monitor_cache = None
+514
View File
@@ -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 X, display # 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()
+24 -68
View File
@@ -151,22 +151,19 @@ class LinuxMediaController(MediaController):
logger.error(f"Failed to toggle mute: {e}") logger.error(f"Failed to toggle mute: {e}")
return False return False
async def get_status(self) -> MediaStatus: def _sync_get_status(self) -> MediaStatus:
"""Get current media playback status.""" """Synchronous status read (called from a worker thread)."""
status = MediaStatus() status = MediaStatus()
# Get system volume
volume, muted = self._get_volume_pulseaudio() volume, muted = self._get_volume_pulseaudio()
status.volume = volume status.volume = volume
status.muted = muted status.muted = muted
# Get active player
player_name = self._get_active_player() player_name = self._get_active_player()
if player_name is None: if player_name is None:
status.state = MediaState.IDLE status.state = MediaState.IDLE
return status return status
# Get playback status
playback_status = self._get_property(player_name, "PlaybackStatus") playback_status = self._get_property(player_name, "PlaybackStatus")
if playback_status == "Playing": if playback_status == "Playing":
status.state = MediaState.PLAYING status.state = MediaState.PLAYING
@@ -177,114 +174,70 @@ class LinuxMediaController(MediaController):
else: else:
status.state = MediaState.IDLE status.state = MediaState.IDLE
# Get metadata
metadata = self._get_property(player_name, "Metadata") metadata = self._get_property(player_name, "Metadata")
if metadata: if metadata:
status.title = str(metadata.get("xesam:title", "")) or None status.title = str(metadata.get("xesam:title", "")) or None
artists = metadata.get("xesam:artist", []) artists = metadata.get("xesam:artist", [])
if artists: if artists:
status.artist = str(artists[0]) if isinstance(artists, list) else str(artists) status.artist = str(artists[0]) if isinstance(artists, list) else str(artists)
status.album = str(metadata.get("xesam:album", "")) or None status.album = str(metadata.get("xesam:album", "")) or None
status.album_art_url = str(metadata.get("mpris:artUrl", "")) or None status.album_art_url = str(metadata.get("mpris:artUrl", "")) or None
# Duration in microseconds
length = metadata.get("mpris:length", 0) length = metadata.get("mpris:length", 0)
if length: if length:
status.duration = int(length) / 1_000_000 status.duration = int(length) / 1_000_000
# Get position (in microseconds)
position = self._get_property(player_name, "Position") position = self._get_property(player_name, "Position")
if position is not None: if position is not None:
status.position = int(position) / 1_000_000 status.position = int(position) / 1_000_000
# Get source name
status.source = player_name.replace(self.MPRIS_PREFIX, "") status.source = player_name.replace(self.MPRIS_PREFIX, "")
return status return status
async def play(self) -> bool: async def get_status(self) -> MediaStatus:
"""Resume playback.""" """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() player_name = self._get_active_player()
if player_name is None: if player_name is None:
return False return False
try: try:
player = self._get_player_interface(player_name) player = self._get_player_interface(player_name)
player.Play() getattr(player, method_name)()
return True return True
except Exception as e: except Exception as e:
logger.error(f"Failed to play: {e}") logger.error(f"Failed to call player.{method_name}: {e}")
return False return False
async def play(self) -> bool:
return await asyncio.to_thread(self._call_player, "Play")
async def pause(self) -> bool: async def pause(self) -> bool:
"""Pause playback.""" return await asyncio.to_thread(self._call_player, "Pause")
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
async def stop(self) -> bool: async def stop(self) -> bool:
"""Stop playback.""" return await asyncio.to_thread(self._call_player, "Stop")
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
async def next_track(self) -> bool: async def next_track(self) -> bool:
"""Skip to next track.""" return await asyncio.to_thread(self._call_player, "Next")
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
async def previous_track(self) -> bool: async def previous_track(self) -> bool:
"""Go to previous track.""" return await asyncio.to_thread(self._call_player, "Previous")
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
async def set_volume(self, volume: int) -> bool: async def set_volume(self, volume: int) -> bool:
"""Set system volume.""" return await asyncio.to_thread(self._set_volume_pulseaudio, volume)
return self._set_volume_pulseaudio(volume)
async def toggle_mute(self) -> bool: async def toggle_mute(self) -> bool:
"""Toggle mute state.""" return await asyncio.to_thread(self._toggle_mute_pulseaudio)
return self._toggle_mute_pulseaudio()
async def seek(self, position: float) -> bool: def _sync_seek(self, position: float) -> bool:
"""Seek to position in seconds."""
player_name = self._get_active_player() player_name = self._get_active_player()
if player_name is None: if player_name is None:
return False return False
try: try:
player = self._get_player_interface(player_name) player = self._get_player_interface(player_name)
# MPRIS expects position in microseconds
player.SetPosition( player.SetPosition(
self._get_property(player_name, "Metadata").get("mpris:trackid", "/"), self._get_property(player_name, "Metadata").get("mpris:trackid", "/"),
int(position * 1_000_000), int(position * 1_000_000),
@@ -294,6 +247,9 @@ class LinuxMediaController(MediaController):
logger.error(f"Failed to seek: {e}") logger.error(f"Failed to seek: {e}")
return False 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: async def open_file(self, file_path: str) -> bool:
"""Open a media file with the default system player (Linux). """Open a media file with the default system player (Linux).
+1 -1
View File
@@ -321,7 +321,7 @@ class ThumbnailService:
if suffix in AUDIO_EXTENSIONS: if suffix in AUDIO_EXTENSIONS:
# Audio files - run in executor (sync operation) # Audio files - run in executor (sync operation)
loop = asyncio.get_event_loop() loop = asyncio.get_running_loop()
thumbnail_data = await loop.run_in_executor( thumbnail_data = await loop.run_in_executor(
None, None,
ThumbnailService.generate_audio_thumbnail, ThumbnailService.generate_audio_thumbnail,
+86 -5
View File
@@ -19,6 +19,9 @@ class ConnectionManager:
self._active_connections: set[WebSocket] = set() self._active_connections: set[WebSocket] = set()
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
self._last_status: dict[str, Any] | None = None 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._get_status_func: Callable[[], Coroutine[Any, Any, Any]] | None = None
self._broadcast_task: asyncio.Task | None = None self._broadcast_task: asyncio.Task | None = None
self._poll_interval: float = 0.5 # Internal poll interval for change detection self._poll_interval: float = 0.5 # Internal poll interval for change detection
@@ -54,6 +57,18 @@ class ConnectionManager:
except Exception as e: except Exception as e:
logger.debug("Failed to send initial status: %s", 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: async def disconnect(self, websocket: WebSocket) -> None:
"""Remove a WebSocket connection. Stops audio capture if last visualizer subscriber.""" """Remove a WebSocket connection. Stops audio capture if last visualizer subscriber."""
should_stop = False should_stop = False
@@ -70,16 +85,27 @@ class ConnectionManager:
) )
async def broadcast(self, message: dict[str, Any]) -> None: 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: async with self._lock:
connections = list(self._active_connections) connections = list(self._active_connections)
if not connections: if not connections:
return 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: async def _send(ws: WebSocket) -> WebSocket | None:
try: try:
await ws.send_json(message) await ws.send_text(payload)
return None return None
except Exception as e: except Exception as e:
logger.debug("Failed to send to client: %s", e) logger.debug("Failed to send to client: %s", e)
@@ -104,6 +130,35 @@ class ConnectionManager:
await self.broadcast(message) await self.broadcast(message)
logger.info("Broadcast sent: links_changed") 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: async def subscribe_visualizer(self, websocket: WebSocket) -> None:
"""Subscribe a client to audio visualizer data. Starts capture on first subscriber.""" """Subscribe a client to audio visualizer data. Starts capture on first subscriber."""
should_start = False should_start = False
@@ -129,7 +184,7 @@ class ConnectionManager:
async def _maybe_start_capture(self) -> None: async def _maybe_start_capture(self) -> None:
"""Start audio capture if not already running (called on first subscriber).""" """Start audio capture if not already running (called on first subscriber)."""
if self._audio_analyzer and not self._audio_analyzer.running: 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) started = await loop.run_in_executor(None, self._audio_analyzer.start)
if started: if started:
logger.info("Audio capture started (first subscriber)") logger.info("Audio capture started (first subscriber)")
@@ -139,7 +194,7 @@ class ConnectionManager:
async def _maybe_stop_capture(self) -> None: async def _maybe_stop_capture(self) -> None:
"""Stop audio capture if running (called when last subscriber leaves).""" """Stop audio capture if running (called when last subscriber leaves)."""
if self._audio_analyzer and self._audio_analyzer.running: 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) await loop.run_in_executor(None, self._audio_analyzer.stop)
logger.info("Audio capture stopped (no subscribers)") logger.info("Audio capture stopped (no subscribers)")
@@ -171,7 +226,7 @@ class ConnectionManager:
idle_interval = 1.0 / max(1, settings.visualizer_fps) idle_interval = 1.0 / max(1, settings.visualizer_fps)
# Bounded wait so we still notice subscribe/unsubscribe transitions. # Bounded wait so we still notice subscribe/unsubscribe transitions.
wake_timeout = max(0.05, idle_interval) wake_timeout = max(0.05, idle_interval)
loop = asyncio.get_event_loop() loop = asyncio.get_running_loop()
last_seq = -1 last_seq = -1
@@ -303,6 +358,10 @@ class ConnectionManager:
get_status_func: Callable[[], Coroutine[Any, Any, Any]], get_status_func: Callable[[], Coroutine[Any, Any, Any]],
) -> None: ) -> None:
"""Background loop that polls for status changes and broadcasts.""" """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: while self._running:
try: try:
# Only poll if we have connected clients # Only poll if we have connected clients
@@ -329,6 +388,28 @@ class ConnectionManager:
# Update cached status even without broadcast # Update cached status even without broadcast
self._last_status = status_dict 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) await asyncio.sleep(self._poll_interval)
except asyncio.CancelledError: except asyncio.CancelledError:
+67 -79
View File
@@ -15,6 +15,22 @@ logger = logging.getLogger(__name__)
# Thread pool for WinRT operations (they don't play well with asyncio) # Thread pool for WinRT operations (they don't play well with asyncio)
_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="winrt") _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) # Global storage for current album art (as bytes)
_current_album_art_bytes: bytes | None = None _current_album_art_bytes: bytes | None = None
@@ -161,8 +177,6 @@ WINDOWS_AVAILABLE = WINSDK_AVAILABLE
def _sync_get_media_status() -> dict[str, Any]: def _sync_get_media_status() -> dict[str, Any]:
"""Synchronously get media status (runs in thread pool).""" """Synchronously get media status (runs in thread pool)."""
import asyncio
result = { result = {
"state": "idle", "state": "idle",
"title": None, "title": None,
@@ -174,9 +188,7 @@ def _sync_get_media_status() -> dict[str, Any]:
} }
try: try:
# Create a new event loop for this thread loop = _thread_loop()
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try: try:
# Get media session manager # Get media session manager
@@ -393,7 +405,8 @@ def _sync_get_media_status() -> dict[str, Any]:
result["source"] = session.source_app_user_model_id result["source"] = session.source_app_user_model_id
finally: finally:
loop.close() # Reuse the loop across calls — see _thread_loop above.
pass
except Exception as e: except Exception as e:
logger.error(f"Error getting media status: {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: def _sync_media_command(command: str) -> bool:
"""Synchronously execute a media command (runs in thread pool).""" """Synchronously execute a media command (runs in thread pool)."""
import asyncio
try: try:
loop = asyncio.new_event_loop() loop = _thread_loop()
asyncio.set_event_loop(loop) manager = loop.run_until_complete(MediaManager.request_async())
if manager is None:
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())
return False 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: except Exception as e:
logger.error(f"Error executing media command {command}: {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: def _sync_seek(position: float) -> bool:
"""Synchronously seek to position.""" """Synchronously seek to position."""
import asyncio
try: try:
loop = asyncio.new_event_loop() loop = _thread_loop()
asyncio.set_event_loop(loop) manager = loop.run_until_complete(MediaManager.request_async())
if manager is None:
return False
try: session = _find_best_session(manager, loop)
manager = loop.run_until_complete(MediaManager.request_async()) if session is None:
if manager is None: return False
return False
session = _find_best_session(manager, loop) position_ticks = int(position * 10_000_000)
if session is None: return loop.run_until_complete(
return False session.try_change_playback_position_async(position_ticks)
)
position_ticks = int(position * 10_000_000)
return loop.run_until_complete(
session.try_change_playback_position_async(position_ticks)
)
finally:
loop.close()
except Exception as e: except Exception as e:
logger.error(f"Error seeking: {e}") logger.error(f"Error seeking: {e}")
@@ -559,7 +558,7 @@ class WindowsMediaController(MediaController):
# Get media info in thread pool (avoids asyncio/WinRT issues) # Get media info in thread pool (avoids asyncio/WinRT issues)
try: try:
loop = asyncio.get_event_loop() loop = asyncio.get_running_loop()
media_info = await asyncio.wait_for( media_info = await asyncio.wait_for(
loop.run_in_executor(_executor, _sync_get_media_status), loop.run_in_executor(_executor, _sync_get_media_status),
timeout=5.0 timeout=5.0
@@ -592,7 +591,7 @@ class WindowsMediaController(MediaController):
async def _run_command(self, command: str) -> bool: async def _run_command(self, command: str) -> bool:
"""Run a media command in the thread pool.""" """Run a media command in the thread pool."""
try: try:
loop = asyncio.get_event_loop() loop = asyncio.get_running_loop()
return await asyncio.wait_for( return await asyncio.wait_for(
loop.run_in_executor(_executor, _sync_media_command, command), loop.run_in_executor(_executor, _sync_media_command, command),
timeout=5.0 timeout=5.0
@@ -616,16 +615,15 @@ class WindowsMediaController(MediaController):
"""Stop playback.""" """Stop playback."""
return await self._run_command("stop") return await self._run_command("stop")
async def next_track(self) -> bool: async def _skip_track(self, command: str) -> bool:
"""Skip to next track.""" # Read the current title from the position cache instead of doing a
# Get current title before skipping # full WinRT round-trip (which can take up to 5s) just for one field.
try: with _position_lock:
status = await self.get_status() track_id = _position_cache.get("track_id") or ""
old_title = status.title or "" # track_id is "title:artist:duration" — extract just the title.
except Exception: old_title = track_id.split(":", 1)[0] if track_id else ""
old_title = ""
result = await self._run_command("next") result = await self._run_command(command)
if result: if result:
with _position_lock: with _position_lock:
_track_skip_pending["active"] = True _track_skip_pending["active"] = True
@@ -634,23 +632,13 @@ class WindowsMediaController(MediaController):
logger.debug(f"Track skip initiated, old title: {old_title}") logger.debug(f"Track skip initiated, old title: {old_title}")
return result return result
async def next_track(self) -> bool:
"""Skip to next track."""
return await self._skip_track("next")
async def previous_track(self) -> bool: async def previous_track(self) -> bool:
"""Go to previous track.""" """Go to previous track."""
# Get current title before skipping return await self._skip_track("previous")
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
async def set_volume(self, volume: int) -> bool: async def set_volume(self, volume: int) -> bool:
"""Set system volume.""" """Set system volume."""
@@ -680,7 +668,7 @@ class WindowsMediaController(MediaController):
async def seek(self, position: float) -> bool: async def seek(self, position: float) -> bool:
"""Seek to position in seconds.""" """Seek to position in seconds."""
try: try:
loop = asyncio.get_event_loop() loop = asyncio.get_running_loop()
return await asyncio.wait_for( return await asyncio.wait_for(
loop.run_in_executor(_executor, _sync_seek, position), loop.run_in_executor(_executor, _sync_seek, position),
timeout=5.0 timeout=5.0
@@ -705,7 +693,7 @@ class WindowsMediaController(MediaController):
""" """
try: try:
import os import os
loop = asyncio.get_event_loop() loop = asyncio.get_running_loop()
await loop.run_in_executor(None, lambda: os.startfile(file_path)) await loop.run_in_executor(None, lambda: os.startfile(file_path))
logger.info(f"Opened file with default player: {file_path}") logger.info(f"Opened file with default player: {file_path}")
return True return True
+468 -49
View File
@@ -329,7 +329,7 @@ body.translations-loaded {
button:focus-visible { button:focus-visible {
outline: 2px solid var(--accent); outline: 2px solid var(--accent);
outline-offset: 2px; 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, input:focus-visible,
@@ -337,7 +337,7 @@ select:focus-visible,
textarea:focus-visible { textarea:focus-visible {
outline: none; outline: none;
border-color: var(--accent); 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 { .tab-btn:focus-visible {
@@ -1004,7 +1004,7 @@ button:disabled {
.controls button:focus-visible { .controls button:focus-visible {
outline: 2px solid var(--accent); outline: 2px solid var(--accent);
outline-offset: 3px; 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, .mute-btn:focus-visible,
@@ -1012,7 +1012,7 @@ button:disabled {
.vinyl-toggle-btn:focus-visible { .vinyl-toggle-btn:focus-visible {
outline: 2px solid var(--accent); outline: 2px solid var(--accent);
outline-offset: 2px; 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 { .controls button.primary {
@@ -1060,7 +1060,7 @@ button:disabled {
#volume-slider:hover::-webkit-slider-thumb { #volume-slider:hover::-webkit-slider-thumb {
transform: scale(1.3); 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 { #volume-slider::-moz-range-thumb {
@@ -1075,7 +1075,7 @@ button:disabled {
#volume-slider:hover::-moz-range-thumb { #volume-slider:hover::-moz-range-thumb {
transform: scale(1.3); 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 { .volume-display {
@@ -1169,7 +1169,7 @@ button:disabled {
.vinyl-toggle-btn.active { .vinyl-toggle-btn.active {
color: var(--accent); color: var(--accent);
border-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 { .vinyl-toggle-btn svg {
@@ -1393,7 +1393,7 @@ button:disabled {
border-radius: 4px; border-radius: 4px;
background: var(--bg-tertiary); background: var(--bg-tertiary);
color: var(--text-primary); 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; font-size: 1rem;
cursor: pointer; cursor: pointer;
transition: border-color 0.25s ease, box-shadow 0.25s ease; transition: border-color 0.25s ease, box-shadow 0.25s ease;
@@ -1402,7 +1402,7 @@ button:disabled {
.audio-device-selector select:focus { .audio-device-selector select:focus {
outline: none; outline: none;
border-color: var(--accent); 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 { .audio-device-status {
@@ -1836,13 +1836,18 @@ button:disabled {
dialog { dialog {
background: var(--bg-secondary); background: var(--bg-secondary);
color: var(--text-primary); 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: 1px solid var(--border);
border-radius: 12px; border-top: 1px solid var(--copper);
border-radius: 0;
padding: 0; padding: 0;
max-width: 500px; max-width: 520px;
width: 90%; width: 90%;
margin: auto; 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; animation: dialogIn 0.25s ease-out;
overflow: visible; overflow: visible;
} }
@@ -2827,7 +2832,7 @@ button.primary svg {
.breadcrumb-item:hover { .breadcrumb-item:hover {
color: var(--accent); color: var(--accent);
background: rgba(29, 185, 84, 0.08); background: rgba(var(--copper-rgb), 0.08);
text-decoration: none; text-decoration: none;
} }
@@ -3072,19 +3077,25 @@ button.primary svg {
/* Browser List View */ /* Browser List View */
.browser-list { .browser-list {
display: flex; display: grid;
flex-direction: column; grid-template-columns: 40px 1fr auto auto auto auto;
gap: 1px; column-gap: 1.25rem;
row-gap: 1px;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
min-height: 200px; min-height: 200px;
} }
.browser-list > .browser-empty,
.browser-list > .browser-loading {
grid-column: 1 / -1;
}
/* List view column header */ /* List view column header */
.browser-list-header { .browser-list-header {
display: grid; display: grid;
grid-template-columns: 40px 1fr auto auto auto auto; grid-template-columns: subgrid;
grid-column: 1 / -1;
align-items: center; align-items: center;
gap: 0.75rem;
padding: 0.4rem 0.75rem; padding: 0.4rem 0.75rem;
font-size: 0.688rem; font-size: 0.688rem;
font-weight: 600; font-weight: 600;
@@ -3102,9 +3113,9 @@ button.primary svg {
.browser-list-item { .browser-list-item {
display: grid; display: grid;
grid-template-columns: 40px 1fr auto auto auto auto; grid-template-columns: subgrid;
grid-column: 1 / -1;
align-items: center; align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
background: transparent; background: transparent;
border: 1px solid transparent; border: 1px solid transparent;
@@ -3235,12 +3246,14 @@ button.primary svg {
} }
.browser-item { .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: 1px solid transparent;
border-radius: 10px; border-radius: 0;
padding: 0.6rem; padding: 0.6rem;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: border-color 0.2s ease, background 0.2s ease, transform 0.2s ease;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -3250,6 +3263,11 @@ button.primary svg {
animation-delay: calc(var(--item-index, 0) * 25ms); 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 { @keyframes itemFadeIn {
from { opacity: 0; transform: translateY(10px); } from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); } to { opacity: 1; transform: translateY(0); }
@@ -3286,14 +3304,21 @@ button.primary svg {
height: 56px; height: 56px;
font-size: 1.75rem; font-size: 1.75rem;
border-radius: 14px; border-radius: 14px;
background: rgba(29, 185, 84, 0.1); background: rgba(var(--copper-rgb), 0.1);
border: 1px solid rgba(29, 185, 84, 0.15); border: 1px solid rgba(var(--copper-rgb), 0.15);
transition: all 0.25s; 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 { .browser-item.browser-root-folder:hover .browser-icon {
background: rgba(29, 185, 84, 0.18); background: rgba(var(--copper-rgb), 0.18);
border-color: rgba(29, 185, 84, 0.3); border-color: rgba(var(--copper-rgb), 0.3);
transform: scale(1.05); transform: scale(1.05);
} }
@@ -3638,9 +3663,12 @@ button.primary svg {
display: none; display: none;
} }
.browser-list-header { .browser-list {
grid-template-columns: 32px 1fr auto auto; grid-template-columns: 32px 1fr auto auto;
gap: 0.5rem; column-gap: 0.875rem;
}
.browser-list-header {
padding: 0.35rem 0.5rem; padding: 0.35rem 0.5rem;
} }
@@ -3649,8 +3677,6 @@ button.primary svg {
} }
.browser-list-item { .browser-list-item {
grid-template-columns: 32px 1fr auto auto;
gap: 0.5rem;
padding: 0.4rem 0.5rem; padding: 0.4rem 0.5rem;
} }
@@ -3720,17 +3746,13 @@ button.primary svg {
display: none; display: none;
} }
.browser-list-header { .browser-list {
grid-template-columns: 40px 1fr auto auto auto; grid-template-columns: 40px 1fr auto auto auto;
} }
.browser-list-header span:nth-child(3) { .browser-list-header span:nth-child(3) {
display: none; display: none;
} }
.browser-list-item {
grid-template-columns: 40px 1fr auto auto auto;
}
} }
/* Update Banner */ /* Update Banner */
@@ -3963,7 +3985,13 @@ html {
} }
@media (max-width: 720px) { @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) ────────────────── */ /* ─── Folio marks (page corners, all tabs) ────────────────── */
@@ -7387,9 +7415,15 @@ select option {
font-size: 9px; font-size: 9px;
letter-spacing: 0.1em; 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 { .browser-container .browser-grid-compact .browser-icon {
width: 28px; width: 100%;
height: 28px; height: 100%;
font-size: 2.25rem;
} }
.browser-container .browser-item { .browser-container .browser-item {
background: transparent !important; background: transparent !important;
@@ -8022,24 +8056,36 @@ select option {
background: rgba(var(--copper-rgb), 0.06) !important; background: rgba(var(--copper-rgb), 0.06) !important;
} }
/* Brightness control row */ /* Slider rows (brightness + contrast share this layout) */
.display-container .display-brightness-control { .display-container .display-slider-row {
display: flex; display: grid;
grid-template-columns: 18px minmax(0, auto) 1fr auto;
align-items: center; align-items: center;
gap: 14px; gap: 12px;
}
.display-container .display-slider-row.display-brightness-control {
padding-top: 16px; padding-top: 16px;
border-top: 1px solid var(--rule); border-top: 1px solid var(--rule);
} }
.display-container .display-brightness-icon { .display-container .display-slider-icon {
color: var(--ink-mute); color: var(--ink-mute);
width: 18px !important; width: 18px !important;
height: 18px !important; height: 18px !important;
flex-shrink: 0; flex-shrink: 0;
} }
.display-container .display-brightness-slider { .display-container .display-slider-label {
font-family: var(--mono);
font-size: 9px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--ink-mute);
white-space: nowrap;
}
.display-container .display-slider {
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
flex: 1; flex: 1;
width: 100%;
height: 2px !important; height: 2px !important;
background: var(--rule-strong); background: var(--rule-strong);
border-radius: 0; border-radius: 0;
@@ -8049,7 +8095,7 @@ select option {
border: 0 !important; border: 0 !important;
min-width: 0; min-width: 0;
} }
.display-container .display-brightness-slider::-webkit-slider-thumb { .display-container .display-slider::-webkit-slider-thumb {
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
width: 14px; width: 14px;
@@ -8059,20 +8105,24 @@ select option {
box-shadow: 0 0 12px var(--copper-glow); box-shadow: 0 0 12px var(--copper-glow);
border: 0; border: 0;
cursor: grab; cursor: grab;
transition: transform 140ms var(--ease);
} }
.display-container .display-brightness-slider::-moz-range-thumb { .display-container .display-slider::-moz-range-thumb {
width: 14px; width: 14px;
height: 14px; height: 14px;
background: var(--copper); background: var(--copper);
border-radius: 50%; border-radius: 50%;
border: 0; border: 0;
cursor: grab; cursor: grab;
transition: transform 140ms var(--ease);
} }
.display-container .display-brightness-slider:disabled { .display-container .display-slider:hover::-webkit-slider-thumb { transform: scale(1.15); }
.display-container .display-slider:hover::-moz-range-thumb { transform: scale(1.15); }
.display-container .display-slider:disabled {
opacity: 0.4; opacity: 0.4;
cursor: not-allowed; cursor: not-allowed;
} }
.display-container .display-brightness-value { .display-container .display-slider-value {
font-family: var(--mono); font-family: var(--mono);
font-size: 12px; font-size: 12px;
color: var(--copper); color: var(--copper);
@@ -8082,6 +8132,57 @@ select option {
text-align: right; text-align: right;
letter-spacing: 0.04em; letter-spacing: 0.04em;
} }
/* Picture tuning section input source, color preset, picture mode.
The underlying <select> is hidden by IconSelect; the visible trigger
inherits the editorial .icon-select-trigger overrides defined later
in this file. */
.display-container .display-tuning {
padding-top: 18px;
border-top: 1px solid var(--rule);
display: flex;
flex-direction: column;
gap: 14px;
}
.display-container .display-tuning-title {
font-family: var(--mono);
font-size: 9px;
letter-spacing: 0.28em;
text-transform: uppercase;
color: var(--ink-faint);
display: flex;
align-items: center;
gap: 10px;
}
.display-container .display-tuning-title::after {
content: '';
flex: 1;
height: 1px;
background: linear-gradient(to right, var(--rule), transparent);
}
.display-container .display-tuning-grid {
display: flex;
flex-direction: column;
gap: 14px;
}
.display-container .display-tuning-field {
display: flex;
flex-direction: column;
gap: 8px;
}
.display-container .display-tuning-label {
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--ink-mute);
white-space: nowrap;
}
/* Make the IconSelect trigger fill the field width (cards are narrow) */
.display-container .display-tuning-field .icon-select-trigger {
width: 100%;
justify-content: flex-start;
}
.display-container .display-monitors > .empty-state-illustration { .display-container .display-monitors > .empty-state-illustration {
grid-column: 1 / -1; grid-column: 1 / -1;
text-align: center; text-align: center;
@@ -9220,3 +9321,321 @@ body.is-fullscreen-player .now-playing .vu-meter {
body.is-fullscreen-player .fs-bloom #fs-bloom-art { animation: none !important; } 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; } :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;
}
}
+62 -56
View File
@@ -26,15 +26,15 @@
</div> </div>
</div> </div>
<div class="mini-controls"> <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> <svg viewBox="0 0 24 24"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
</button> </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"> <svg viewBox="0 0 24 24" id="mini-play-pause-icon">
<path d="M8 5v14l11-7z"/> <path d="M8 5v14l11-7z"/>
</svg> </svg>
</button> </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> <svg viewBox="0 0 24 24"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
</button> </button>
</div> </div>
@@ -48,7 +48,7 @@
</div> </div>
</div> </div>
<div class="mini-volume-container"> <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"> <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"/> <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> </svg>
@@ -67,7 +67,7 @@
<h2 data-i18n="app.title">Media Server</h2> <h2 data-i18n="app.title">Media Server</h2>
<p data-i18n="auth.message">Enter your API token to connect to the media server.</p> <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"> <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"> <div class="help-text">
<p data-i18n="auth.help">To get your token, run:</p> <p data-i18n="auth.help">To get your token, run:</p>
<code>media-server --show-token</code> <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"> <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> <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> </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> <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> </button>
<div class="accent-picker"> <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> <span class="accent-dot" id="accentDot"></span>
</button> </button>
<div class="accent-picker-dropdown" id="accentDropdown"></div> <div class="accent-picker-dropdown" id="accentDropdown"></div>
</div> </div>
<button class="header-btn" onclick="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> <svg viewBox="0 0 24 24"><path fill="currentColor" d="M12 3v10.55A4 4 0 1 0 14 17V7h4V3h-6z"/></svg>
</button> </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-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> <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>
<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;"> <svg id="theme-icon-sun" viewBox="0 0 24 24" style="display: none;">
<path d="M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zM2 13h2c.55 0 1-.45 1-1s-.45-1-1-1H2c-.55 0-1 .45-1 1s.45 1 1 1zm18 0h2c.55 0 1-.45 1-1s-.45-1-1-1h-2c-.55 0-1 .45-1 1s.45 1 1 1zM11 2v2c0 .55.45 1 1 1s1-.45 1-1V2c0-.55-.45-1-1-1s-1 .45-1 1zm0 18v2c0 .55.45 1 1 1s1-.45 1-1v-2c0-.55-.45-1-1-1s-1 .45-1 1zM5.99 4.58c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0s.39-1.03 0-1.41L5.99 4.58zm12.37 12.37c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0 .39-.39.39-1.03 0-1.41l-1.06-1.06zm1.06-10.96c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06zM7.05 18.36c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06z"/> <path d="M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zM2 13h2c.55 0 1-.45 1-1s-.45-1-1-1H2c-.55 0-1 .45-1 1s.45 1 1 1zm18 0h2c.55 0 1-.45 1-1s-.45-1-1-1h-2c-.55 0-1 .45-1 1s.45 1 1 1zM11 2v2c0 .55.45 1 1 1s1-.45 1-1V2c0-.55-.45-1-1-1s-1 .45-1 1zm0 18v2c0 .55.45 1 1 1s1-.45 1-1v-2c0-.55-.45-1-1-1s-1 .45-1 1zM5.99 4.58c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0s.39-1.03 0-1.41L5.99 4.58zm12.37 12.37c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0 .39-.39.39-1.03 0-1.41l-1.06-1.06zm1.06-10.96c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06zM7.05 18.36c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06z"/>
</svg> </svg>
@@ -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"/> <path d="M9 2c-1.05 0-2.05.16-3 .46 4.06 1.27 7 5.06 7 9.54 0 4.48-2.94 8.27-7 9.54.95.3 1.95.46 3 .46 5.52 0 10-4.48 10-10S14.52 2 9 2z"/>
</svg> </svg>
</button> </button>
<select id="locale-select" 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="en">EN</option>
<option value="ru">RU</option> <option value="ru">RU</option>
</select> </select>
<span class="header-toolbar-sep"></span> <span class="header-toolbar-sep"></span>
<button class="header-btn header-btn-logout" onclick="clearToken()" data-i18n-title="auth.logout.title" title="Clear saved token" 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> <svg viewBox="0 0 24 24"><path d="M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5zM4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4V5z"/></svg>
</button> </button>
</div> </div>
@@ -136,29 +136,29 @@
<!-- Connection Banner --> <!-- Connection Banner -->
<div class="connection-banner hidden" id="connectionBanner"> <div class="connection-banner hidden" id="connectionBanner">
<span id="connectionBannerText"></span> <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> </div>
<!-- Tab Bar (editorial: numbered, italic active) --> <!-- Tab Bar (editorial: numbered, italic active) -->
<div class="tab-bar" id="tabBar" role="tablist"> <div class="tab-bar" id="tabBar" role="tablist">
<div class="tab-indicator" id="tabIndicator"></div> <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 class="tab-num">01</span>
<span data-i18n="tab.player">Now Spinning</span> <span data-i18n="tab.player">Now Spinning</span>
</button> </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 class="tab-num">02</span>
<span data-i18n="tab.display">Display</span> <span data-i18n="tab.display">Display</span>
</button> </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 class="tab-num">03</span>
<span data-i18n="tab.browser">Library</span> <span data-i18n="tab.browser">Library</span>
</button> </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 class="tab-num">04</span>
<span data-i18n="tab.quick_access">Quick Access</span> <span data-i18n="tab.quick_access">Quick Access</span>
</button> </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 class="tab-num">05</span>
<span data-i18n="tab.settings">Settings</span> <span data-i18n="tab.settings">Settings</span>
</button> </button>
@@ -172,7 +172,7 @@
<span class="fs-chrome-sep">·</span> <span class="fs-chrome-sep">·</span>
<span class="fs-chrome-kicker" data-i18n="player.kicker">Now Playing</span> <span class="fs-chrome-kicker" data-i18n="player.kicker">Now Playing</span>
</div> </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> <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> <span data-i18n="player.fullscreen.exit_short">Exit</span>
<kbd class="fs-chrome-kbd">ESC</kbd> <kbd class="fs-chrome-kbd">ESC</kbd>
@@ -267,13 +267,13 @@
</div> </div>
<div class="controls"> <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> <svg viewBox="0 0 24 24"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
</button> </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> <svg viewBox="0 0 24 24" id="play-pause-icon"><path d="M8 5v14l11-7z"/></svg>
</button> </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> <svg viewBox="0 0 24 24"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
</button> </button>
@@ -287,7 +287,7 @@
</div> </div>
<!-- Volume control: mute + slim slider, integrated --> <!-- Volume control: mute + slim slider, integrated -->
<div class="vu-volume"> <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"> <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"/> <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> </svg>
@@ -301,7 +301,7 @@
<!-- Hidden but functional: legacy display + visualizer toggle. --> <!-- Hidden but functional: legacy display + visualizer toggle. -->
<div class="visually-hidden"> <div class="visually-hidden">
<div id="volume-display">50%</div> <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> <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> </button>
</div> </div>
@@ -318,34 +318,34 @@
<div class="browser-toolbar" id="browserToolbar"> <div class="browser-toolbar" id="browserToolbar">
<div class="browser-toolbar-left"> <div class="browser-toolbar-left">
<div class="view-toggle"> <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> <svg viewBox="0 0 24 24" width="18" height="18"><path d="M3 3h8v8H3V3zm0 10h8v8H3v-8zm10-10h8v8h-8V3zm0 10h8v8h-8v-8z"/></svg>
</button> </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> <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>
<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> <svg viewBox="0 0 24 24" width="18" height="18"><path d="M3 4h18v2H3V4zm0 7h18v2H3v-2zm0 7h18v2H3v-2z"/></svg>
</button> </button>
</div> </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> <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>
<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> <svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M8 5v14l11-7z"/></svg>
</button> </button>
</div> </div>
<div class="browser-search-wrapper" id="browserSearchWrapper" style="display: none;"> <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> <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()"> <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" onclick="clearBrowserSearch()" style="display: none;"> <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> <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> </button>
</div> </div>
<div class="browser-toolbar-right"> <div class="browser-toolbar-right">
<label class="items-per-page-label"> <label class="items-per-page-label">
<span data-i18n="browser.items_per_page">Items per page:</span> <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="25">25</option>
<option value="50">50</option> <option value="50">50</option>
<option value="100" selected>100</option> <option value="100" selected>100</option>
@@ -366,13 +366,13 @@
<!-- Pagination --> <!-- Pagination -->
<div class="pagination" id="browserPagination" style="display: none;"> <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"> <div class="pagination-center">
<span data-i18n="browser.page">Page</span> <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> <span id="pageTotal">/ 1</span>
</div> </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> <span class="pagination-showing" id="paginationShowing"></span>
</div> </div>
</div> </div>
@@ -398,7 +398,7 @@
<div class="audio-device-selector"> <div class="audio-device-selector">
<label> <label>
<span data-i18n="settings.audio.device">Loopback Device</span> <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> <option value="" data-i18n="settings.audio.auto">Auto-detect</option>
</select> </select>
</label> </label>
@@ -434,7 +434,7 @@
</tr> </tr>
</tbody> </tbody>
</table> </table>
<div class="add-card" onclick="showAddFolderDialog()"> <div class="add-card" data-onclick="showAddFolderDialog()">
<span class="add-card-icon">+</span> <span class="add-card-icon">+</span>
</div> </div>
</div> </div>
@@ -464,7 +464,7 @@
</tr> </tr>
</tbody> </tbody>
</table> </table>
<div class="add-card" onclick="showAddScriptDialog()"> <div class="add-card" data-onclick="showAddScriptDialog()">
<span class="add-card-icon">+</span> <span class="add-card-icon">+</span>
</div> </div>
</div> </div>
@@ -496,7 +496,7 @@
</tr> </tr>
</tbody> </tbody>
</table> </table>
<div class="add-card" onclick="showAddLinkDialog()"> <div class="add-card" data-onclick="showAddLinkDialog()">
<span class="add-card-icon">+</span> <span class="add-card-icon">+</span>
</div> </div>
</div> </div>
@@ -522,20 +522,20 @@
<td colspan="4" class="empty-state"> <td colspan="4" class="empty-state">
<div class="empty-state-illustration"> <div class="empty-state-illustration">
<svg viewBox="0 0 64 64"><circle cx="32" cy="32" r="24"/><path d="M32 20v12l8 8"/></svg> <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> </div>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<div class="add-card" onclick="showAddCallbackDialog()"> <div class="add-card" data-onclick="showAddCallbackDialog()">
<span class="add-card-icon">+</span> <span class="add-card-icon">+</span>
</div> </div>
</div> </div>
</details> </details>
</div> </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-container" data-tab-content="display" role="tabpanel" id="panel-display">
<div class="display-monitors" id="displayMonitors"> <div class="display-monitors" id="displayMonitors">
<div class="empty-state-illustration"> <div class="empty-state-illustration">
@@ -543,6 +543,12 @@
<p data-i18n="display.loading">Loading monitors...</p> <p data-i18n="display.loading">Loading monitors...</p>
</div> </div>
</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>
</div> </div>
@@ -551,7 +557,7 @@
<div class="dialog-header"> <div class="dialog-header">
<h3 id="dialogTitle" data-i18n="scripts.dialog.add">Add Script</h3> <h3 id="dialogTitle" data-i18n="scripts.dialog.add">Add Script</h3>
</div> </div>
<form id="scriptForm" onsubmit="saveScript(event)"> <form id="scriptForm" data-onsubmit="saveScript(event)">
<div class="dialog-body"> <div class="dialog-body">
<input type="hidden" id="scriptOriginalName"> <input type="hidden" id="scriptOriginalName">
<input type="hidden" id="scriptIsEdit"> <input type="hidden" id="scriptIsEdit">
@@ -593,13 +599,13 @@
<div class="params-section"> <div class="params-section">
<div class="params-header"> <div class="params-header">
<span data-i18n="scripts.field.parameters">Parameters</span> <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>
<div id="scriptParamsContainer"></div> <div id="scriptParamsContainer"></div>
</div> </div>
</div> </div>
<div class="dialog-footer"> <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> <button type="submit" class="btn-primary" data-i18n="scripts.button.save">Save</button>
</div> </div>
</form> </form>
@@ -608,14 +614,14 @@
<!-- Script Parameters Input Dialog (shown before executing scripts with params) --> <!-- Script Parameters Input Dialog (shown before executing scripts with params) -->
<dialog id="scriptParamsDialog"> <dialog id="scriptParamsDialog">
<div class="dialog-header"> <div class="dialog-header">
<h3 id="scriptParamsDialogTitle">Execute Script</h3> <h3 id="scriptParamsDialogTitle" data-i18n="scripts.params.execute">Execute Script</h3>
</div> </div>
<form id="scriptParamsForm" onsubmit="submitScriptWithParams(event)"> <form id="scriptParamsForm" data-onsubmit="submitScriptWithParams(event)">
<div class="dialog-body"> <div class="dialog-body">
<div id="scriptParamsInputs"></div> <div id="scriptParamsInputs"></div>
</div> </div>
<div class="dialog-footer"> <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> <button type="submit" class="btn-primary" data-i18n="scripts.params.execute">Execute</button>
</div> </div>
</form> </form>
@@ -626,7 +632,7 @@
<div class="dialog-header"> <div class="dialog-header">
<h3 id="callbackDialogTitle" data-i18n="callbacks.dialog.add">Add Callback</h3> <h3 id="callbackDialogTitle" data-i18n="callbacks.dialog.add">Add Callback</h3>
</div> </div>
<form id="callbackForm" onsubmit="saveCallback(event)"> <form id="callbackForm" data-onsubmit="saveCallback(event)">
<div class="dialog-body"> <div class="dialog-body">
<input type="hidden" id="callbackIsEdit"> <input type="hidden" id="callbackIsEdit">
@@ -664,7 +670,7 @@
</label> </label>
</div> </div>
<div class="dialog-footer"> <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> <button type="submit" class="btn-primary" data-i18n="callbacks.button.save">Save</button>
</div> </div>
</form> </form>
@@ -675,7 +681,7 @@
<div class="dialog-header"> <div class="dialog-header">
<h3 id="linkDialogTitle" data-i18n="links.dialog.add">Add Link</h3> <h3 id="linkDialogTitle" data-i18n="links.dialog.add">Add Link</h3>
</div> </div>
<form id="linkForm" onsubmit="saveLink(event)"> <form id="linkForm" data-onsubmit="saveLink(event)">
<div class="dialog-body"> <div class="dialog-body">
<input type="hidden" id="linkOriginalName"> <input type="hidden" id="linkOriginalName">
<input type="hidden" id="linkIsEdit"> <input type="hidden" id="linkIsEdit">
@@ -710,7 +716,7 @@
</label> </label>
</div> </div>
<div class="dialog-footer"> <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> <button type="submit" class="btn-primary" data-i18n="links.button.save">Save</button>
</div> </div>
</form> </form>
@@ -737,7 +743,7 @@
</div> </div>
</div> </div>
<div class="dialog-footer"> <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> </div>
</dialog> </dialog>
@@ -746,7 +752,7 @@
<div class="dialog-header"> <div class="dialog-header">
<h3 id="folderDialogTitle" data-i18n="browser.folder_dialog.title_add">Add Media Folder</h3> <h3 id="folderDialogTitle" data-i18n="browser.folder_dialog.title_add">Add Media Folder</h3>
</div> </div>
<form id="folderForm" onsubmit="saveFolder(event)"> <form id="folderForm" data-onsubmit="saveFolder(event)">
<div class="dialog-body"> <div class="dialog-body">
<input type="hidden" id="folderIsEdit"> <input type="hidden" id="folderIsEdit">
<input type="hidden" id="folderOriginalId"> <input type="hidden" id="folderOriginalId">
@@ -773,7 +779,7 @@
</label> </label>
</div> </div>
<div class="dialog-footer"> <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> <button type="submit" class="btn-primary" data-i18n="browser.folder_dialog.save">Save</button>
</div> </div>
</form> </form>
@@ -813,7 +819,7 @@
</ul> </ul>
</div> </div>
<div class="dialog-footer"> <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> </div>
</dialog> </dialog>
+72
View File
@@ -63,6 +63,8 @@ import {
import { import {
loadDisplayMonitors, onDisplayBrightnessInput, onDisplayBrightnessChange, loadDisplayMonitors, onDisplayBrightnessInput, onDisplayBrightnessChange,
onDisplayContrastInput, onDisplayContrastChange,
onDisplayInputSourceChange, onDisplayColorPresetChange, onDisplayPictureModeChange,
toggleDisplayPower, loadHeaderLinks, loadLinksTable, toggleDisplayPower, loadHeaderLinks, loadLinksTable,
showAddLinkDialog, showEditLinkDialog, closeLinkDialog, saveLink, deleteLinkConfirm, showAddLinkDialog, showEditLinkDialog, closeLinkDialog, saveLink, deleteLinkConfirm,
linkFormDirty, setLinkFormDirty, linkFormDirty, setLinkFormDirty,
@@ -72,6 +74,10 @@ import {
toggleDynamicBackground, applyDynamicBackground, updateBackgroundColors, toggleDynamicBackground, applyDynamicBackground, updateBackgroundColors,
} from './background.js'; } from './background.js';
import {
updateForegroundUI, loadForegroundProcess,
} from './foreground.js';
// ============================================================ // ============================================================
// Register late-bound callbacks for core's updateAllText() // Register late-bound callbacks for core's updateAllText()
// ============================================================ // ============================================================
@@ -127,11 +133,15 @@ Object.assign(window, {
saveLink, deleteLinkConfirm, saveLink, deleteLinkConfirm,
// Display // Display
loadDisplayMonitors, onDisplayBrightnessInput, onDisplayBrightnessChange, loadDisplayMonitors, onDisplayBrightnessInput, onDisplayBrightnessChange,
onDisplayContrastInput, onDisplayContrastChange,
onDisplayInputSourceChange, onDisplayColorPresetChange, onDisplayPictureModeChange,
toggleDisplayPower, toggleDisplayPower,
// Audio device // Audio device
onAudioDeviceChanged, onAudioDeviceChanged,
// About // About
showAboutDialog, closeAboutDialog, showAboutDialog, closeAboutDialog,
// Foreground
loadForegroundProcess,
}); });
// ============================================================ // ============================================================
@@ -155,10 +165,72 @@ HTMLDialogElement.prototype.showModal = function (...args) {
return result; 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 () => { window.addEventListener('DOMContentLoaded', async () => {
// Cache DOM references // Cache DOM references
cacheDom(); cacheDom();
// Wire CSP-safe inline-handler stand-ins from index.html
wireInlineHandlers(document);
// Initialize theme and accent color // Initialize theme and accent color
initTheme(); initTheme();
initAccentColor(); initAccentColor();
+50 -20
View File
@@ -66,12 +66,14 @@ function showRootFolders() {
// Hide search at root level // Hide search at root level
showBrowserSearch(false); 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'); const breadcrumb = document.getElementById('breadcrumb');
breadcrumb.innerHTML = ''; breadcrumb.innerHTML = '';
const root = document.createElement('span'); const root = document.createElement('span');
root.className = 'breadcrumb-item breadcrumb-home'; 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); breadcrumb.appendChild(root);
// Hide play all button and pagination // Hide play all button and pagination
@@ -133,8 +135,10 @@ function showRootFolders() {
} }
async function browsePath(folderId, path, offset = 0, nocache = false) { 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); showBrowserSearch(false);
bumpBrowseGen();
try { try {
if (!hasCredentials()) return; if (!hasCredentials()) return;
@@ -195,10 +199,13 @@ function renderBreadcrumbs(currentPathStr, parentPath) {
const parts = (currentPathStr || '').split('/').filter(p => p); const parts = (currentPathStr || '').split('/').filter(p => p);
let path = '/'; let path = '/';
// Home link (back to folder list) // Home link (back to folder list) — use a real <button> so it's
const home = document.createElement('span'); // keyboard-focusable and reachable by screen readers.
const home = document.createElement('button');
home.type = 'button';
home.className = 'breadcrumb-item breadcrumb-home'; 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(); home.onclick = () => showRootFolders();
breadcrumb.appendChild(home); breadcrumb.appendChild(home);
@@ -512,16 +519,34 @@ function formatBitrate(bps) {
return Math.round(bps / 1000) + ' kbps'; 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) { async function loadThumbnail(imgElement, fileName) {
const myGen = currentBrowseGen();
const folderId = currentFolderId;
const relPath = buildRelativeFilePath(currentPath, fileName);
const cacheKey = `${folderId}|${relPath}`;
try { try {
if (!hasCredentials()) return; if (!hasCredentials()) return;
// Note: the imgElement is intentionally NOT in the DOM yet when
const absolutePath = buildAbsolutePath(currentFolderId, currentPath, fileName); // 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 // Check cache first
if (thumbnailCache.has(absolutePath)) { if (thumbnailCache.has(cacheKey)) {
const cachedUrl = thumbnailCache.get(absolutePath); const cachedUrl = thumbnailCache.get(cacheKey);
imgElement.onload = () => { imgElement.onload = () => {
if (!imgElement.isConnected) return;
imgElement.classList.remove('loading'); imgElement.classList.remove('loading');
imgElement.classList.add('loaded'); imgElement.classList.add('loaded');
}; };
@@ -529,17 +554,24 @@ async function loadThumbnail(imgElement, fileName) {
return; return;
} }
const encodedPath = encodeURIComponent(absolutePath); const params = new URLSearchParams({
folder_id: folderId,
path: relPath,
size: 'medium',
});
const response = await fetch( const response = await fetch(
`/api/browser/thumbnail?path=${encodedPath}&size=medium`, `/api/browser/thumbnail?${params.toString()}`,
{ headers: getAuthHeaders() } { headers: getAuthHeaders() }
); );
// Drop the response if the user has since navigated away.
if (myGen !== currentBrowseGen() || !imgElement.isConnected) return;
if (response.status === 200) { if (response.status === 200) {
const blob = await response.blob(); const blob = await response.blob();
if (myGen !== currentBrowseGen() || !imgElement.isConnected) return;
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
thumbnailCache.set(absolutePath, url); thumbnailCache.set(cacheKey, url);
// Evict oldest entries when cache exceeds limit // Evict oldest entries when cache exceeds limit
if (thumbnailCache.size > THUMBNAIL_CACHE_MAX) { if (thumbnailCache.size > THUMBNAIL_CACHE_MAX) {
@@ -548,13 +580,11 @@ async function loadThumbnail(imgElement, fileName) {
thumbnailCache.delete(oldest); thumbnailCache.delete(oldest);
} }
// Wait for image to actually load before showing it
imgElement.onload = () => { imgElement.onload = () => {
imgElement.classList.remove('loading'); imgElement.classList.remove('loading');
imgElement.classList.add('loaded'); imgElement.classList.add('loaded');
}; };
// Revoke previous blob URL if not managed by cache
if (imgElement.src && imgElement.src.startsWith('blob:')) { if (imgElement.src && imgElement.src.startsWith('blob:')) {
let isCached = false; let isCached = false;
for (const cachedUrl of thumbnailCache.values()) { for (const cachedUrl of thumbnailCache.values()) {
@@ -564,8 +594,8 @@ async function loadThumbnail(imgElement, fileName) {
} }
imgElement.src = url; imgElement.src = url;
} else { } else {
// Fallback to icon (204 = no thumbnail available)
const parent = imgElement.parentElement; const parent = imgElement.parentElement;
if (!parent) return;
const isList = parent.classList.contains('browser-list-icon'); const isList = parent.classList.contains('browser-list-icon');
imgElement.remove(); imgElement.remove();
if (isList) { if (isList) {
@@ -579,7 +609,7 @@ async function loadThumbnail(imgElement, fileName) {
} }
} catch (error) { } catch (error) {
console.error('Error loading thumbnail:', 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 { try {
if (!hasCredentials()) return; if (!hasCredentials()) return;
const absolutePath = buildAbsolutePath(currentFolderId, currentPath, fileName); const relativePath = buildRelativeFilePath(currentPath, fileName);
const response = await fetch('/api/browser/play', { const response = await fetch('/api/browser/play', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, 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'); if (!response.ok) throw new Error('Failed to play file');
+13 -12
View File
@@ -81,7 +81,7 @@ async function _loadCallbacksTableImpl() {
`).join(''); `).join('');
} catch (error) { } catch (error) {
console.error('Error loading callbacks:', 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); const callback = callbacksList.find(c => c.name === callbackName);
if (!callback) { if (!callback) {
showToast('Callback not found', 'error'); showToast(t('callbacks.msg.not_found'), 'error');
return; return;
} }
@@ -142,7 +142,7 @@ export async function showEditCallbackDialog(callbackName) {
dialog.showModal(); dialog.showModal();
} catch (error) { } catch (error) {
console.error('Error loading callback for edit:', 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 shell: true
}; };
const encodedName = encodeURIComponent(callbackName);
const endpoint = isEdit ? const endpoint = isEdit ?
`/api/callbacks/update/${callbackName}` : `/api/callbacks/update/${encodedName}` :
`/api/callbacks/create/${callbackName}`; `/api/callbacks/create/${encodedName}`;
const method = isEdit ? 'PUT' : 'POST'; const method = isEdit ? 'PUT' : 'POST';
@@ -191,16 +192,16 @@ export async function saveCallback(event) {
const result = await response.json(); const result = await response.json();
if (response.ok && result.success) { if (response.ok && result.success) {
showToast(`Callback ${isEdit ? 'updated' : 'created'} successfully`, 'success'); showToast(t(isEdit ? 'callbacks.msg.updated' : 'callbacks.msg.created'), 'success');
callbackFormDirty = false; callbackFormDirty = false;
closeCallbackDialog(); closeCallbackDialog();
loadCallbacksTable(); loadCallbacksTable();
} else { } 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) { } catch (error) {
console.error('Error saving callback:', 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 { } finally {
if (submitBtn) submitBtn.disabled = false; if (submitBtn) submitBtn.disabled = false;
} }
@@ -212,7 +213,7 @@ export async function deleteCallbackConfirm(callbackName) {
} }
try { try {
const response = await fetch(`/api/callbacks/delete/${callbackName}`, { const response = await fetch(`/api/callbacks/delete/${encodeURIComponent(callbackName)}`, {
method: 'DELETE', method: 'DELETE',
headers: getAuthHeaders() headers: getAuthHeaders()
}); });
@@ -220,13 +221,13 @@ export async function deleteCallbackConfirm(callbackName) {
const result = await response.json(); const result = await response.json();
if (response.ok && result.success) { if (response.ok && result.success) {
showToast('Callback deleted successfully', 'success'); showToast(t('callbacks.msg.deleted'), 'success');
loadCallbacksTable(); loadCallbacksTable();
} else { } else {
showToast(result.detail || 'Failed to delete callback', 'error'); showToast(result.detail || t('callbacks.msg.delete_failed'), 'error');
} }
} catch (error) { } catch (error) {
console.error('Error deleting callback:', error); console.error('Error deleting callback:', error);
showToast('Error deleting callback', 'error'); showToast(t('callbacks.msg.delete_failed'), 'error');
} }
} }
+59 -7
View File
@@ -155,6 +155,7 @@ export const VOLUME_RELEASE_DELAY_MS = 500;
// Shared state (accessed across multiple modules) // Shared state (accessed across multiple modules)
export let ws = null; export let ws = null;
export function setWs(value) { ws = value; } export function setWs(value) { ws = value; }
export function getWs() { return ws; }
export let currentState = 'idle'; export let currentState = 'idle';
export function setCurrentState(value) { currentState = value; } export function setCurrentState(value) { currentState = value; }
export let currentDuration = 0; export let currentDuration = 0;
@@ -513,6 +514,12 @@ export function setVolume(volume) {
sendCommand('volume', { volume: 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() { export function toggleMute() {
sendCommand('mute'); sendCommand('mute');
@@ -536,23 +543,68 @@ function _persistMdiCache() {
try { localStorage.setItem('mdiIconCache', JSON.stringify(mdiIconCache)); } catch {} 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) { 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]; if (mdiIconCache[name]) return mdiIconCache[name];
try { 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) { if (response.ok) {
const svg = await response.text(); const raw = await response.text();
mdiIconCache[name] = svg; const safe = sanitizeSvg(raw);
_persistMdiCache(); if (safe) {
return svg; mdiIconCache[name] = safe;
_persistMdiCache();
return safe;
}
} }
} catch (e) { } catch (e) {
console.warn('Failed to fetch MDI icon:', name, 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) { export async function resolveMdiIcons(container) {
+188
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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);
}
}
+381 -21
View File
@@ -1,12 +1,107 @@
// ============================================================ // ============================================================
// Display Brightness & Power Control + Links Management // Display Brightness, Power, Contrast, Input Source, Color Preset,
// Picture Mode Control + Links Management
// ============================================================ // ============================================================
import { t, showToast, escapeHtml, closeDialog, showConfirm, resolveMdiIcons, fetchMdiIcon, getAuthHeaders, hasCredentials } from './core.js'; import { t, showToast, escapeHtml, closeDialog, showConfirm, resolveMdiIcons, fetchMdiIcon, getAuthHeaders, hasCredentials } from './core.js';
import { IconSelect } from './icon-select.js';
let displayBrightnessTimers = {}; let displayBrightnessTimers = {};
let displayContrastTimers = {};
let _displayIconSelects = [];
const DISPLAY_THROTTLE_MS = 50; const DISPLAY_THROTTLE_MS = 50;
// ─── Icon palette for the tuning IconSelects ───────────────────────────
// All SVGs are 24x24 monochrome — IconSelect's CSS fills them with currentColor.
const ICON_PORT_GENERIC =
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M4 8h16v8H4V8zm2 2v4h12v-4H6zm2 1h2v2H8v-2zm4 0h2v2h-2v-2zm-9 6h18v2H3v-2z"/></svg>';
const ICON_PORT_HDMI =
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M3 9l2-2h14l2 2v5l-2 2h-3l-1 1H9l-1-1H5l-2-2V9zm2.5.5v4l1 1h2l1 1h7l1-1h2l1-1v-4l-1-.5H6.5l-1 .5z"/></svg>';
const ICON_PORT_DP =
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M4 8l2-2h12l2 2v8l-2 2H6l-2-2V8zm2 .5V15l1 1h10l1-1V8.5L17 8H7l-1 .5zM8 10h2v4H8v-4zm6 0h2v4h-2v-4z"/></svg>';
const ICON_PORT_DVI =
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M3 8h18v8H3V8zm2 1.5v5h14v-5H5zM7 11h1.5v2H7v-2zm3 0h1.5v2H10v-2zm3 0h1.5v2H13v-2zm3 0h1.5v2H16v-2z"/></svg>';
const ICON_PORT_VGA =
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M5 7h14a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2V9a2 2 0 012-2zm0 2v6h14V9H5zm2 1.5a.75.75 0 100 1.5.75.75 0 000-1.5zm3 0a.75.75 0 100 1.5.75.75 0 000-1.5zm3 0a.75.75 0 100 1.5.75.75 0 000-1.5zm3 0a.75.75 0 100 1.5.75.75 0 000-1.5z"/></svg>';
const ICON_PORT_USBC =
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M5 10a3 3 0 013-3h8a3 3 0 013 3v4a3 3 0 01-3 3H8a3 3 0 01-3-3v-4zm3-1.5A1.5 1.5 0 006.5 10v4A1.5 1.5 0 008 15.5h8a1.5 1.5 0 001.5-1.5v-4A1.5 1.5 0 0016 8.5H8zm1 2h6v3H9v-3z"/></svg>';
const ICON_THERMOMETER =
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M12 3a3 3 0 00-3 3v8.17A4 4 0 1015 14.17V6a3 3 0 00-3-3zm-1.5 3a1.5 1.5 0 113 0v8.76a2.5 2.5 0 11-3 0V6zm1.5 5a1 1 0 011 1v2.27a1.5 1.5 0 11-2 0V12a1 1 0 011-1z"/></svg>';
const ICON_MODE_MOVIE =
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M4 5h16v14H4V5zm2 2v2h2V7H6zm0 4v2h2v-2H6zm0 4v2h2v-2H6zm10-8v2h2V7h-2zm0 4v2h2v-2h-2zm0 4v2h2v-2h-2zm-6-7h4v8h-4V8z"/></svg>';
const ICON_MODE_GAME =
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M7 8a5 5 0 00-5 5 4 4 0 007.4 2.1L11 14h2l1.6 1.1A4 4 0 0022 13a5 5 0 00-5-5H7zm1 3v1H7v-1H6v-1h1V9h1v1h1v1H8zm7 0a1 1 0 110-2 1 1 0 010 2zm2 2a1 1 0 110-2 1 1 0 010 2z"/></svg>';
const ICON_MODE_SPORT =
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M12 2a10 10 0 100 20 10 10 0 000-20zm0 2c1.7 0 3.3.5 4.6 1.4l-1.4 2.4L12 6.5l-3.2 1.3-1.4-2.4A8 8 0 0112 4zm-7.6 4l2.5 1.3-.5 3.5L4 16.4A8 8 0 014.4 8zm15.2 0a8 8 0 01.4 8.4l-2.4-1.6-.5-3.5L19.6 8zM12 8.7l3 1.2.6 3.2L13 15h-2l-2.6-1.9.6-3.2L12 8.7zm-5.3 8.8L9 16.5l2.4 1h1.2l2.4-1 2.3 1A8 8 0 016.7 17.5z"/></svg>';
const ICON_MODE_PRO =
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M3 4h18v12H13v2h4v2H7v-2h4v-2H3V4zm2 2v8h14V6H5zm2 2h6v2H7V8zm0 3h10v2H7v-2z"/></svg>';
const ICON_MODE_DOCS =
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M6 3h9l4 4v14H6V3zm2 2v14h10V9h-4V5H8zm2 6h6v2h-6v-2zm0 3h6v2h-6v-2z"/></svg>';
const ICON_MODE_USER =
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M12 3a4 4 0 100 8 4 4 0 000-8zm0 2a2 2 0 110 4 2 2 0 010-4zm0 8c-3.3 0-7 1.5-7 4.5V20h14v-2.5c0-3-3.7-4.5-7-4.5z"/></svg>';
const ICON_MODE_DEFAULT =
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M3 5h18v12H3V5zm2 2v8h14V7H5zm-2 12h18v2H3v-2z"/></svg>';
const ICON_MODE_MIXED =
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M3 5h18v14H3V5zm2 2v10h7V7H5zm9 0v10h5V7h-5z"/></svg>';
function inputSourceIcon(src) {
const s = String(src || '').toUpperCase();
if (s.startsWith('HDMI')) return ICON_PORT_HDMI;
if (s.startsWith('DP')) return ICON_PORT_DP;
if (s.startsWith('DVI')) return ICON_PORT_DVI;
if (s.startsWith('VGA')) return ICON_PORT_VGA;
if (s.startsWith('USB')) return ICON_PORT_USBC;
return ICON_PORT_GENERIC;
}
function pictureModeIcon(label) {
const k = String(label || '').toLowerCase();
if (k.includes('movie')) return ICON_MODE_MOVIE;
if (k.includes('game')) return ICON_MODE_GAME;
if (k.includes('sport')) return ICON_MODE_SPORT;
if (k.includes('professional')) return ICON_MODE_PRO;
if (k.includes('productivity')) return ICON_MODE_DOCS;
if (k.includes('user')) return ICON_MODE_USER;
if (k.includes('mixed')) return ICON_MODE_MIXED;
return ICON_MODE_DEFAULT;
}
// Humanise enum-style identifiers returned by monitorcontrol so users
// don't see SHOUT_CASE strings in the UI.
function humanizeInputSource(raw) {
if (!raw) return '';
// OFF / RESERVED → "Off" / "Reserved"
// VGA1 → "VGA 1", HDMI1 → "HDMI 1", DP1 → "DisplayPort 1"
const map = { DP: 'DisplayPort', DVI: 'DVI', HDMI: 'HDMI', VGA: 'VGA', USBC: 'USB-C' };
const m = String(raw).toUpperCase().match(/^(DP|DVI|HDMI|VGA|USBC|USB_C)(\d*)$/);
if (m) {
const key = m[1] === 'USB_C' ? 'USBC' : m[1];
return `${map[key]}${m[2] ? ' ' + m[2] : ''}`;
}
return String(raw)
.replace(/_/g, ' ')
.toLowerCase()
.replace(/\b\w/g, c => c.toUpperCase());
}
function humanizeColorPreset(raw) {
if (!raw) return '';
// COLOR_TEMP_6500K → "6500 K", COLOR_TEMP_NATIVE → "Native",
// COLOR_TEMP_USER1 → "User 1"
const s = String(raw).replace(/^COLOR_TEMP_?/i, '');
const kelvin = s.match(/^(\d{4,5})K?$/);
if (kelvin) return `${kelvin[1]} K`;
const user = s.match(/^USER\s*_?(\d+)$/i);
if (user) return `User ${user[1]}`;
return s
.replace(/_/g, ' ')
.toLowerCase()
.replace(/\b\w/g, c => c.toUpperCase());
}
export async function loadDisplayMonitors() { export async function loadDisplayMonitors() {
if (!hasCredentials()) return; if (!hasCredentials()) return;
@@ -14,7 +109,7 @@ export async function loadDisplayMonitors() {
if (!container) return; if (!container) return;
try { try {
const response = await fetch('/api/display/monitors?refresh=true', { const response = await fetch('/api/display/monitors', {
headers: getAuthHeaders() headers: getAuthHeaders()
}); });
@@ -36,7 +131,13 @@ export async function loadDisplayMonitors() {
return; return;
} }
// Destroy IconSelects from a previous render so listeners + popups
// don't pile up.
_displayIconSelects.forEach(inst => { try { inst.destroy(); } catch (_) {} });
_displayIconSelects = [];
container.innerHTML = ''; container.innerHTML = '';
const pendingIconSelects = [];
monitors.forEach(monitor => { monitors.forEach(monitor => {
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'display-monitor-card'; card.className = 'display-monitor-card';
@@ -47,16 +148,19 @@ export async function loadDisplayMonitors() {
let powerBtn = ''; let powerBtn = '';
if (monitor.power_supported) { 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 = ` powerBtn = `
<button class="display-power-btn ${monitor.power_on ? 'on' : 'off'}" id="power-btn-${monitor.id}" <button class="display-power-btn ${monitor.power_on ? 'on' : 'off'}" id="power-btn-${monitor.id}"
onclick="toggleDisplayPower(${monitor.id}, '${monitor.name.replace(/'/g, "\\'")}')" data-action="toggle-power" data-monitor-id="${monitor.id}"
title="${monitor.power_on ? t('display.power_off') : t('display.power_on')}"> 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> <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>`; </button>`;
} }
const details = [monitor.resolution, monitor.manufacturer].filter(Boolean).join(' \u00B7 '); 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 const primaryBadge = monitor.is_primary
? `<span class="display-primary-badge" title="${t('display.primary')}" aria-label="${t('display.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"> <svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
@@ -65,29 +169,169 @@ export async function loadDisplayMonitors() {
</span>` </span>`
: ''; : '';
// Contrast (DDC/CI) — render only if the monitor reports it.
let contrastRow = '';
if (monitor.contrast_supported) {
const contrastValue = monitor.contrast !== null && monitor.contrast !== undefined
? monitor.contrast : 50;
contrastRow = `
<div class="display-slider-row">
<svg class="display-slider-icon" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18V4c4.41 0 8 3.59 8 8s-3.59 8-8 8z"/>
</svg>
<span class="display-slider-label" data-i18n="display.contrast">${t('display.contrast')}</span>
<input type="range" class="display-slider display-contrast-slider"
min="0" max="100" value="${contrastValue}"
data-display-slider="contrast" data-monitor-id="${monitor.id}">
<span class="display-slider-value" id="contrast-val-${monitor.id}">${contrastValue}%</span>
</div>`;
}
// Build the picture-tuning selects (input source / color preset / picture mode).
const tuningRows = [];
// Each tuning field renders a hidden <select> (state holder)
// which IconSelect then enhances after the card lands in the DOM.
const tuningTargets = [];
if (monitor.input_source_supported && monitor.available_input_sources.length > 0) {
const current = monitor.input_source;
const options = monitor.available_input_sources.map(src => {
const selected = src === current ? 'selected' : '';
return `<option value="${escapeHtml(src)}" ${selected}>${escapeHtml(humanizeInputSource(src))}</option>`;
}).join('');
tuningRows.push(`
<div class="display-tuning-field">
<span class="display-tuning-label" data-i18n="display.input_source">${t('display.input_source')}</span>
<select data-display-select="input" data-monitor-id="${monitor.id}"
aria-label="${t('display.input_source')}">
${options}
</select>
</div>`);
tuningTargets.push({
kind: 'input',
monitorId: monitor.id,
items: monitor.available_input_sources.map(src => ({
value: src,
icon: inputSourceIcon(src),
label: humanizeInputSource(src),
})),
});
}
if (monitor.color_preset_supported && monitor.available_color_presets.length > 0) {
const current = monitor.color_preset;
const options = monitor.available_color_presets.map(p => {
const selected = p === current ? 'selected' : '';
return `<option value="${escapeHtml(p)}" ${selected}>${escapeHtml(humanizeColorPreset(p))}</option>`;
}).join('');
tuningRows.push(`
<div class="display-tuning-field">
<span class="display-tuning-label" data-i18n="display.color_preset">${t('display.color_preset')}</span>
<select data-display-select="color" data-monitor-id="${monitor.id}"
aria-label="${t('display.color_preset')}">
${options}
</select>
</div>`);
tuningTargets.push({
kind: 'color',
monitorId: monitor.id,
items: monitor.available_color_presets.map(p => ({
value: p,
icon: ICON_THERMOMETER,
label: humanizeColorPreset(p),
})),
});
}
if (monitor.picture_mode_supported && monitor.available_picture_modes.length > 0) {
const current = monitor.picture_mode_code;
const options = monitor.available_picture_modes.map(m => {
const selected = m.code === current ? 'selected' : '';
return `<option value="${m.code}" ${selected}>${escapeHtml(m.label)}</option>`;
}).join('');
tuningRows.push(`
<div class="display-tuning-field">
<span class="display-tuning-label" data-i18n="display.picture_mode">${t('display.picture_mode')}</span>
<select data-display-select="mode" data-monitor-id="${monitor.id}"
aria-label="${t('display.picture_mode')}">
${options}
</select>
</div>`);
tuningTargets.push({
kind: 'mode',
monitorId: monitor.id,
items: monitor.available_picture_modes.map(m => ({
value: String(m.code),
icon: pictureModeIcon(m.label),
label: m.label,
})),
});
}
pendingIconSelects.push(...tuningTargets);
const tuningBlock = tuningRows.length > 0
? `<div class="display-tuning">
<div class="display-tuning-title" data-i18n="display.tuning">${t('display.tuning')}</div>
<div class="display-tuning-grid">${tuningRows.join('')}</div>
</div>`
: '';
card.innerHTML = ` card.innerHTML = `
<div class="display-monitor-header"> <div class="display-monitor-header">
<svg class="display-monitor-icon" viewBox="0 0 24 24" width="20" height="20"> <svg class="display-monitor-icon" viewBox="0 0 24 24" width="20" height="20">
<path fill="currentColor" d="M20 3H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h6v2H8v2h8v-2h-2v-2h6c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 12H4V5h16v10z"/> <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> </svg>
<div class="display-monitor-info"> <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} ${detailsHtml}
</div> </div>
${powerBtn} ${powerBtn}
</div> </div>
<div class="display-brightness-control"> <div class="display-slider-row display-brightness-control">
<svg class="display-brightness-icon" viewBox="0 0 24 24" width="16" height="16"> <svg class="display-slider-icon display-brightness-icon" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path fill="currentColor" d="M20 8.69V4h-4.69L12 .69 8.69 4H4v4.69L.69 12 4 15.31V20h4.69L12 23.31 15.31 20H20v-4.69L23.31 12 20 8.69zM12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6 6 2.69 6 6-2.69 6-6 6zm0-10c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4z"/> <path fill="currentColor" d="M20 8.69V4h-4.69L12 .69 8.69 4H4v4.69L.69 12 4 15.31V20h4.69L12 23.31 15.31 20H20v-4.69L23.31 12 20 8.69zM12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6 6 2.69 6 6-2.69 6-6 6zm0-10c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4z"/>
</svg> </svg>
<input type="range" class="display-brightness-slider" min="0" max="100" value="${brightnessValue}" ${brightnessDisabled} <span class="display-slider-label" data-i18n="display.brightness">${t('display.brightness')}</span>
oninput="onDisplayBrightnessInput(${monitor.id}, this.value)" <input type="range" class="display-slider display-brightness-slider" min="0" max="100" value="${brightnessValue}" ${brightnessDisabled}
onchange="onDisplayBrightnessChange(${monitor.id}, this.value)"> data-display-slider="brightness" data-monitor-id="${monitor.id}">
<span class="display-brightness-value" id="brightness-val-${monitor.id}">${brightnessValue}%</span> <span class="display-slider-value display-brightness-value" id="brightness-val-${monitor.id}">${brightnessValue}%</span>
</div>`; </div>
${contrastRow}
${tuningBlock}`;
container.appendChild(card); container.appendChild(card);
}); });
// Bind a single delegated click handler for the power buttons,
// plus input/change handlers for the brightness & contrast sliders.
// Avoids inline on* attributes (blocked by script-src 'self' CSP).
container.removeEventListener('click', _onPowerButtonClick);
container.addEventListener('click', _onPowerButtonClick);
container.removeEventListener('input', _onDisplaySliderInput);
container.addEventListener('input', _onDisplaySliderInput);
container.removeEventListener('change', _onDisplaySliderChange);
container.addEventListener('change', _onDisplaySliderChange);
// Enhance every tuning <select> with an IconSelect now that the
// cards are in the DOM (IconSelect needs offsetParent + sibling).
pendingIconSelects.forEach(({ kind, monitorId, items }) => {
const sel = container.querySelector(
`select[data-display-select="${kind}"][data-monitor-id="${monitorId}"]`
);
if (!sel) return;
const handler = kind === 'input' ? onDisplayInputSourceChange
: kind === 'color' ? onDisplayColorPresetChange
: onDisplayPictureModeChange;
_displayIconSelects.push(new IconSelect({
target: sel,
items,
columns: 1,
horizontal: true,
onChange: (value) => handler(monitorId, value),
}));
});
} catch (e) { } catch (e) {
console.error('Failed to load display monitors:', e); console.error('Failed to load display monitors:', e);
} }
@@ -124,7 +368,122 @@ async function sendDisplayBrightness(monitorId, brightness) {
} }
} }
export async function toggleDisplayPower(monitorId, monitorName) { export function onDisplayContrastInput(monitorId, value) {
const label = document.getElementById(`contrast-val-${monitorId}`);
if (label) label.textContent = `${value}%`;
if (displayContrastTimers[monitorId]) clearTimeout(displayContrastTimers[monitorId]);
displayContrastTimers[monitorId] = setTimeout(() => {
sendDisplayContrast(monitorId, parseInt(value));
displayContrastTimers[monitorId] = null;
}, DISPLAY_THROTTLE_MS);
}
export function onDisplayContrastChange(monitorId, value) {
if (displayContrastTimers[monitorId]) {
clearTimeout(displayContrastTimers[monitorId]);
displayContrastTimers[monitorId] = null;
}
sendDisplayContrast(monitorId, parseInt(value));
}
async function sendDisplayContrast(monitorId, contrast) {
try {
const r = await fetch(`/api/display/contrast/${monitorId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
body: JSON.stringify({ contrast })
});
const data = await r.json().catch(() => ({}));
if (!data.success) showToast(t('display.msg.contrast_failed'), 'error');
} catch (e) {
console.error('Failed to set contrast:', e);
showToast(t('display.msg.contrast_failed'), 'error');
}
}
export async function onDisplayInputSourceChange(monitorId, source) {
try {
const r = await fetch(`/api/display/input_source/${monitorId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
body: JSON.stringify({ source })
});
const data = await r.json().catch(() => ({}));
if (data.success) showToast(t('display.msg.input_changed'), 'success');
else showToast(t('display.msg.input_failed'), 'error');
} catch (e) {
console.error('Failed to set input source:', e);
showToast(t('display.msg.input_failed'), 'error');
}
}
export async function onDisplayColorPresetChange(monitorId, preset) {
try {
const r = await fetch(`/api/display/color_preset/${monitorId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
body: JSON.stringify({ preset })
});
const data = await r.json().catch(() => ({}));
if (data.success) showToast(t('display.msg.color_changed'), 'success');
else showToast(t('display.msg.color_failed'), 'error');
} catch (e) {
console.error('Failed to set color preset:', e);
showToast(t('display.msg.color_failed'), 'error');
}
}
export async function onDisplayPictureModeChange(monitorId, codeRaw) {
const code = parseInt(codeRaw, 10);
if (Number.isNaN(code)) return;
try {
const r = await fetch(`/api/display/picture_mode/${monitorId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
body: JSON.stringify({ code })
});
const data = await r.json().catch(() => ({}));
if (data.success) showToast(t('display.msg.mode_changed'), 'success');
else showToast(t('display.msg.mode_failed'), 'error');
} catch (e) {
console.error('Failed to set picture mode:', e);
showToast(t('display.msg.mode_failed'), 'error');
}
}
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);
}
function _onDisplaySliderInput(event) {
const el = event.target.closest('input[data-display-slider]');
if (!el) return;
const id = Number(el.dataset.monitorId);
if (!Number.isFinite(id)) return;
if (el.dataset.displaySlider === 'brightness') {
onDisplayBrightnessInput(id, el.value);
} else if (el.dataset.displaySlider === 'contrast') {
onDisplayContrastInput(id, el.value);
}
}
function _onDisplaySliderChange(event) {
const el = event.target.closest('input[data-display-slider]');
if (!el) return;
const id = Number(el.dataset.monitorId);
if (!Number.isFinite(id)) return;
if (el.dataset.displaySlider === 'brightness') {
onDisplayBrightnessChange(id, el.value);
} else if (el.dataset.displaySlider === 'contrast') {
onDisplayContrastChange(id, el.value);
}
}
export async function toggleDisplayPower(monitorId) {
const btn = document.getElementById(`power-btn-${monitorId}`); const btn = document.getElementById(`power-btn-${monitorId}`);
const isOn = btn && btn.classList.contains('on'); const isOn = btn && btn.classList.contains('on');
const newState = !isOn; const newState = !isOn;
@@ -142,13 +501,13 @@ export async function toggleDisplayPower(monitorId, monitorName) {
btn.classList.toggle('off', !newState); btn.classList.toggle('off', !newState);
btn.title = newState ? t('display.power_off') : t('display.power_on'); 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 { } else {
showToast('Failed to change monitor power', 'error'); showToast(t('display.msg.power_failed'), 'error');
} }
} catch (e) { } catch (e) {
console.error('Failed to set display power:', e); console.error('Failed to set display power:', e);
showToast('Failed to change monitor power', 'error'); showToast(t('display.msg.power_failed'), 'error');
} }
} }
@@ -245,7 +604,7 @@ async function _loadLinksTableImpl() {
resolveMdiIcons(tbody); resolveMdiIcons(tbody);
} catch (error) { } catch (error) {
console.error('Error loading links:', 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>`;
} }
} }
@@ -348,9 +707,10 @@ export async function saveLink(event) {
description: document.getElementById('linkDescription').value || '' description: document.getElementById('linkDescription').value || ''
}; };
const encodedName = encodeURIComponent(linkName);
const endpoint = isEdit ? const endpoint = isEdit ?
`/api/links/update/${linkName}` : `/api/links/update/${encodedName}` :
`/api/links/create/${linkName}`; `/api/links/create/${encodedName}`;
const method = isEdit ? 'PUT' : 'POST'; const method = isEdit ? 'PUT' : 'POST';
@@ -384,7 +744,7 @@ export async function deleteLinkConfirm(linkName) {
} }
try { try {
const response = await fetch(`/api/links/delete/${linkName}`, { const response = await fetch(`/api/links/delete/${encodeURIComponent(linkName)}`, {
method: 'DELETE', method: 'DELETE',
headers: getAuthHeaders() headers: getAuthHeaders()
}); });
+73 -47
View File
@@ -8,11 +8,12 @@ import {
ws, currentState, setCurrentState, currentDuration, setCurrentDuration, ws, currentState, setCurrentState, currentDuration, setCurrentDuration,
currentPosition, setCurrentPosition, isUserAdjustingVolume, currentPosition, setCurrentPosition, isUserAdjustingVolume,
lastStatus, setLastStatus, currentPlayState, setCurrentPlayState, lastStatus, setLastStatus, currentPlayState, setCurrentPlayState,
POSITION_INTERPOLATION_MS, seek, POSITION_INTERPOLATION_MS, seek, notifyRemoteVolume,
getAuthHeaders, hasCredentials, getAuthHeaders, hasCredentials,
} from './core.js'; } from './core.js';
import { updateBackgroundColors } from './background.js'; import { updateBackgroundColors } from './background.js';
import { loadDisplayMonitors } from './links.js'; import { loadDisplayMonitors } from './links.js';
import { loadForegroundProcess } from './foreground.js';
import { IconSelect } from './icon-select.js'; import { IconSelect } from './icon-select.js';
// Tab management // Tab management
@@ -75,6 +76,7 @@ export function switchTab(tabName) {
if (tabName === 'display') { if (tabName === 'display') {
loadDisplayMonitors(); loadDisplayMonitors();
loadForegroundProcess();
} }
localStorage.setItem('activeTab', tabName); localStorage.setItem('activeTab', tabName);
@@ -205,20 +207,39 @@ export function renderAccentSwatches() {
const swatches = accentPresets.map(p => const swatches = accentPresets.map(p =>
`<div class="accent-swatch ${p.color === current ? 'active' : ''}" `<div class="accent-swatch ${p.color === current ? 'active' : ''}"
style="background: ${p.color}" style="background: ${p.color}"
onclick="selectAccentColor('${p.color}', '${p.hover}')" data-accent-color="${p.color}" data-accent-hover="${p.hover}"
title="${p.name}"></div>` title="${p.name}"></div>`
).join(''); ).join('');
const customRow = ` const customRow = `
<div class="accent-custom-row ${isCustom ? 'active' : ''}" onclick="document.getElementById('accentCustomInput').click()"> <div class="accent-custom-row ${isCustom ? 'active' : ''}" data-accent-custom-row>
<span class="accent-custom-swatch" style="background: ${isCustom ? current : '#888'}"></span> <span class="accent-custom-swatch" style="background: ${isCustom ? current : '#888'}"></span>
<span class="accent-custom-label">${t('accent.custom')}</span> <span class="accent-custom-label">${t('accent.custom')}</span>
<input type="color" id="accentCustomInput" value="${current}" <input type="color" id="accentCustomInput" value="${current}">
onclick="event.stopPropagation()"
onchange="selectAccentColor(this.value, lightenColor(this.value, 15))">
</div>`; </div>`;
dropdown.innerHTML = swatches + customRow; dropdown.innerHTML = swatches + customRow;
// Wire CSP-safe handlers (script-src 'self' blocks inline on* attributes).
dropdown.querySelectorAll('.accent-swatch[data-accent-color]').forEach(el => {
el.addEventListener('click', () => {
selectAccentColor(el.dataset.accentColor, el.dataset.accentHover);
});
});
const customRowEl = dropdown.querySelector('[data-accent-custom-row]');
const customInput = dropdown.querySelector('#accentCustomInput');
if (customRowEl && customInput) {
customRowEl.addEventListener('click', (e) => {
// The native color popup only opens from a user-initiated click on
// the <input>. Forward clicks on the row to the input — except when
// the input itself was the source (avoids re-entry).
if (e.target !== customInput) customInput.click();
});
customInput.addEventListener('click', (e) => e.stopPropagation());
customInput.addEventListener('change', () => {
selectAccentColor(customInput.value, lightenColor(customInput.value, 15));
});
}
} }
export function selectAccentColor(color, hover) { export function selectAccentColor(color, hover) {
@@ -687,54 +708,49 @@ export async function onAudioDeviceChanged() {
let lastArtworkKey = null; let lastArtworkKey = null;
let currentArtworkBlobUrl = null; let currentArtworkBlobUrl = null;
let artworkFetchGen = 0;
let artworkAbort = null;
let lastPositionUpdate = 0; let lastPositionUpdate = 0;
let lastPositionValue = 0; let lastPositionValue = 0;
let interpolationInterval = null; let interpolationInterval = null;
export function setupProgressDrag(bar, fill) { 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) { function getPercent(clientX) {
const rect = bar.getBoundingClientRect(); const rect = bar.getBoundingClientRect();
return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
} }
function updatePreview(percent) { fill.style.width = (percent * 100) + '%'; }
function updatePreview(percent) { function pointerStart(getX, moveEvent, endEvent, getMoveX, getEndX) {
fill.style.width = (percent * 100) + '%';
}
function handleStart(clientX) {
if (currentDuration <= 0) return; if (currentDuration <= 0) return;
dragging = true;
bar.classList.add('dragging'); bar.classList.add('dragging');
updatePreview(getPercent(clientX)); updatePreview(getPercent(getX));
}
function handleMove(clientX) { function onMove(e) { updatePreview(getPercent(getMoveX(e))); }
if (!dragging) return; function onEnd(e) {
updatePreview(getPercent(clientX)); document.removeEventListener(moveEvent, onMove);
} document.removeEventListener(endEvent, onEnd);
bar.classList.remove('dragging');
function handleEnd(clientX) { const clientX = getEndX(e);
if (!dragging) return; if (clientX !== undefined) seek(getPercent(clientX) * currentDuration);
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);
} }
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) => { bar.addEventListener('click', (e) => {
if (currentDuration > 0) { if (currentDuration > 0) {
@@ -811,28 +827,35 @@ export function updateUI(status) {
lastArtworkKey = artworkKey; 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 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"; 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) { 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', { fetch('/api/media/artwork', {
headers: getAuthHeaders() headers: getAuthHeaders(),
signal: artworkAbort.signal,
}) })
.then(r => r.ok ? r.blob() : null) .then(r => r.ok ? r.blob() : null)
.then(blob => { .then(blob => {
if (!blob) return; if (!blob || myGen !== artworkFetchGen) return;
const oldBlobUrl = currentArtworkBlobUrl; const oldBlobUrl = currentArtworkBlobUrl;
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
currentArtworkBlobUrl = url; currentArtworkBlobUrl = url;
swapArtworkSrc(dom.albumArt, url); swapArtworkSrc(dom.albumArt, url);
if (dom.miniAlbumArt.src !== url) dom.miniAlbumArt.src = url; if (dom.miniAlbumArt.src !== url) dom.miniAlbumArt.src = url;
if (dom.albumArtGlow && dom.albumArtGlow.src !== url) dom.albumArtGlow.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); syncFullscreenBloomArt(url);
if (oldBlobUrl) setTimeout(() => URL.revokeObjectURL(oldBlobUrl), 1000); 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 { } else {
if (currentArtworkBlobUrl) { if (currentArtworkBlobUrl) {
URL.revokeObjectURL(currentArtworkBlobUrl); URL.revokeObjectURL(currentArtworkBlobUrl);
@@ -858,6 +881,9 @@ export function updateUI(status) {
} }
if (!isUserAdjustingVolume) { 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.volumeSlider.value = status.volume;
dom.volumeDisplay.textContent = `${status.volume}%`; dom.volumeDisplay.textContent = `${status.volume}%`;
dom.miniVolumeSlider.value = status.volume; dom.miniVolumeSlider.value = status.volume;
+28 -27
View File
@@ -150,7 +150,7 @@ async function executeScript(scriptName, buttonElement) {
async function _doExecuteScript(scriptName, params) { async function _doExecuteScript(scriptName, params) {
try { try {
const response = await fetch(`/api/scripts/execute/${scriptName}`, { const response = await fetch(`/api/scripts/execute/${encodeURIComponent(scriptName)}`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
body: JSON.stringify({ params }) body: JSON.stringify({ params })
@@ -393,7 +393,7 @@ async function _loadScriptsTableImpl() {
resolveMdiIcons(tbody); resolveMdiIcons(tbody);
} catch (error) { } catch (error) {
console.error('Error loading scripts:', 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); const script = scriptsList.find(s => s.name === scriptName);
if (!script) { if (!script) {
showToast('Script not found', 'error'); showToast(t('scripts.msg.not_found'), 'error');
return; return;
} }
@@ -470,7 +470,7 @@ export async function showEditScriptDialog(scriptName) {
dialog.showModal(); dialog.showModal();
} catch (error) { } catch (error) {
console.error('Error loading script for edit:', 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(), parameters: _collectParameterDefinitions(),
}; };
const encodedName = encodeURIComponent(scriptName);
const endpoint = isEdit ? const endpoint = isEdit ?
`/api/scripts/update/${scriptName}` : `/api/scripts/update/${encodedName}` :
`/api/scripts/create/${scriptName}`; `/api/scripts/create/${encodedName}`;
const method = isEdit ? 'PUT' : 'POST'; const method = isEdit ? 'PUT' : 'POST';
@@ -524,15 +525,15 @@ export async function saveScript(event) {
const result = await response.json(); const result = await response.json();
if (response.ok && result.success) { if (response.ok && result.success) {
showToast(`Script ${isEdit ? 'updated' : 'created'} successfully`, 'success'); showToast(t(isEdit ? 'scripts.msg.updated' : 'scripts.msg.created'), 'success');
scriptFormDirty = false; scriptFormDirty = false;
closeScriptDialog(); closeScriptDialog();
} else { } 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) { } catch (error) {
console.error('Error saving script:', 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 { } finally {
if (submitBtn) submitBtn.disabled = false; if (submitBtn) submitBtn.disabled = false;
} }
@@ -544,7 +545,7 @@ export async function deleteScriptConfirm(scriptName) {
} }
try { try {
const response = await fetch(`/api/scripts/delete/${scriptName}`, { const response = await fetch(`/api/scripts/delete/${encodeURIComponent(scriptName)}`, {
method: 'DELETE', method: 'DELETE',
headers: getAuthHeaders() headers: getAuthHeaders()
}); });
@@ -552,13 +553,13 @@ export async function deleteScriptConfirm(scriptName) {
const result = await response.json(); const result = await response.json();
if (response.ok && result.success) { if (response.ok && result.success) {
showToast('Script deleted successfully', 'success'); showToast(t('scripts.msg.deleted'), 'success');
} else { } else {
showToast(result.detail || 'Failed to delete script', 'error'); showToast(result.detail || t('scripts.msg.delete_failed'), 'error');
} }
} catch (error) { } catch (error) {
console.error('Error deleting script:', 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 outputPre = document.getElementById('executionOutput');
const errorPre = document.getElementById('executionError'); 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 success = result.success && result.exit_code === 0;
const statusClass = success ? 'success' : 'error'; const statusClass = success ? 'success' : 'error';
const statusText = success ? 'Success' : 'Failed'; const statusText = t(success ? 'execution.success' : 'execution.failed');
statusDiv.innerHTML = ` statusDiv.innerHTML = `
<div class="status-item ${statusClass}"> <div class="status-item ${statusClass}">
<label>Status</label> <label>${escapeHtml(t('execution.status'))}</label>
<value>${statusText}</value> <value>${escapeHtml(statusText)}</value>
</div> </div>
<div class="status-item"> <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> <value>${result.exit_code !== undefined ? result.exit_code : 'N/A'}</value>
</div> </div>
<div class="status-item"> <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> <value>${result.execution_time !== undefined && result.execution_time !== null ? result.execution_time.toFixed(3) + 's' : 'N/A'}</value>
</div> </div>
`; `;
@@ -606,7 +607,7 @@ function showExecutionResult(name, result, type = 'script') {
if (result.stdout && result.stdout.trim()) { if (result.stdout && result.stdout.trim()) {
outputPre.textContent = result.stdout; outputPre.textContent = result.stdout;
} else { } else {
outputPre.textContent = '(no output)'; outputPre.textContent = t('execution.no_output');
outputPre.style.fontStyle = 'italic'; outputPre.style.fontStyle = 'italic';
outputPre.style.color = 'var(--text-secondary)'; outputPre.style.color = 'var(--text-secondary)';
} }
@@ -642,11 +643,11 @@ async function _executeScriptDebugWithParams(scriptName, params) {
const title = document.getElementById('executionDialogTitle'); const title = document.getElementById('executionDialogTitle');
const statusDiv = document.getElementById('executionStatus'); const statusDiv = document.getElementById('executionStatus');
title.textContent = `Executing: ${scriptName}`; title.textContent = `${t('execution.executing')}: ${scriptName}`;
statusDiv.innerHTML = ` statusDiv.innerHTML = `
<div class="status-item"> <div class="status-item">
<label>Status</label> <label>${escapeHtml(t('execution.status'))}</label>
<value><span class="loading-spinner"></span> Running...</value> <value><span class="loading-spinner"></span> ${escapeHtml(t('execution.running'))}</value>
</div> </div>
`; `;
document.getElementById('outputSection').style.display = 'none'; document.getElementById('outputSection').style.display = 'none';
@@ -813,11 +814,11 @@ export async function executeCallbackDebug(callbackName) {
const title = document.getElementById('executionDialogTitle'); const title = document.getElementById('executionDialogTitle');
const statusDiv = document.getElementById('executionStatus'); const statusDiv = document.getElementById('executionStatus');
title.textContent = `Executing: ${callbackName}`; title.textContent = `${t('execution.executing')}: ${callbackName}`;
statusDiv.innerHTML = ` statusDiv.innerHTML = `
<div class="status-item"> <div class="status-item">
<label>Status</label> <label>${escapeHtml(t('execution.status'))}</label>
<value><span class="loading-spinner"></span> Running...</value> <value><span class="loading-spinner"></span> ${escapeHtml(t('execution.running'))}</value>
</div> </div>
`; `;
document.getElementById('outputSection').style.display = 'none'; document.getElementById('outputSection').style.display = 'none';
@@ -826,7 +827,7 @@ export async function executeCallbackDebug(callbackName) {
dialog.showModal(); dialog.showModal();
try { try {
const response = await fetch(`/api/callbacks/execute/${callbackName}`, { const response = await fetch(`/api/callbacks/execute/${encodeURIComponent(callbackName)}`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() } headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }
}); });
+78 -15
View File
@@ -3,7 +3,7 @@
// ============================================================ // ============================================================
import { import {
dom, t, showToast, setWs, dom, t, setWs, getWs,
WS_BACKOFF_BASE_MS, WS_BACKOFF_MAX_MS, WS_BACKOFF_BASE_MS, WS_BACKOFF_MAX_MS,
WS_MAX_RECONNECT_ATTEMPTS, WS_PING_INTERVAL_MS, WS_MAX_RECONNECT_ATTEMPTS, WS_PING_INTERVAL_MS,
authRequired, showUpdateBanner, authRequired, showUpdateBanner,
@@ -12,10 +12,29 @@ import { updateUI, setFrequencyData, stopPositionInterpolation, loadAudioDevices
import { loadScripts, loadScriptsTable, displayQuickAccess } from './scripts.js'; import { loadScripts, loadScriptsTable, displayQuickAccess } from './scripts.js';
import { loadCallbacksTable } from './callbacks.js'; import { loadCallbacksTable } from './callbacks.js';
import { loadHeaderLinks, loadLinksTable } from './links.js'; import { loadHeaderLinks, loadLinksTable } from './links.js';
import { updateForegroundUI } from './foreground.js';
let reconnectTimeout = null; let reconnectTimeout = null;
let pingInterval = null;
let wsReconnectAttempts = 0; 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 = '') { export function showAuthForm(errorMessage = '') {
const overlay = document.getElementById('auth-overlay'); const overlay = document.getElementById('auth-overlay');
@@ -47,19 +66,24 @@ export function authenticate() {
export function clearToken() { export function clearToken() {
localStorage.removeItem('media_server_token'); localStorage.removeItem('media_server_token');
// Access ws via import const current = getWs();
import('./core.js').then(core => { if (current) {
if (core.ws) { try { current.close(1000, 'token cleared'); } catch { /* ignore */ }
core.ws.close(); }
}
});
showAuthForm(t('auth.cleared')); showAuthForm(t('auth.cleared'));
} }
export function connectWebSocket(token) { export function connectWebSocket(token) {
if (pingInterval) { // Always cancel a pending reconnect first — otherwise a user-triggered
clearInterval(pingInterval); // reconnect can race a scheduled one and create two live sockets.
pingInterval = null; 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:'; 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 wsUrl = token ? `${wsBase}?token=${encodeURIComponent(token)}` : wsBase;
const newWs = new WebSocket(wsUrl); const newWs = new WebSocket(wsUrl);
activeSocket = newWs;
setWs(newWs); setWs(newWs);
newWs.onopen = () => { newWs.onopen = () => {
@@ -84,10 +109,18 @@ export function connectWebSocket(token) {
}; };
newWs.onmessage = (event) => { 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') { if (msg.type === 'status' || msg.type === 'status_update') {
updateUI(msg.data); updateUI(msg.data);
} else if (msg.type === 'foreground' || msg.type === 'foreground_update') {
updateForegroundUI(msg.data);
} else if (msg.type === 'scripts_changed') { } else if (msg.type === 'scripts_changed') {
console.log('Scripts changed, reloading...'); console.log('Scripts changed, reloading...');
loadScripts(); loadScripts();
@@ -116,6 +149,13 @@ export function connectWebSocket(token) {
updateConnectionStatus(false); updateConnectionStatus(false);
stopPositionInterpolation(); 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) { if (event.code === 4001) {
localStorage.removeItem('media_server_token'); localStorage.removeItem('media_server_token');
showAuthForm(t('auth.invalid')); showAuthForm(t('auth.invalid'));
@@ -133,7 +173,9 @@ export function connectWebSocket(token) {
showConnectionBanner(t('connection.reconnecting').replace('{attempt}', wsReconnectAttempts), false); showConnectionBanner(t('connection.reconnecting').replace('{attempt}', wsReconnectAttempts), false);
} }
clearReconnect();
reconnectTimeout = setTimeout(() => { reconnectTimeout = setTimeout(() => {
reconnectTimeout = null;
const savedToken = localStorage.getItem('media_server_token'); const savedToken = localStorage.getItem('media_server_token');
if (savedToken || !authRequired) { if (savedToken || !authRequired) {
connectWebSocket(savedToken || ''); connectWebSocket(savedToken || '');
@@ -145,9 +187,10 @@ export function connectWebSocket(token) {
} }
}; };
pingInterval = setInterval(() => { clearPing();
if (newWs && newWs.readyState === WebSocket.OPEN) { activePingInterval = setInterval(() => {
newWs.send(JSON.stringify({ type: 'ping' })); if (newWs.readyState === WebSocket.OPEN) {
try { newWs.send(JSON.stringify({ type: 'ping' })); } catch { /* ignore */ }
} }
}, WS_PING_INTERVAL_MS); }, WS_PING_INTERVAL_MS);
} }
@@ -182,3 +225,23 @@ export function manualReconnect() {
connectWebSocket(savedToken || ''); 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();
});
+50 -1
View File
@@ -161,6 +161,31 @@
"display.power_on": "Turn on", "display.power_on": "Turn on",
"display.power_off": "Turn off", "display.power_off": "Turn off",
"display.primary": "Primary", "display.primary": "Primary",
"display.brightness": "Brightness",
"display.contrast": "Contrast",
"display.tuning": "Picture tuning",
"display.input_source": "Input",
"display.color_preset": "Color temp",
"display.picture_mode": "Picture mode",
"display.msg.contrast_failed": "Failed to set contrast",
"display.msg.input_changed": "Input source switched",
"display.msg.input_failed": "Failed to switch input source",
"display.msg.color_changed": "Color preset applied",
"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.title": "Media Browser",
"browser.home": "Home", "browser.home": "Home",
"browser.manage_folders": "Manage Folders", "browser.manage_folders": "Manage Folders",
@@ -267,5 +292,29 @@
"about.source_code": "Source Code", "about.source_code": "Source Code",
"dialog.close": "Close", "dialog.close": "Close",
"update.available": "Update available: v{version}", "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"
} }
+50 -1
View File
@@ -161,6 +161,31 @@
"display.power_on": "Включить", "display.power_on": "Включить",
"display.power_off": "Выключить", "display.power_off": "Выключить",
"display.primary": "Основной", "display.primary": "Основной",
"display.brightness": "Яркость",
"display.contrast": "Контраст",
"display.tuning": "Настройка изображения",
"display.input_source": "Вход",
"display.color_preset": "Цветовая температура",
"display.picture_mode": "Режим изображения",
"display.msg.contrast_failed": "Не удалось установить контраст",
"display.msg.input_changed": "Источник входа переключён",
"display.msg.input_failed": "Не удалось переключить источник",
"display.msg.color_changed": "Цветовая температура применена",
"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.title": "Медиа Браузер",
"browser.home": "Главная", "browser.home": "Главная",
"browser.manage_folders": "Управление папками", "browser.manage_folders": "Управление папками",
@@ -267,5 +292,29 @@
"about.source_code": "Исходный код", "about.source_code": "Исходный код",
"dialog.close": "Закрыть", "dialog.close": "Закрыть",
"update.available": "Доступно обновление: v{version}", "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} дн назад"
} }
+4 -6
View File
@@ -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. // 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.addEventListener('install', () => {
self.skipWaiting(); self.skipWaiting();
@@ -9,7 +11,3 @@ self.addEventListener('install', () => {
self.addEventListener('activate', (event) => { self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim()); event.waitUntil(self.clients.claim());
}); });
self.addEventListener('fetch', (event) => {
event.respondWith(fetch(event.request));
});
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "media-server-frontend", "name": "media-server-frontend",
"version": "0.2.3", "version": "0.2.7",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "media-server-frontend", "name": "media-server-frontend",
"version": "0.2.3", "version": "0.2.7",
"devDependencies": { "devDependencies": {
"esbuild": "^0.27.4" "esbuild": "^0.27.4"
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "media-server-frontend", "name": "media-server-frontend",
"version": "0.2.3", "version": "0.2.7",
"private": true, "private": true,
"description": "Frontend build tooling for media server WebUI", "description": "Frontend build tooling for media server WebUI",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "media-server" name = "media-server"
version = "0.2.3" version = "0.2.7"
description = "REST API server for controlling system-wide media playback" description = "REST API server for controlling system-wide media playback"
readme = "README.md" readme = "README.md"
license = { text = "MIT" } license = { text = "MIT" }
+94 -26
View File
@@ -1,35 +1,103 @@
# Restart the Media Server # Restart the Media Server.
# Stop any running instance #
$procs = Get-Process -Name 'media-server' -ErrorAction SilentlyContinue # Robust against the two ways the server gets started:
foreach ($p in $procs) { # - Installer build: %LOCALAPPDATA%\Media Server\media-server.bat
Write-Host "Stopping server (PID $($p.Id))..." # (runs as python.exe -m media_server.main)
Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue # - Dev editable install: media-server console script on PATH
} # (runs as media-server.exe)
if ($procs) { Start-Sleep -Seconds 2 } #
# The old version of this script only killed processes named 'media-server',
# which silently missed the installer-bundled process (named 'python').
# This version kills whatever currently owns the listen port, so it doesn't
# matter how the previous instance was launched.
# Merge registry PATH with current PATH so newly-installed tools are visible param(
$regUser = [Environment]::GetEnvironmentVariable('PATH', 'User') [ValidateSet('auto', 'dev', 'installer')]
if ($regUser) { [string]$Mode = 'auto',
$currentDirs = $env:PATH -split ';' | ForEach-Object { $_.TrimEnd('\') } [int]$Port = 8765
foreach ($dir in ($regUser -split ';')) { )
if ($dir -and ($currentDirs -notcontains $dir.TrimEnd('\'))) {
$env:PATH = "$env:PATH;$dir" $InstallerLauncher = Join-Path $env:LOCALAPPDATA 'Media Server\media-server.bat'
} $InstallerDir = Join-Path $env:LOCALAPPDATA 'Media Server'
# --- Resolve launch mode -----------------------------------------------------
if ($Mode -eq 'auto') {
if (Test-Path $InstallerLauncher) {
$Mode = 'installer'
} else {
$Mode = 'dev'
} }
} }
# Start server detached # --- Stop whatever is listening on the port ---------------------------------
Write-Host "Starting server..." $listenerPids = @()
Start-Process -FilePath 'media-server' ` try {
-WorkingDirectory 'c:\Users\Alexei\Documents\haos-integration-media-player\media-server' ` $conns = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue
-WindowStyle Hidden if ($conns) {
$listenerPids = $conns | Select-Object -ExpandProperty OwningProcess -Unique
}
} catch {
# Get-NetTCPConnection unavailable (rare); fall back to netstat parsing
$listenerPids = & netstat -ano | Select-String ":$Port\s+.*LISTENING" | ForEach-Object {
($_ -split '\s+')[-1]
} | Sort-Object -Unique
}
foreach ($targetPid in $listenerPids) {
$proc = Get-Process -Id $targetPid -ErrorAction SilentlyContinue
if ($proc) {
Write-Host "Stopping listener PID $($proc.Id) ($($proc.ProcessName))..."
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
}
}
# Also kill any orphan media-server.exe instances that didn't bind the port.
$orphans = Get-Process -Name 'media-server' -ErrorAction SilentlyContinue
foreach ($p in $orphans) {
Write-Host "Stopping orphan media-server PID $($p.Id)..."
Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
}
if ($listenerPids -or $orphans) {
# Allow the OS to release the listen socket from TIME_WAIT.
Start-Sleep -Seconds 3
}
# --- Start the chosen flavour ------------------------------------------------
if ($Mode -eq 'installer') {
if (-not (Test-Path $InstallerLauncher)) {
Write-Error "Installer launcher not found: $InstallerLauncher"
exit 1
}
Write-Host "Starting installer build: $InstallerLauncher"
Start-Process -FilePath $InstallerLauncher `
-WorkingDirectory $InstallerDir `
-WindowStyle Hidden
} else {
# Merge registry PATH so newly-installed dev tools are visible.
$regUser = [Environment]::GetEnvironmentVariable('PATH', 'User')
if ($regUser) {
$currentDirs = $env:PATH -split ';' | ForEach-Object { $_.TrimEnd('\') }
foreach ($dir in ($regUser -split ';')) {
if ($dir -and ($currentDirs -notcontains $dir.TrimEnd('\'))) {
$env:PATH = "$env:PATH;$dir"
}
}
}
Write-Host "Starting dev install (PATH media-server)..."
Start-Process -FilePath 'media-server' `
-WorkingDirectory 'c:\Users\Alexei\Documents\haos-integration-media-player\media-server' `
-WindowStyle Hidden
}
Start-Sleep -Seconds 3 Start-Sleep -Seconds 3
# Verify it's running # --- Verify it's listening ---------------------------------------------------
$check = Get-Process -Name 'media-server' -ErrorAction SilentlyContinue $verify = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue
if ($check) { if ($verify) {
Write-Host "Server started (PID $($check[0].Id))" $vpid = $verify[0].OwningProcess
$vproc = Get-Process -Id $vpid -ErrorAction SilentlyContinue
Write-Host "Server listening on port $Port (PID $vpid, $($vproc.ProcessName))"
} else { } else {
Write-Host "WARNING: Server does not appear to be running!" Write-Warning "Server is not listening on port $Port yet - check logs."
} }
+87
View File
@@ -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