Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 527f3d0aa4 | |||
| 982dda42ac | |||
| eaeebb64cd | |||
| bcc6d40ed7 | |||
| 770bba7e60 | |||
| d1f621f0b4 | |||
| 6120625fa9 | |||
| 57fdeb70fb | |||
| 0d07f7f1f4 | |||
| 372e4eb11f | |||
| d27484a46d | |||
| 261a14c575 | |||
| e7372b0ccb | |||
| ec5178142e | |||
| 46af2bb8cc | |||
| 25a492d5dd | |||
| f4be2bdb89 | |||
| 51ec1503f4 | |||
| 08c3c80df4 | |||
| 62eeca1b9e | |||
| 4c93bfb8c1 | |||
| 59840a1190 | |||
| 2a474ea52c | |||
| f85ce77f14 | |||
| b09569f390 | |||
| f2c82164e8 | |||
| 588a303c44 | |||
| 2049850180 | |||
| 9b84fdd0e5 | |||
| 3de2b4496e | |||
| d7f488ac70 | |||
| 968eb156bc | |||
| a0f74dfc39 | |||
| 6066b4a2c5 | |||
| 153424eff8 | |||
| 336d596b66 | |||
| d937c1590c | |||
| d157388a94 | |||
| e9e4165927 | |||
| 77b39e5684 | |||
| d9d4672ca3 | |||
| 265b001b99 | |||
| 14e9f2294e | |||
| 8110c152b0 | |||
| 21adeb1070 | |||
| 68614c982d | |||
| a2a258e898 | |||
| 456eb3a881 | |||
| c586b1b518 | |||
| ee5184920d | |||
| af556e0bff | |||
| 26b4672a99 |
@@ -0,0 +1,16 @@
|
||||
# Normalise text files to LF in the repo so Windows checkouts stop
|
||||
# nagging "LF will be replaced by CRLF" on every git status.
|
||||
* text=auto eol=lf
|
||||
|
||||
# Binary assets — never touch.
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.svg text
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.exe binary
|
||||
*.dll binary
|
||||
*.zip binary
|
||||
@@ -8,6 +8,7 @@ on:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
if: "!startsWith(github.event.head_commit.message, 'chore: release')"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -53,3 +53,5 @@ Thumbs.db
|
||||
# Node.js / esbuild
|
||||
node_modules/
|
||||
media_server/static/dist/
|
||||
# Added by code-review-graph
|
||||
.code-review-graph/
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"code-review-graph": {
|
||||
"command": "uvx",
|
||||
"args": [
|
||||
"code-review-graph",
|
||||
"serve"
|
||||
],
|
||||
"type": "stdio"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -196,3 +196,42 @@ pytest --tb=short -q
|
||||
|
||||
- **ALWAYS ask for user approval before committing and pushing changes.**
|
||||
- When pushing, always push to all remotes: `git push origin master && git push github master`
|
||||
|
||||
<!-- code-review-graph MCP tools -->
|
||||
## MCP Tools: code-review-graph
|
||||
|
||||
**IMPORTANT: This project has a knowledge graph. ALWAYS use the
|
||||
code-review-graph MCP tools BEFORE using Grep/Glob/Read to explore
|
||||
the codebase.** The graph is faster, cheaper (fewer tokens), and gives
|
||||
you structural context (callers, dependents, test coverage) that file
|
||||
scanning cannot.
|
||||
|
||||
### When to use graph tools FIRST
|
||||
|
||||
- **Exploring code**: `semantic_search_nodes` or `query_graph` instead of Grep
|
||||
- **Understanding impact**: `get_impact_radius` instead of manually tracing imports
|
||||
- **Code review**: `detect_changes` + `get_review_context` instead of reading entire files
|
||||
- **Finding relationships**: `query_graph` with callers_of/callees_of/imports_of/tests_for
|
||||
- **Architecture questions**: `get_architecture_overview` + `list_communities`
|
||||
|
||||
Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need.
|
||||
|
||||
### Key Tools
|
||||
|
||||
| Tool | Use when |
|
||||
|------|----------|
|
||||
| `detect_changes` | Reviewing code changes — gives risk-scored analysis |
|
||||
| `get_review_context` | Need source snippets for review — token-efficient |
|
||||
| `get_impact_radius` | Understanding blast radius of a change |
|
||||
| `get_affected_flows` | Finding which execution paths are impacted |
|
||||
| `query_graph` | Tracing callers, callees, imports, tests, dependencies |
|
||||
| `semantic_search_nodes` | Finding functions/classes by name or keyword |
|
||||
| `get_architecture_overview` | Understanding high-level codebase structure |
|
||||
| `refactor_tool` | Planning renames, finding dead code |
|
||||
|
||||
### Workflow
|
||||
|
||||
1. The graph auto-updates on file changes (via hooks).
|
||||
2. Use `detect_changes` for code review.
|
||||
3. Use `get_affected_flows` to understand impact.
|
||||
4. Use `query_graph` pattern="tests_for" to check coverage.
|
||||
|
||||
+53
-5
@@ -1,8 +1,55 @@
|
||||
## v0.1.5 (2026-04-11)
|
||||
## v0.2.5 (2026-05-16)
|
||||
|
||||
### Security
|
||||
|
||||
- **Loopback-by-default + auto-generated token:** Server now binds `127.0.0.1` by default; first-run bootstrap generates a random `api_token` and refuses to bind a non-loopback interface without auth unless explicitly opted in. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- **Browser path-traversal hardening:** `BrowserService.validate_path` now rejects absolute paths, drive letters, UNC paths, and NUL bytes. `/api/browser/{play,metadata,thumbnail}` require a `folder_id` plus a folder-relative path — arbitrary filesystem reads via the browser API are no longer possible. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- **Strict input validation on links/scripts:** Pydantic validators reject non-http(s) URLs and any icon outside the `mdi:<slug>` namespace. Create/update/delete on scripts, callbacks, and links is gated by the corresponding `*_management` flags. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- **Hardened response headers + CORS:** Strict `Content-Security-Policy`, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer`, `X-Content-Type-Options: nosniff`. CORS locked to `localhost:<port>` + `127.0.0.1:<port>` by default; configurable for trusted origins. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- **Atomic config writes with restrictive permissions:** `config.yaml` writes go through a temp file + `os.replace` and land with `0o600` on POSIX, so a crash mid-write can never leave a half-written token on disk readable to other users. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- **Subprocess process-group isolation:** Spawned scripts/callbacks now get their own process group (`CREATE_NEW_PROCESS_GROUP` on Windows, `start_new_session=True` on POSIX), so a timeout actually kills the whole tree instead of orphaning child processes. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- **Frontend XSS hardening:** Monitor name + details are `escapeHtml`'d, the power button moved to a delegated `data-action` handler, and remote MDI SVGs are parsed and sanitized (strip `<script>`, `<foreignObject>`, `on*` handlers, `javascript:` hrefs) before they touch `innerHTML`. All dynamic URL segments now go through `encodeURIComponent`. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- **CSP-compliant event wiring:** Strict `script-src 'self'` was blocking every inline `onclick`/`onchange`/`oninput`/`onsubmit` in the UI, leaving buttons and forms silently dead. All 53 inline handler attributes in `index.html` were renamed to `data-on*` and a new `wireInlineHandlers()` in `app.js` parses each expression on `DOMContentLoaded` and attaches a real `addEventListener` — supports no-arg calls, string/number/bool/null literals, and the `event` token. No `unsafe-inline` or `unsafe-hashes` needed. ([eaeebb6](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/eaeebb6))
|
||||
|
||||
### Bug Fixes
|
||||
- Make WebSocket token query parameter optional to prevent connection failures for clients that authenticate via other means ([34eb7c7](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/34eb7c7))
|
||||
- Fetch playback status eagerly on new WebSocket connection instead of waiting for the next poll cycle ([d09a0b9](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d09a0b9))
|
||||
|
||||
- **WebSocket reconnect robustness:** Close the previous socket before opening a new one, clear the ping interval per-socket, clear `reconnectTimeout` up-front, retry on `online`/`visibilitychange`, and wrap `JSON.parse` in try/catch — eliminates the stale-socket leaks and "stuck offline after sleep" cases. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- **Artwork fetch race:** `AbortController` + generation guard so a rapid track change can no longer paint the previous track's artwork over the current one. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- **Audio analyzer no longer spins infinitely without a loopback device:** A sticky `_unavailable` flag short-circuits start/stop; cleared by `set_device()` so the user can recover once a device appears. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- **Volume short-circuit cache invalidation:** Cache is now busted when the server reports a remote volume change, so the UI no longer ignores volume updates that happened outside the app. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- **Browser thumbnail race:** Per-folder generation counter + `isConnected` checks; in-flight fetches are aborted on navigation, so thumbnails from a folder you already left can't paint into the current view. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- **Track-skip uses cached title** instead of a full WinRT status round-trip — skip feedback is now instant. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- **Browser list column alignment:** `.browser-list` switched to CSS grid + subgrid so header and rows share column tracks, eliminating the misaligned columns when content widths differed between rows. Matching responsive column overrides applied at the parent. Root-folder SVG sizing (hardcoded 24×24 in `browser.js`) now fills the 56px icon box instead of rendering at ~43%. Compact-grid icon fills its thumb wrapper so the emoji centers instead of being stranded top-left. Premature `isConnected` bail removed from `loadThumbnail` — the img element is intentionally detached when called from `renderBrowserGrid/List`, and the post-await checks already handle navigation-away correctly. ([982dda4](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/982dda4))
|
||||
|
||||
### Performance
|
||||
|
||||
- **Blocking IO off the event loop:** Linux MPRIS/`pactl` calls, `/api/display` DDC/CI handlers, and `browse_directory` are all wrapped in `asyncio.to_thread` — slow SMB shares or laggy monitors can no longer stall the entire async runtime. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- **Windows status poll loop reuse:** The 0.5s status poll now caches one asyncio loop per worker thread via `threading.local` instead of `new_event_loop`/`close` on every tick. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- **WebSocket broadcast: serialize once:** `broadcast()` serializes JSON a single time and uses `send_text` to fan out to all clients. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- **Thumbnail cache cleanup actually runs:** The hourly cleanup task was defined but never scheduled — it is now wired into the lifespan handler so the cache no longer grows unbounded. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- **Progress drag listeners attached only while dragging** — no more global `mousemove` handler firing on every cursor twitch. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
|
||||
### UI/UX
|
||||
|
||||
- **Copper accent consistency:** Green leftover focus rings (`rgba(29,185,84,…)`) replaced with copper (`rgba(var(--copper-rgb),…)`) across the UI. Dialogs now have square corners and a copper top hairline so they read as part of the editorial chrome. `.browser-item` is transparent with a copper hover border (was a filled card). ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- **Audio device select** uses `var(--sans)` instead of the generic system font so it matches surrounding controls. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- **Mobile padding tuned for ≤480px screens.** ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- **Accessible breadcrumb home:** Now a real `<button>` with `aria-label`, and `aria-current` is set on the root. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- **i18n gaps filled:** `display.msg.power_*`, `execution.*`, `scripts.params.execute`, `callbacks.empty` now have proper en + ru strings. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
|
||||
---
|
||||
|
||||
### Development / Internal
|
||||
|
||||
#### Quality
|
||||
|
||||
- All `asyncio.get_event_loop()` in coroutines migrated to `get_running_loop()` (the former is deprecated in Python 3.12+). ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- `ThreadPoolExecutor`s now shut down cleanly during lifespan teardown. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- `config_manager` dedup: 12 near-identical CRUD methods collapsed onto generic `_upsert`/`_delete` helpers — about **290 lines removed** with no behavior change. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- Service worker no longer pass-throughs every fetch. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- M3U playlist written via `NamedTemporaryFile` so a fixed-path symlink can no longer clobber it. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- `__version__` prefers live `pyproject.toml` in dev checkouts so `pip install -e .` users see the source-of-truth version, not the stale metadata baked in at install time. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
- `_broadcast_after_open` hardening: initialize status, swallow per-poll errors, and track background tasks in a strong-ref set with done-callback cleanup so they aren't garbage-collected mid-flight. ([bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40))
|
||||
|
||||
---
|
||||
|
||||
@@ -11,7 +58,8 @@
|
||||
|
||||
| Hash | Message | Author |
|
||||
|------|---------|--------|
|
||||
| [34eb7c7](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/34eb7c7) | fix(ws): make WebSocket token parameter optional | alexei.dolgolyov |
|
||||
| [d09a0b9](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d09a0b9) | fix(ws): fetch status eagerly on new WebSocket connection | alexei.dolgolyov |
|
||||
| [982dda4](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/982dda4) | fix(browser): align list columns via subgrid and fix icon sizing | alexei.dolgolyov |
|
||||
| [eaeebb6](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/eaeebb6) | fix(csp): replace inline on* handlers with data-on* + JS wiring | alexei.dolgolyov |
|
||||
| [bcc6d40](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/bcc6d40) | fix: comprehensive security, bug, performance, and UI/UX audit | alexei.dolgolyov |
|
||||
|
||||
</details>
|
||||
|
||||
+5
-2
@@ -91,8 +91,11 @@ cleanup_site_packages() {
|
||||
find "$sp_dir" -name "*.pyi" -delete 2>/dev/null || true
|
||||
rm -rf "$sp_dir"/{pip,setuptools,pkg_resources,_distutils_hack}* 2>/dev/null || true
|
||||
|
||||
# Trim numpy if present
|
||||
for mod in polynomial linalg ma lib distutils f2py typing _pyinstaller; do
|
||||
# Trim numpy if present.
|
||||
# Keep only modules that numpy/__init__.py does NOT import unconditionally —
|
||||
# lib, linalg, ma, polynomial, fft, ctypeslib, matrixlib are all required for
|
||||
# `import numpy` to succeed, so they MUST stay.
|
||||
for mod in distutils f2py typing _pyinstaller; do
|
||||
rm -rf "$sp_dir/numpy/$mod" 2>/dev/null || true
|
||||
done
|
||||
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ echo "Creating virtualenv..."
|
||||
python3 -m venv "${DIST_DIR}/venv"
|
||||
source "${DIST_DIR}/venv/bin/activate"
|
||||
pip install --quiet --upgrade pip
|
||||
pip install --quiet ".[visualizer]"
|
||||
pip install --quiet "."
|
||||
|
||||
# Remove the installed package (app source is on PYTHONPATH via launcher)
|
||||
rm -rf "${DIST_DIR}"/venv/lib/python*/site-packages/media_server*
|
||||
|
||||
@@ -60,12 +60,18 @@ CORE_DEPS=(
|
||||
)
|
||||
|
||||
# Windows-specific dependencies
|
||||
# NOTE: wmi is a transitive dep of screen-brightness-control gated on
|
||||
# `platform_system == "Windows"`. pip evaluates env markers against the HOST
|
||||
# (Linux in CI), so it gets skipped during cross-build. Listed explicitly here
|
||||
# so the wheel actually lands in the Windows bundle. Same gotcha as the
|
||||
# uvicorn[standard]/uvloop case documented above.
|
||||
WIN_DEPS=(
|
||||
"winsdk>=1.0.0b10"
|
||||
"pywin32>=306"
|
||||
"comtypes>=1.2.0"
|
||||
"pycaw>=20230407"
|
||||
"screen-brightness-control>=0.20.0"
|
||||
"wmi>=1.5.1"
|
||||
"monitorcontrol>=3.0.0"
|
||||
)
|
||||
|
||||
@@ -113,6 +119,22 @@ for whl in "$WHEEL_DIR"/*.whl; do
|
||||
unzip -qo "$whl" -d "$SITE_PACKAGES"
|
||||
done
|
||||
|
||||
# numpy wheels from PyPI don't include _distributor_init_local.py unless
|
||||
# patched by delvewheel. In embedded Python, os.add_dll_directory() is never
|
||||
# called, so libopenblas can't be found and numpy fails to import.
|
||||
# Generate the missing loader here instead.
|
||||
if [ -d "${SITE_PACKAGES}/numpy" ]; then
|
||||
cat > "${SITE_PACKAGES}/numpy/_distributor_init_local.py" << 'EOF'
|
||||
import os
|
||||
import sys
|
||||
if sys.platform == 'win32':
|
||||
_libs = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'numpy.libs'))
|
||||
if os.path.isdir(_libs):
|
||||
os.add_dll_directory(_libs)
|
||||
EOF
|
||||
echo "Generated numpy/_distributor_init_local.py"
|
||||
fi
|
||||
|
||||
cleanup_site_packages "$SITE_PACKAGES" "pyd" "dll"
|
||||
verify_frontend
|
||||
copy_app_files "$DIST_DIR"
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
"""Media Server - REST API for controlling system media playback."""
|
||||
|
||||
import re
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
from pathlib import Path
|
||||
|
||||
_VERSION_RE = re.compile(r'^version\s*=\s*"([^"]+)"', re.MULTILINE)
|
||||
|
||||
|
||||
def _detect_version() -> str:
|
||||
# 1. Package metadata (works when pip-installed in dev)
|
||||
# 1. Live pyproject.toml — only present in dev checkouts. Prefer this
|
||||
# over installed package metadata so `pip install -e .` users don't
|
||||
# see stale versions after editing pyproject.toml without reinstalling.
|
||||
pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml"
|
||||
if pyproject.is_file():
|
||||
try:
|
||||
match = _VERSION_RE.search(pyproject.read_text(encoding="utf-8"))
|
||||
if match:
|
||||
return match.group(1)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# 2. Package metadata (works for any pip-installed copy).
|
||||
try:
|
||||
return version("media-server")
|
||||
except PackageNotFoundError:
|
||||
pass
|
||||
|
||||
# 2. VERSION file written by build scripts (production builds)
|
||||
# Located at install root, two levels up from this package
|
||||
# 3. VERSION file written by build scripts (production builds).
|
||||
version_file = Path(__file__).resolve().parent.parent.parent / "VERSION"
|
||||
if version_file.is_file():
|
||||
return version_file.read_text().strip()
|
||||
|
||||
+69
-22
@@ -1,6 +1,8 @@
|
||||
"""Configuration management for the media server."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
@@ -8,6 +10,8 @@ import yaml
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MediaFolderConfig(BaseModel):
|
||||
"""Configuration for a media folder."""
|
||||
@@ -81,8 +85,35 @@ class Settings(BaseSettings):
|
||||
)
|
||||
|
||||
# Server settings
|
||||
host: str = Field(default="0.0.0.0", description="Server bind address")
|
||||
host: str = Field(
|
||||
default="127.0.0.1",
|
||||
description=(
|
||||
"Server bind address. Use 127.0.0.1 for loopback-only (default, safest),"
|
||||
" or 0.0.0.0 to expose on the LAN (requires api_tokens to be set)."
|
||||
),
|
||||
)
|
||||
port: int = Field(default=8765, description="Server port")
|
||||
allow_lan_without_auth: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"Allow binding to a non-loopback address with no api_tokens configured."
|
||||
" Off by default to prevent unauthenticated LAN exposure."
|
||||
),
|
||||
)
|
||||
cors_origins: list[str] = Field(
|
||||
default_factory=list,
|
||||
description=(
|
||||
"Allowed CORS origins. Empty (default) means only same-origin requests"
|
||||
" from http://localhost:<port> and http://127.0.0.1:<port>."
|
||||
),
|
||||
)
|
||||
|
||||
# Admin-grade operations (script / callback / link / folder create/update/delete).
|
||||
# When True the same token used for read/play can also persist arbitrary shell
|
||||
# commands. Disable to make the API read+execute only.
|
||||
scripts_management: bool = Field(default=True, description="Allow scripts CRUD via API")
|
||||
callbacks_management: bool = Field(default=True, description="Allow callbacks CRUD via API")
|
||||
links_management: bool = Field(default=True, description="Allow links CRUD via API")
|
||||
|
||||
# Authentication (empty = auth disabled, anyone can access the API)
|
||||
api_tokens: dict[str, str] = Field(
|
||||
@@ -218,21 +249,25 @@ def get_config_dir() -> Path:
|
||||
|
||||
|
||||
def generate_default_config(path: Optional[Path] = None) -> Path:
|
||||
"""Generate a default configuration file with a new API token."""
|
||||
"""Generate a default configuration file with a freshly generated API token.
|
||||
|
||||
The token is written into ``api_tokens.default`` and printed to the logger
|
||||
so first-run users can copy it. Subsequent runs preserve whatever the user
|
||||
has set.
|
||||
"""
|
||||
if path is None:
|
||||
path = get_config_dir() / "config.yaml"
|
||||
|
||||
default_token = secrets.token_urlsafe(32)
|
||||
|
||||
config = {
|
||||
"host": "0.0.0.0",
|
||||
"host": "127.0.0.1",
|
||||
"port": 8765,
|
||||
# "api_tokens": {
|
||||
# "default": "your-secret-token-here",
|
||||
# },
|
||||
"api_tokens": {
|
||||
"default": default_token,
|
||||
},
|
||||
"poll_interval": 1.0,
|
||||
"log_level": "INFO",
|
||||
# Audio device to control (use GET /api/audio/devices to list available devices)
|
||||
# Set to null or remove to use default device
|
||||
# "audio_device": "Speakers (Realtek",
|
||||
"scripts": {
|
||||
"example_script": {
|
||||
"command": "echo Hello from Media Server!",
|
||||
@@ -240,26 +275,38 @@ def generate_default_config(path: Optional[Path] = None) -> Path:
|
||||
"timeout": 10,
|
||||
"shell": True,
|
||||
},
|
||||
# Add your custom scripts here:
|
||||
# "shutdown": {
|
||||
# "command": "shutdown /s /t 60",
|
||||
# "description": "Shutdown computer in 60 seconds",
|
||||
# "timeout": 5,
|
||||
# },
|
||||
# "lock_screen": {
|
||||
# "command": "rundll32.exe user32.dll,LockWorkStation",
|
||||
# "description": "Lock the workstation",
|
||||
# "timeout": 5,
|
||||
# },
|
||||
},
|
||||
}
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(config, f, default_flow_style=False, sort_keys=False)
|
||||
_write_yaml_atomic(path, config)
|
||||
_restrict_config_perms(path)
|
||||
|
||||
logger.info("Generated default config at %s", path)
|
||||
logger.info("API token (label=default): %s", default_token)
|
||||
|
||||
return path
|
||||
|
||||
|
||||
def _write_yaml_atomic(path: Path, data: dict) -> None:
|
||||
"""Write YAML to disk atomically via tmp file + rename, with restricted perms."""
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||
_restrict_config_perms(tmp)
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def _restrict_config_perms(path: Path) -> None:
|
||||
"""On POSIX, ensure config file is readable only by owner (0600)."""
|
||||
if os.name == "nt":
|
||||
return
|
||||
try:
|
||||
os.chmod(path, 0o600)
|
||||
os.chmod(path.parent, 0o700)
|
||||
except OSError:
|
||||
logger.debug("Could not chmod %s", path, exc_info=True)
|
||||
|
||||
|
||||
# Global settings instance
|
||||
settings = Settings.load_from_yaml()
|
||||
|
||||
+147
-402
@@ -1,52 +1,50 @@
|
||||
"""Thread-safe configuration file manager for runtime script updates."""
|
||||
"""Thread-safe configuration file manager for runtime updates."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
from .config import CallbackConfig, LinkConfig, MediaFolderConfig, ScriptConfig, settings
|
||||
from .config import (
|
||||
CallbackConfig,
|
||||
LinkConfig,
|
||||
MediaFolderConfig,
|
||||
ScriptConfig,
|
||||
_restrict_config_perms,
|
||||
_write_yaml_atomic,
|
||||
settings,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConfigManager:
|
||||
"""Thread-safe configuration file manager."""
|
||||
"""Thread-safe configuration file manager.
|
||||
|
||||
All writes go through ``_save()`` which writes to ``config.yaml.tmp`` and
|
||||
then ``os.replace()``s it into place so a crash mid-write cannot corrupt
|
||||
the only persistent user data. On POSIX the file is also chmodded to 0600
|
||||
so co-tenant users cannot read the API token.
|
||||
"""
|
||||
|
||||
def __init__(self, config_path: Optional[Path] = None):
|
||||
"""Initialize the config manager.
|
||||
|
||||
Args:
|
||||
config_path: Path to config file. If None, will search standard locations.
|
||||
"""
|
||||
self._lock = threading.Lock()
|
||||
self._config_path = config_path or self._find_config_path()
|
||||
logger.info(f"ConfigManager initialized with path: {self._config_path}")
|
||||
|
||||
def _find_config_path(self) -> Path:
|
||||
"""Find the active config file path.
|
||||
@staticmethod
|
||||
def _find_config_path() -> Path:
|
||||
"""Find the active config file path (or the default if none exists yet)."""
|
||||
search_paths = [Path("config.yaml"), Path("config.yml")]
|
||||
|
||||
Returns:
|
||||
Path to the config file.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If no config file is found.
|
||||
"""
|
||||
# Same search logic as Settings.load_from_yaml()
|
||||
search_paths = [
|
||||
Path("config.yaml"),
|
||||
Path("config.yml"),
|
||||
]
|
||||
|
||||
# Add platform-specific config directory
|
||||
if os.name == "nt": # Windows
|
||||
if os.name == "nt":
|
||||
appdata = os.environ.get("APPDATA", "")
|
||||
if appdata:
|
||||
search_paths.append(Path(appdata) / "media-server" / "config.yaml")
|
||||
else: # Linux/Unix/macOS
|
||||
else:
|
||||
search_paths.append(Path.home() / ".config" / "media-server" / "config.yaml")
|
||||
search_paths.append(Path("/etc/media-server/config.yaml"))
|
||||
|
||||
@@ -54,7 +52,6 @@ class ConfigManager:
|
||||
if search_path.exists():
|
||||
return search_path
|
||||
|
||||
# If not found, use the default location
|
||||
if os.name == "nt":
|
||||
default_path = Path(os.environ.get("APPDATA", "")) / "media-server" / "config.yaml"
|
||||
else:
|
||||
@@ -63,422 +60,170 @@ class ConfigManager:
|
||||
logger.warning(f"No config file found, using default path: {default_path}")
|
||||
return default_path
|
||||
|
||||
def add_script(self, name: str, config: ScriptConfig) -> None:
|
||||
"""Add a new script to config.
|
||||
def _load(self) -> dict[str, Any]:
|
||||
"""Read the config YAML, returning an empty dict if the file is missing."""
|
||||
if not self._config_path.exists():
|
||||
return {}
|
||||
with open(self._config_path, "r", encoding="utf-8") as f:
|
||||
return yaml.safe_load(f) or {}
|
||||
|
||||
Args:
|
||||
name: Script name (must be unique).
|
||||
config: Script configuration.
|
||||
def _save(self, data: dict[str, Any]) -> None:
|
||||
"""Atomically write the config YAML and lock down its permissions."""
|
||||
self._config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
_write_yaml_atomic(self._config_path, data)
|
||||
_restrict_config_perms(self._config_path)
|
||||
|
||||
Raises:
|
||||
ValueError: If script already exists.
|
||||
IOError: If config file cannot be written.
|
||||
"""
|
||||
# --- Generic per-section CRUD --------------------------------------
|
||||
|
||||
def _upsert(
|
||||
self,
|
||||
section: str,
|
||||
key: str,
|
||||
value: Any,
|
||||
*,
|
||||
require_absent: bool = False,
|
||||
require_present: bool = False,
|
||||
in_memory_target: dict[str, Any] | None = None,
|
||||
verb: str = "set",
|
||||
) -> None:
|
||||
with self._lock:
|
||||
# Read YAML
|
||||
if not self._config_path.exists():
|
||||
data = {}
|
||||
else:
|
||||
with open(self._config_path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
data = self._load()
|
||||
existing = data.get(section, {})
|
||||
if require_absent and key in existing:
|
||||
raise ValueError(f"{section[:-1].title()} '{key}' already exists")
|
||||
if require_present and (not isinstance(existing, dict) or key not in existing):
|
||||
raise ValueError(f"{section[:-1].title()} '{key}' does not exist")
|
||||
|
||||
# Check if script already exists
|
||||
if "scripts" in data and name in data["scripts"]:
|
||||
raise ValueError(f"Script '{name}' already exists")
|
||||
if not isinstance(existing, dict):
|
||||
existing = {}
|
||||
existing[key] = value.model_dump(exclude_none=True)
|
||||
data[section] = existing
|
||||
|
||||
# Add script
|
||||
if "scripts" not in data:
|
||||
data["scripts"] = {}
|
||||
data["scripts"][name] = config.model_dump(exclude_none=True)
|
||||
self._save(data)
|
||||
|
||||
# Write YAML
|
||||
self._config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self._config_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||
if in_memory_target is not None:
|
||||
in_memory_target[key] = value
|
||||
logger.info(f"{section[:-1].title()} '{key}' {verb} in config")
|
||||
|
||||
# Update in-memory settings
|
||||
settings.scripts[name] = config
|
||||
def _delete(
|
||||
self,
|
||||
section: str,
|
||||
key: str,
|
||||
*,
|
||||
in_memory_target: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
with self._lock:
|
||||
data = self._load()
|
||||
existing = data.get(section, {})
|
||||
if not isinstance(existing, dict) or key not in existing:
|
||||
raise ValueError(f"{section[:-1].title()} '{key}' does not exist")
|
||||
del existing[key]
|
||||
data[section] = existing
|
||||
|
||||
logger.info(f"Script '{name}' added to config")
|
||||
self._save(data)
|
||||
|
||||
if in_memory_target is not None and key in in_memory_target:
|
||||
del in_memory_target[key]
|
||||
logger.info(f"{section[:-1].title()} '{key}' deleted from config")
|
||||
|
||||
# --- Scripts -------------------------------------------------------
|
||||
|
||||
def add_script(self, name: str, config: ScriptConfig) -> None:
|
||||
self._upsert(
|
||||
"scripts", name, config,
|
||||
require_absent=True,
|
||||
in_memory_target=settings.scripts,
|
||||
verb="added",
|
||||
)
|
||||
|
||||
def update_script(self, name: str, config: ScriptConfig) -> None:
|
||||
"""Update an existing script.
|
||||
|
||||
Args:
|
||||
name: Script name.
|
||||
config: New script configuration.
|
||||
|
||||
Raises:
|
||||
ValueError: If script does not exist.
|
||||
IOError: If config file cannot be written.
|
||||
"""
|
||||
with self._lock:
|
||||
# Read YAML
|
||||
if not self._config_path.exists():
|
||||
raise ValueError(f"Config file not found: {self._config_path}")
|
||||
|
||||
with open(self._config_path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
|
||||
# Check if script exists
|
||||
if "scripts" not in data or name not in data["scripts"]:
|
||||
raise ValueError(f"Script '{name}' does not exist")
|
||||
|
||||
# Update script
|
||||
data["scripts"][name] = config.model_dump(exclude_none=True)
|
||||
|
||||
# Write YAML
|
||||
with open(self._config_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||
|
||||
# Update in-memory settings
|
||||
settings.scripts[name] = config
|
||||
|
||||
logger.info(f"Script '{name}' updated in config")
|
||||
self._upsert(
|
||||
"scripts", name, config,
|
||||
require_present=True,
|
||||
in_memory_target=settings.scripts,
|
||||
verb="updated",
|
||||
)
|
||||
|
||||
def delete_script(self, name: str) -> None:
|
||||
"""Delete a script from config.
|
||||
self._delete("scripts", name, in_memory_target=settings.scripts)
|
||||
|
||||
Args:
|
||||
name: Script name.
|
||||
|
||||
Raises:
|
||||
ValueError: If script does not exist.
|
||||
IOError: If config file cannot be written.
|
||||
"""
|
||||
with self._lock:
|
||||
# Read YAML
|
||||
if not self._config_path.exists():
|
||||
raise ValueError(f"Config file not found: {self._config_path}")
|
||||
|
||||
with open(self._config_path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
|
||||
# Check if script exists
|
||||
if "scripts" not in data or name not in data["scripts"]:
|
||||
raise ValueError(f"Script '{name}' does not exist")
|
||||
|
||||
# Delete script
|
||||
del data["scripts"][name]
|
||||
|
||||
# Write YAML
|
||||
with open(self._config_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||
|
||||
# Update in-memory settings
|
||||
if name in settings.scripts:
|
||||
del settings.scripts[name]
|
||||
|
||||
logger.info(f"Script '{name}' deleted from config")
|
||||
# --- Callbacks -----------------------------------------------------
|
||||
|
||||
def add_callback(self, name: str, config: CallbackConfig) -> None:
|
||||
"""Add a new callback to config.
|
||||
|
||||
Args:
|
||||
name: Callback name (must be unique).
|
||||
config: Callback configuration.
|
||||
|
||||
Raises:
|
||||
ValueError: If callback already exists.
|
||||
IOError: If config file cannot be written.
|
||||
"""
|
||||
with self._lock:
|
||||
# Read YAML
|
||||
if not self._config_path.exists():
|
||||
data = {}
|
||||
else:
|
||||
with open(self._config_path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
|
||||
# Check if callback already exists
|
||||
if "callbacks" in data and name in data["callbacks"]:
|
||||
raise ValueError(f"Callback '{name}' already exists")
|
||||
|
||||
# Add callback
|
||||
if "callbacks" not in data:
|
||||
data["callbacks"] = {}
|
||||
data["callbacks"][name] = config.model_dump(exclude_none=True)
|
||||
|
||||
# Write YAML
|
||||
self._config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self._config_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||
|
||||
# Update in-memory settings
|
||||
settings.callbacks[name] = config
|
||||
|
||||
logger.info(f"Callback '{name}' added to config")
|
||||
self._upsert(
|
||||
"callbacks", name, config,
|
||||
require_absent=True,
|
||||
in_memory_target=settings.callbacks,
|
||||
verb="added",
|
||||
)
|
||||
|
||||
def update_callback(self, name: str, config: CallbackConfig) -> None:
|
||||
"""Update an existing callback.
|
||||
|
||||
Args:
|
||||
name: Callback name.
|
||||
config: New callback configuration.
|
||||
|
||||
Raises:
|
||||
ValueError: If callback does not exist.
|
||||
IOError: If config file cannot be written.
|
||||
"""
|
||||
with self._lock:
|
||||
# Read YAML
|
||||
if not self._config_path.exists():
|
||||
raise ValueError(f"Config file not found: {self._config_path}")
|
||||
|
||||
with open(self._config_path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
|
||||
# Check if callback exists
|
||||
if "callbacks" not in data or name not in data["callbacks"]:
|
||||
raise ValueError(f"Callback '{name}' does not exist")
|
||||
|
||||
# Update callback
|
||||
data["callbacks"][name] = config.model_dump(exclude_none=True)
|
||||
|
||||
# Write YAML
|
||||
with open(self._config_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||
|
||||
# Update in-memory settings
|
||||
settings.callbacks[name] = config
|
||||
|
||||
logger.info(f"Callback '{name}' updated in config")
|
||||
self._upsert(
|
||||
"callbacks", name, config,
|
||||
require_present=True,
|
||||
in_memory_target=settings.callbacks,
|
||||
verb="updated",
|
||||
)
|
||||
|
||||
def delete_callback(self, name: str) -> None:
|
||||
"""Delete a callback from config.
|
||||
self._delete("callbacks", name, in_memory_target=settings.callbacks)
|
||||
|
||||
Args:
|
||||
name: Callback name.
|
||||
|
||||
Raises:
|
||||
ValueError: If callback does not exist.
|
||||
IOError: If config file cannot be written.
|
||||
"""
|
||||
with self._lock:
|
||||
# Read YAML
|
||||
if not self._config_path.exists():
|
||||
raise ValueError(f"Config file not found: {self._config_path}")
|
||||
|
||||
with open(self._config_path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
|
||||
# Check if callback exists
|
||||
if "callbacks" not in data or name not in data["callbacks"]:
|
||||
raise ValueError(f"Callback '{name}' does not exist")
|
||||
|
||||
# Delete callback
|
||||
del data["callbacks"][name]
|
||||
|
||||
# Write YAML
|
||||
with open(self._config_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||
|
||||
# Update in-memory settings
|
||||
if name in settings.callbacks:
|
||||
del settings.callbacks[name]
|
||||
|
||||
logger.info(f"Callback '{name}' deleted from config")
|
||||
# --- Media folders -------------------------------------------------
|
||||
|
||||
def add_media_folder(self, folder_id: str, config: MediaFolderConfig) -> None:
|
||||
"""Add a new media folder to config.
|
||||
|
||||
Args:
|
||||
folder_id: Folder ID (must be unique).
|
||||
config: Media folder configuration.
|
||||
|
||||
Raises:
|
||||
ValueError: If folder already exists.
|
||||
IOError: If config file cannot be written.
|
||||
"""
|
||||
with self._lock:
|
||||
# Read YAML
|
||||
if not self._config_path.exists():
|
||||
data = {}
|
||||
else:
|
||||
with open(self._config_path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
|
||||
# Check if folder already exists
|
||||
if "media_folders" in data and folder_id in data["media_folders"]:
|
||||
raise ValueError(f"Media folder '{folder_id}' already exists")
|
||||
|
||||
# Add folder
|
||||
if "media_folders" not in data:
|
||||
data["media_folders"] = {}
|
||||
data["media_folders"][folder_id] = config.model_dump(exclude_none=True)
|
||||
|
||||
# Write YAML
|
||||
self._config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self._config_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||
|
||||
# Update in-memory settings
|
||||
settings.media_folders[folder_id] = config
|
||||
|
||||
logger.info(f"Media folder '{folder_id}' added to config")
|
||||
self._upsert(
|
||||
"media_folders", folder_id, config,
|
||||
require_absent=True,
|
||||
in_memory_target=settings.media_folders,
|
||||
verb="added",
|
||||
)
|
||||
|
||||
def update_media_folder(self, folder_id: str, config: MediaFolderConfig) -> None:
|
||||
"""Update an existing media folder.
|
||||
|
||||
Args:
|
||||
folder_id: Folder ID.
|
||||
config: New media folder configuration.
|
||||
|
||||
Raises:
|
||||
ValueError: If folder does not exist.
|
||||
IOError: If config file cannot be written.
|
||||
"""
|
||||
with self._lock:
|
||||
# Read YAML
|
||||
if not self._config_path.exists():
|
||||
raise ValueError(f"Config file not found: {self._config_path}")
|
||||
|
||||
with open(self._config_path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
|
||||
# Check if folder exists
|
||||
if "media_folders" not in data or folder_id not in data["media_folders"]:
|
||||
raise ValueError(f"Media folder '{folder_id}' does not exist")
|
||||
|
||||
# Update folder
|
||||
data["media_folders"][folder_id] = config.model_dump(exclude_none=True)
|
||||
|
||||
# Write YAML
|
||||
with open(self._config_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||
|
||||
# Update in-memory settings
|
||||
settings.media_folders[folder_id] = config
|
||||
|
||||
logger.info(f"Media folder '{folder_id}' updated in config")
|
||||
self._upsert(
|
||||
"media_folders", folder_id, config,
|
||||
require_present=True,
|
||||
in_memory_target=settings.media_folders,
|
||||
verb="updated",
|
||||
)
|
||||
|
||||
def delete_media_folder(self, folder_id: str) -> None:
|
||||
"""Delete a media folder from config.
|
||||
self._delete("media_folders", folder_id, in_memory_target=settings.media_folders)
|
||||
|
||||
Args:
|
||||
folder_id: Folder ID.
|
||||
|
||||
Raises:
|
||||
ValueError: If folder does not exist.
|
||||
IOError: If config file cannot be written.
|
||||
"""
|
||||
with self._lock:
|
||||
# Read YAML
|
||||
if not self._config_path.exists():
|
||||
raise ValueError(f"Config file not found: {self._config_path}")
|
||||
|
||||
with open(self._config_path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
|
||||
# Check if folder exists
|
||||
if "media_folders" not in data or folder_id not in data["media_folders"]:
|
||||
raise ValueError(f"Media folder '{folder_id}' does not exist")
|
||||
|
||||
# Delete folder
|
||||
del data["media_folders"][folder_id]
|
||||
|
||||
# Write YAML
|
||||
with open(self._config_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||
|
||||
# Update in-memory settings
|
||||
if folder_id in settings.media_folders:
|
||||
del settings.media_folders[folder_id]
|
||||
|
||||
logger.info(f"Media folder '{folder_id}' deleted from config")
|
||||
# --- Links ---------------------------------------------------------
|
||||
|
||||
def add_link(self, name: str, config: LinkConfig) -> None:
|
||||
"""Add a new link to config."""
|
||||
with self._lock:
|
||||
if not self._config_path.exists():
|
||||
data = {}
|
||||
else:
|
||||
with open(self._config_path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
|
||||
if "links" in data and name in data["links"]:
|
||||
raise ValueError(f"Link '{name}' already exists")
|
||||
|
||||
if "links" not in data:
|
||||
data["links"] = {}
|
||||
data["links"][name] = config.model_dump(exclude_none=True)
|
||||
|
||||
self._config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self._config_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||
|
||||
settings.links[name] = config
|
||||
logger.info(f"Link '{name}' added to config")
|
||||
self._upsert(
|
||||
"links", name, config,
|
||||
require_absent=True,
|
||||
in_memory_target=settings.links,
|
||||
verb="added",
|
||||
)
|
||||
|
||||
def update_link(self, name: str, config: LinkConfig) -> None:
|
||||
"""Update an existing link."""
|
||||
with self._lock:
|
||||
if not self._config_path.exists():
|
||||
raise ValueError(f"Config file not found: {self._config_path}")
|
||||
|
||||
with open(self._config_path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
|
||||
if "links" not in data or name not in data["links"]:
|
||||
raise ValueError(f"Link '{name}' does not exist")
|
||||
|
||||
data["links"][name] = config.model_dump(exclude_none=True)
|
||||
|
||||
with open(self._config_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||
|
||||
settings.links[name] = config
|
||||
logger.info(f"Link '{name}' updated in config")
|
||||
self._upsert(
|
||||
"links", name, config,
|
||||
require_present=True,
|
||||
in_memory_target=settings.links,
|
||||
verb="updated",
|
||||
)
|
||||
|
||||
def delete_link(self, name: str) -> None:
|
||||
"""Delete a link from config."""
|
||||
self._delete("links", name, in_memory_target=settings.links)
|
||||
|
||||
# --- Top-level settings --------------------------------------------
|
||||
|
||||
def set_setting(self, key: str, value: Any) -> None:
|
||||
"""Set a top-level config setting and persist to YAML."""
|
||||
with self._lock:
|
||||
if not self._config_path.exists():
|
||||
raise ValueError(f"Config file not found: {self._config_path}")
|
||||
|
||||
with open(self._config_path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
|
||||
if "links" not in data or name not in data["links"]:
|
||||
raise ValueError(f"Link '{name}' does not exist")
|
||||
|
||||
del data["links"][name]
|
||||
|
||||
with open(self._config_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||
|
||||
if name in settings.links:
|
||||
del settings.links[name]
|
||||
logger.info(f"Link '{name}' deleted from config")
|
||||
|
||||
def set_setting(self, key: str, value) -> None:
|
||||
"""Set a top-level config setting and persist to YAML.
|
||||
|
||||
Args:
|
||||
key: Setting name (e.g., "visualizer_device").
|
||||
value: Setting value (None removes the key).
|
||||
"""
|
||||
with self._lock:
|
||||
if not self._config_path.exists():
|
||||
data = {}
|
||||
else:
|
||||
with open(self._config_path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
|
||||
data = self._load()
|
||||
if value is None:
|
||||
data.pop(key, None)
|
||||
else:
|
||||
data[key] = value
|
||||
|
||||
self._config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self._config_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||
|
||||
# Update in-memory settings
|
||||
self._save(data)
|
||||
if hasattr(settings, key):
|
||||
setattr(settings, key, value)
|
||||
logger.info("Setting '%s' updated to: %s", key, value)
|
||||
|
||||
|
||||
# Global config manager instance
|
||||
config_manager = ConfigManager()
|
||||
|
||||
+95
-9
@@ -63,10 +63,10 @@ async def lifespan(app: FastAPI):
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"Media Server starting on {settings.host}:{settings.port}")
|
||||
|
||||
# Log authentication status
|
||||
# Log authentication status — never log full or partial token material.
|
||||
if settings.api_tokens:
|
||||
for label, token in settings.api_tokens.items():
|
||||
logger.info(f"API Token [{label}]: {token[:8]}...")
|
||||
labels = ", ".join(settings.api_tokens.keys())
|
||||
logger.info(f"Authentication enabled. Tokens configured: [{labels}]")
|
||||
else:
|
||||
logger.warning("No API tokens configured — authentication is DISABLED")
|
||||
|
||||
@@ -87,6 +87,24 @@ async def lifespan(app: FastAPI):
|
||||
# Store globally so health endpoint can access cached result
|
||||
app.state.update_checker = update_checker
|
||||
|
||||
# Schedule periodic thumbnail cache cleanup so the 500 MB cap is actually
|
||||
# enforced. Runs once at startup and then hourly until shutdown.
|
||||
from .services.thumbnail_service import ThumbnailService
|
||||
|
||||
async def _thumbnail_cleanup_loop() -> None:
|
||||
while True:
|
||||
try:
|
||||
await asyncio.to_thread(ThumbnailService.cleanup_cache)
|
||||
except Exception as e:
|
||||
logger.warning("Thumbnail cache cleanup failed: %s", e)
|
||||
try:
|
||||
await asyncio.sleep(3600)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
|
||||
import asyncio
|
||||
cleanup_task = asyncio.create_task(_thumbnail_cleanup_loop())
|
||||
|
||||
# Register audio visualizer (capture starts on-demand when clients subscribe)
|
||||
analyzer = None
|
||||
if settings.visualizer_enabled:
|
||||
@@ -109,6 +127,13 @@ async def lifespan(app: FastAPI):
|
||||
if update_checker is not None:
|
||||
await update_checker.stop()
|
||||
|
||||
# Cancel periodic thumbnail cleanup
|
||||
cleanup_task.cancel()
|
||||
try:
|
||||
await cleanup_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# Stop audio visualizer
|
||||
await ws_manager.stop_audio_monitor()
|
||||
if analyzer and analyzer.running:
|
||||
@@ -117,6 +142,13 @@ async def lifespan(app: FastAPI):
|
||||
# Stop WebSocket status monitor
|
||||
await ws_manager.stop_status_monitor()
|
||||
|
||||
# Shut down dedicated thread pools so pending scripts don't leak threads
|
||||
from .routes.callbacks import shutdown_callback_executor
|
||||
from .routes.scripts import shutdown_script_executor
|
||||
|
||||
shutdown_script_executor()
|
||||
shutdown_callback_executor()
|
||||
|
||||
# Clean up platform-specific resources
|
||||
import platform as _platform
|
||||
if _platform.system() == "Windows":
|
||||
@@ -138,16 +170,43 @@ def create_app() -> FastAPI:
|
||||
# Compress responses > 1KB
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
||||
|
||||
# Add CORS middleware for cross-origin requests
|
||||
# Token auth is via Authorization header, not cookies, so credentials are not needed
|
||||
# CORS — restrict to same-origin by default; users that integrate the API
|
||||
# from another origin (e.g. Home Assistant on a different host) can set
|
||||
# cors_origins in config.yaml.
|
||||
cors_origins = settings.cors_origins or [
|
||||
f"http://localhost:{settings.port}",
|
||||
f"http://127.0.0.1:{settings.port}",
|
||||
]
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_origins=cors_origins,
|
||||
allow_credentials=False,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Authorization", "Content-Type"],
|
||||
)
|
||||
|
||||
# Security headers — strict CSP for the bundled UI, disallow framing, hide referrer.
|
||||
@app.middleware("http")
|
||||
async def security_headers_middleware(request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
response.headers.setdefault(
|
||||
"Content-Security-Policy",
|
||||
(
|
||||
"default-src 'self'; "
|
||||
"img-src 'self' data: blob: https://api.iconify.design; "
|
||||
"connect-src 'self' https://api.iconify.design ws: wss:; "
|
||||
"script-src 'self'; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"font-src 'self' data:; "
|
||||
"frame-ancestors 'none'; "
|
||||
"base-uri 'self'"
|
||||
),
|
||||
)
|
||||
response.headers.setdefault("X-Frame-Options", "DENY")
|
||||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||
response.headers.setdefault("Referrer-Policy", "no-referrer")
|
||||
return response
|
||||
|
||||
# Add token logging middleware
|
||||
@app.middleware("http")
|
||||
async def token_logging_middleware(request: Request, call_next):
|
||||
@@ -247,7 +306,8 @@ def main():
|
||||
if args.generate_config:
|
||||
config_path = generate_default_config()
|
||||
print(f"Configuration file generated at: {config_path}")
|
||||
print("Authentication is disabled by default. Add api_tokens to enable it.")
|
||||
print("A random API token was generated under api_tokens.default.")
|
||||
print("Run `python -m media_server.main --show-token` to view it.")
|
||||
return
|
||||
|
||||
if args.show_token:
|
||||
@@ -260,6 +320,32 @@ def main():
|
||||
print("\nAuthentication is DISABLED (no tokens configured)")
|
||||
return
|
||||
|
||||
# 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)
|
||||
print(
|
||||
f"\nFirst run: generated default config at {config_path}.\n"
|
||||
"Run --show-token to retrieve the API token, then restart.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(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:
|
||||
print(
|
||||
"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.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# Check if port is available before starting
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
try:
|
||||
|
||||
+100
-80
@@ -23,6 +23,17 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/browser", tags=["browser"])
|
||||
|
||||
# Strong refs to background tasks so they don't get garbage-collected mid-flight.
|
||||
_background_tasks: set[asyncio.Task] = set()
|
||||
|
||||
|
||||
def _spawn_background(coro) -> asyncio.Task:
|
||||
"""Schedule a background coroutine and keep a strong ref to its Task."""
|
||||
task = asyncio.create_task(coro)
|
||||
_background_tasks.add(task)
|
||||
task.add_done_callback(_background_tasks.discard)
|
||||
return task
|
||||
|
||||
|
||||
def _require_folder_management() -> None:
|
||||
"""Raise 403 if media folder management is disabled in config."""
|
||||
@@ -38,16 +49,23 @@ async def _broadcast_after_open(controller, label: str, max_wait: float = 2.0) -
|
||||
|
||||
Fires as a background task so the HTTP response returns immediately.
|
||||
"""
|
||||
status = None
|
||||
try:
|
||||
interval = 0.3
|
||||
elapsed = 0.0
|
||||
while elapsed < max_wait:
|
||||
await asyncio.sleep(interval)
|
||||
elapsed += interval
|
||||
status = await controller.get_status()
|
||||
try:
|
||||
status = await controller.get_status()
|
||||
except Exception as poll_err: # noqa: BLE001 — broadcast is best-effort
|
||||
logger.debug("get_status during broadcast poll failed: %s", poll_err)
|
||||
continue
|
||||
if status.state in ("playing", "paused"):
|
||||
break
|
||||
|
||||
if status is None:
|
||||
return
|
||||
status_dict = status.model_dump()
|
||||
await ws_manager.broadcast({"type": "status", "data": status_dict})
|
||||
logger.info(f"Broadcasted status update after opening: {label}")
|
||||
@@ -74,9 +92,14 @@ class FolderUpdateRequest(BaseModel):
|
||||
|
||||
|
||||
class PlayRequest(BaseModel):
|
||||
"""Request model for playing a media file."""
|
||||
"""Request model for playing a media file.
|
||||
|
||||
path: str = Field(..., description="Full path to the media file")
|
||||
Both ``folder_id`` and ``path`` are required so the server can validate
|
||||
the file lives inside a configured media folder.
|
||||
"""
|
||||
|
||||
folder_id: str = Field(..., description="Media folder ID")
|
||||
path: str = Field(..., description="Path relative to folder root")
|
||||
|
||||
|
||||
class PlayFolderRequest(BaseModel):
|
||||
@@ -128,8 +151,10 @@ async def create_folder(
|
||||
"""
|
||||
_require_folder_management()
|
||||
try:
|
||||
# Validate folder_id format (alphanumeric and underscore only)
|
||||
if not request.folder_id.replace("_", "").isalnum():
|
||||
# Validate folder_id format (alphanumeric and underscore only).
|
||||
# Same constraint is enforced when validating paths so traversal can't
|
||||
# be smuggled through the ID itself.
|
||||
if not request.folder_id or not request.folder_id.replace("_", "").isalnum():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Folder ID must contain only alphanumeric characters and underscores",
|
||||
@@ -277,13 +302,15 @@ async def browse(
|
||||
# URL decode the path
|
||||
decoded_path = unquote(path)
|
||||
|
||||
# Browse directory
|
||||
result = BrowserService.browse_directory(
|
||||
folder_id=folder_id,
|
||||
path=decoded_path,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
nocache=nocache,
|
||||
# Browse directory in a thread — iterdir() + stat() can block on
|
||||
# network shares for many seconds; never run on the event loop.
|
||||
result = await asyncio.to_thread(
|
||||
BrowserService.browse_directory,
|
||||
folder_id,
|
||||
decoded_path,
|
||||
offset,
|
||||
limit,
|
||||
nocache,
|
||||
)
|
||||
|
||||
return result
|
||||
@@ -307,41 +334,40 @@ async def browse(
|
||||
# Metadata Endpoint
|
||||
@router.get("/metadata")
|
||||
async def get_metadata(
|
||||
path: str = Query(..., description="Full path to media file (URL-encoded)"),
|
||||
folder_id: str = Query(..., description="Media folder ID"),
|
||||
path: str = Query(..., description="Path relative to folder root (URL-encoded)"),
|
||||
_: str = Depends(verify_token),
|
||||
):
|
||||
"""Get metadata for a media file.
|
||||
"""Get metadata for a media file inside a configured media folder.
|
||||
|
||||
Args:
|
||||
path: Full path to the media file (URL-encoded).
|
||||
folder_id: ID of the media folder.
|
||||
path: Path relative to folder root (URL-encoded).
|
||||
|
||||
Returns:
|
||||
Media file metadata.
|
||||
|
||||
Raises:
|
||||
HTTPException: If file not found or metadata extraction fails.
|
||||
"""
|
||||
try:
|
||||
# URL decode the path
|
||||
decoded_path = unquote(path)
|
||||
file_path = Path(decoded_path)
|
||||
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
file_path = BrowserService.validate_path(folder_id, decoded_path)
|
||||
|
||||
if not file_path.is_file():
|
||||
raise HTTPException(status_code=400, detail="Path is not a file")
|
||||
if not BrowserService.is_media_file(file_path):
|
||||
raise HTTPException(status_code=400, detail="File is not a media file")
|
||||
|
||||
# Extract metadata in executor (blocking operation)
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
metadata = await loop.run_in_executor(
|
||||
None,
|
||||
MetadataService.extract_metadata,
|
||||
file_path,
|
||||
)
|
||||
|
||||
return metadata
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -352,59 +378,47 @@ async def get_metadata(
|
||||
# Thumbnail Endpoint
|
||||
@router.get("/thumbnail")
|
||||
async def get_thumbnail(
|
||||
path: str = Query(..., description="Full path to media file (URL-encoded)"),
|
||||
folder_id: str = Query(..., description="Media folder ID"),
|
||||
path: str = Query(..., description="Path relative to folder root (URL-encoded)"),
|
||||
size: str = Query(default="medium", description='Thumbnail size: "small" or "medium"'),
|
||||
_: str = Depends(verify_token),
|
||||
):
|
||||
"""Get thumbnail for a media file.
|
||||
|
||||
Args:
|
||||
path: Full path to the media file (URL-encoded).
|
||||
size: Thumbnail size ("small" or "medium").
|
||||
|
||||
Returns:
|
||||
JPEG image bytes.
|
||||
|
||||
Raises:
|
||||
HTTPException: If file not found or thumbnail generation fails.
|
||||
"""
|
||||
"""Get thumbnail for a media file inside a configured media folder."""
|
||||
try:
|
||||
# URL decode the path
|
||||
decoded_path = unquote(path)
|
||||
file_path = Path(decoded_path)
|
||||
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
file_path = BrowserService.validate_path(folder_id, decoded_path)
|
||||
|
||||
if not file_path.is_file():
|
||||
raise HTTPException(status_code=400, detail="Path is not a file")
|
||||
if not BrowserService.is_media_file(file_path):
|
||||
raise HTTPException(status_code=400, detail="File is not a media file")
|
||||
|
||||
# Validate size
|
||||
if size not in ("small", "medium"):
|
||||
size = "medium"
|
||||
|
||||
# Get thumbnail
|
||||
thumbnail_data = await ThumbnailService.get_thumbnail(file_path, size)
|
||||
|
||||
if thumbnail_data is None:
|
||||
return Response(status_code=204)
|
||||
|
||||
# Calculate ETag (hash of path + mtime)
|
||||
import hashlib
|
||||
stat = file_path.stat()
|
||||
etag_data = f"{file_path}:{stat.st_mtime}:{size}".encode()
|
||||
etag = hashlib.md5(etag_data).hexdigest()
|
||||
|
||||
# Return image with caching headers
|
||||
return Response(
|
||||
content=thumbnail_data,
|
||||
media_type="image/jpeg",
|
||||
headers={
|
||||
"ETag": f'"{etag}"',
|
||||
"Cache-Control": "public, max-age=86400", # 24 hours
|
||||
"Cache-Control": "public, max-age=86400",
|
||||
},
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -420,44 +434,37 @@ async def play_file(
|
||||
):
|
||||
"""Open a media file with the default system player.
|
||||
|
||||
Args:
|
||||
request: Play request with file path.
|
||||
|
||||
Returns:
|
||||
Success message.
|
||||
|
||||
Raises:
|
||||
HTTPException: If file not found or playback fails.
|
||||
Requires both ``folder_id`` and a folder-relative ``path``; the resolved
|
||||
file must live inside the configured media folder and be a recognized
|
||||
media file. This prevents arbitrary OS-handler invocation (e.g.,
|
||||
``os.startfile`` on Windows ``.lnk``/UNC paths).
|
||||
"""
|
||||
try:
|
||||
file_path = Path(request.path)
|
||||
|
||||
# Validate file exists
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
decoded_path = unquote(request.path)
|
||||
file_path = BrowserService.validate_path(request.folder_id, decoded_path)
|
||||
|
||||
if not file_path.is_file():
|
||||
raise HTTPException(status_code=400, detail="Path is not a file")
|
||||
|
||||
# Validate file is a media file
|
||||
if not BrowserService.is_media_file(file_path):
|
||||
raise HTTPException(status_code=400, detail="File is not a media file")
|
||||
|
||||
# Get media controller and open file
|
||||
controller = get_media_controller()
|
||||
success = await controller.open_file(str(file_path))
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="Failed to open file")
|
||||
|
||||
# Poll until player registers with media session API (up to 2s)
|
||||
asyncio.create_task(_broadcast_after_open(controller, file_path.name))
|
||||
_spawn_background(_broadcast_after_open(controller, file_path.name))
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Playing {file_path.name}",
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -489,26 +496,38 @@ async def play_folder(
|
||||
if not full_path.is_dir():
|
||||
raise HTTPException(status_code=400, detail="Path is not a directory")
|
||||
|
||||
# Collect all media files sorted by name
|
||||
media_files = sorted(
|
||||
[f for f in full_path.iterdir() if f.is_file() and BrowserService.is_media_file(f)],
|
||||
key=lambda f: f.name.lower(),
|
||||
)
|
||||
def _scan(directory: Path) -> list[Path]:
|
||||
return sorted(
|
||||
(
|
||||
f for f in directory.iterdir()
|
||||
if f.is_file() and BrowserService.is_media_file(f)
|
||||
),
|
||||
key=lambda f: f.name.lower(),
|
||||
)
|
||||
|
||||
media_files = await asyncio.to_thread(_scan, full_path)
|
||||
|
||||
if not media_files:
|
||||
raise HTTPException(status_code=404, detail="No media files found in this folder")
|
||||
|
||||
# Generate M3U playlist with absolute paths and EXTINF entries
|
||||
# Written to local temp dir to avoid extra SMB file handle on network shares
|
||||
# Uses utf-8-sig (BOM) so players detect encoding properly
|
||||
# Generate M3U playlist with absolute paths and EXTINF entries.
|
||||
# Use NamedTemporaryFile to get a fresh per-call path — prevents
|
||||
# symlink-clobber races between concurrent /play-folder requests
|
||||
# and any local user pre-creating a fixed temp filename.
|
||||
lines = ["#EXTM3U"]
|
||||
for f in media_files:
|
||||
lines.append(f"#EXTINF:-1,{f.stem}")
|
||||
lines.append(str(f))
|
||||
m3u_content = "\r\n".join(lines) + "\r\n"
|
||||
m3u_content = ("\r\n".join(lines) + "\r\n").encode("utf-8-sig")
|
||||
|
||||
playlist_path = Path(tempfile.gettempdir()) / ".media_server_playlist.m3u"
|
||||
playlist_path.write_text(m3u_content, encoding="utf-8-sig")
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="wb",
|
||||
prefix=".media_server_playlist_",
|
||||
suffix=".m3u",
|
||||
delete=False,
|
||||
) as f:
|
||||
f.write(m3u_content)
|
||||
playlist_path = Path(f.name)
|
||||
|
||||
# Open playlist with default player
|
||||
controller = get_media_controller()
|
||||
@@ -517,8 +536,9 @@ async def play_folder(
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="Failed to open playlist")
|
||||
|
||||
# Poll until player registers with media session API (up to 2s)
|
||||
asyncio.create_task(_broadcast_after_open(controller, f"playlist ({len(media_files)} files)"))
|
||||
_spawn_background(
|
||||
_broadcast_after_open(controller, f"playlist ({len(media_files)} files)")
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Any
|
||||
@@ -21,6 +22,22 @@ logger = logging.getLogger(__name__)
|
||||
_callback_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="callback")
|
||||
|
||||
|
||||
def shutdown_callback_executor() -> None:
|
||||
"""Shut down the callback executor cleanly on application teardown."""
|
||||
_callback_executor.shutdown(wait=False, cancel_futures=True)
|
||||
|
||||
|
||||
def _require_callbacks_management() -> None:
|
||||
if not settings.callbacks_management:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=(
|
||||
"Callbacks management is disabled. Set callbacks_management: true"
|
||||
" in config.yaml to enable."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class CallbackInfo(BaseModel):
|
||||
"""Information about a configured callback."""
|
||||
|
||||
@@ -131,7 +148,7 @@ async def execute_callback(
|
||||
|
||||
try:
|
||||
# Execute in dedicated thread pool to not block the default executor
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
result = await loop.run_in_executor(
|
||||
_callback_executor,
|
||||
lambda: _run_callback(
|
||||
@@ -178,6 +195,11 @@ def _run_callback(
|
||||
Dict with exit_code, stdout, stderr, execution_time
|
||||
"""
|
||||
start_time = time.time()
|
||||
popen_kwargs: dict[str, Any] = {}
|
||||
if sys.platform == "win32":
|
||||
popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
else:
|
||||
popen_kwargs["start_new_session"] = True
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
@@ -186,6 +208,7 @@ def _run_callback(
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
**popen_kwargs,
|
||||
)
|
||||
execution_time = time.time() - start_time
|
||||
return {
|
||||
@@ -230,7 +253,7 @@ async def create_callback(
|
||||
Raises:
|
||||
HTTPException: If callback already exists or name is invalid.
|
||||
"""
|
||||
# Validate name
|
||||
_require_callbacks_management()
|
||||
_validate_callback_name(callback_name)
|
||||
|
||||
# Check if callback already exists
|
||||
@@ -278,7 +301,7 @@ async def update_callback(
|
||||
Raises:
|
||||
HTTPException: If callback does not exist.
|
||||
"""
|
||||
# Validate name
|
||||
_require_callbacks_management()
|
||||
_validate_callback_name(callback_name)
|
||||
|
||||
# Check if callback exists
|
||||
@@ -324,7 +347,7 @@ async def delete_callback(
|
||||
Raises:
|
||||
HTTPException: If callback does not exist.
|
||||
"""
|
||||
# Validate name
|
||||
_require_callbacks_management()
|
||||
_validate_callback_name(callback_name)
|
||||
|
||||
# Check if callback exists
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Display brightness and power control API endpoints."""
|
||||
"""Display brightness, power, contrast, input-source, color-preset and picture-mode API."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
@@ -9,6 +10,10 @@ from ..auth import verify_token
|
||||
from ..services.display_service import (
|
||||
list_monitors,
|
||||
set_brightness,
|
||||
set_color_preset,
|
||||
set_contrast,
|
||||
set_input_source,
|
||||
set_picture_mode,
|
||||
set_power,
|
||||
)
|
||||
|
||||
@@ -25,12 +30,37 @@ class PowerRequest(BaseModel):
|
||||
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")
|
||||
async def get_monitors(
|
||||
refresh: bool = False, _: str = Depends(verify_token)
|
||||
refresh: bool = False,
|
||||
rediscover: bool = False,
|
||||
_: str = Depends(verify_token),
|
||||
) -> list[dict]:
|
||||
"""List all connected monitors with brightness and power info."""
|
||||
monitors = list_monitors(force_refresh=refresh)
|
||||
"""List all connected monitors with their reported DDC/CI capabilities."""
|
||||
monitors = await asyncio.to_thread(
|
||||
list_monitors, force_refresh=refresh, rediscover=rediscover
|
||||
)
|
||||
logger.debug("Found %d monitors", len(monitors))
|
||||
return [m.to_dict() for m in monitors]
|
||||
|
||||
@@ -40,7 +70,7 @@ async def set_monitor_brightness(
|
||||
monitor_id: int, request: BrightnessRequest, _: str = Depends(verify_token)
|
||||
) -> dict:
|
||||
"""Set brightness for a specific monitor."""
|
||||
success = set_brightness(monitor_id, request.brightness)
|
||||
success = await asyncio.to_thread(set_brightness, monitor_id, request.brightness)
|
||||
if success:
|
||||
logger.info("Set monitor %d brightness to %d", monitor_id, request.brightness)
|
||||
return {"success": success}
|
||||
@@ -52,7 +82,51 @@ async def set_monitor_power(
|
||||
) -> dict:
|
||||
"""Turn a monitor on or off."""
|
||||
action = "on" if request.on else "off"
|
||||
success = set_power(monitor_id, request.on)
|
||||
success = await asyncio.to_thread(set_power, monitor_id, request.on)
|
||||
if success:
|
||||
logger.info("Set monitor %d power %s", monitor_id, action)
|
||||
return {"success": success}
|
||||
|
||||
|
||||
@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}
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from ..auth import verify_token
|
||||
from ..config import LinkConfig, settings
|
||||
@@ -15,6 +16,35 @@ from ..services.websocket_manager import ws_manager
|
||||
router = APIRouter(prefix="/api/links", tags=["links"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Only allow MDI iconify slugs and safe `http(s)`-ish URLs through the API.
|
||||
_MDI_ICON_RE = re.compile(r"^mdi:[a-z0-9][a-z0-9-]{0,63}$")
|
||||
_ALLOWED_URL_SCHEMES = {"http", "https"}
|
||||
|
||||
|
||||
def _validate_url(url: str) -> str:
|
||||
"""Ensure the URL is well-formed http(s) — no ``javascript:`` etc."""
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme.lower() not in _ALLOWED_URL_SCHEMES:
|
||||
raise ValueError("URL must start with http:// or https://")
|
||||
if not parsed.netloc:
|
||||
raise ValueError("URL must include a host")
|
||||
return url
|
||||
|
||||
|
||||
def _validate_icon(icon: str) -> str:
|
||||
"""Restrict icon names to safe Material Design Icons slugs."""
|
||||
if not _MDI_ICON_RE.match(icon):
|
||||
raise ValueError("Icon must be of the form 'mdi:<lowercase-slug>'")
|
||||
return icon
|
||||
|
||||
|
||||
def _require_links_management() -> None:
|
||||
if not settings.links_management:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Links management is disabled. Set links_management: true in config.yaml to enable.",
|
||||
)
|
||||
|
||||
|
||||
class LinkInfo(BaseModel):
|
||||
"""Information about a configured link."""
|
||||
@@ -29,22 +59,25 @@ class LinkInfo(BaseModel):
|
||||
class LinkCreateRequest(BaseModel):
|
||||
"""Request model for creating or updating a link."""
|
||||
|
||||
url: str = Field(..., description="URL to open", min_length=1)
|
||||
url: str = Field(..., description="URL to open", min_length=1, max_length=2048)
|
||||
icon: str = Field(default="mdi:link", description="MDI icon name (e.g., 'mdi:led-strip-variant')")
|
||||
label: str = Field(default="", description="Tooltip text")
|
||||
description: str = Field(default="", description="Optional description")
|
||||
label: str = Field(default="", description="Tooltip text", max_length=128)
|
||||
description: str = Field(default="", description="Optional description", max_length=512)
|
||||
|
||||
@field_validator("url")
|
||||
@classmethod
|
||||
def _check_url(cls, v: str) -> str:
|
||||
return _validate_url(v)
|
||||
|
||||
@field_validator("icon")
|
||||
@classmethod
|
||||
def _check_icon(cls, v: str) -> str:
|
||||
return _validate_icon(v)
|
||||
|
||||
|
||||
def _validate_link_name(name: str) -> None:
|
||||
"""Validate link name.
|
||||
|
||||
Args:
|
||||
name: Link name to validate.
|
||||
|
||||
Raises:
|
||||
HTTPException: If name is invalid.
|
||||
"""
|
||||
if not re.match(r'^[a-zA-Z0-9_]+$', name):
|
||||
"""Validate link name."""
|
||||
if not re.match(r"^[a-zA-Z0-9_]+$", name):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Link name must contain only letters, numbers, and underscores",
|
||||
@@ -90,6 +123,7 @@ async def create_link(
|
||||
Returns:
|
||||
Success response with link name.
|
||||
"""
|
||||
_require_links_management()
|
||||
_validate_link_name(link_name)
|
||||
|
||||
if link_name in settings.links:
|
||||
@@ -129,6 +163,7 @@ async def update_link(
|
||||
Returns:
|
||||
Success response with link name.
|
||||
"""
|
||||
_require_links_management()
|
||||
_validate_link_name(link_name)
|
||||
|
||||
if link_name not in settings.links:
|
||||
@@ -166,6 +201,7 @@ async def delete_link(
|
||||
Returns:
|
||||
Success response with link name.
|
||||
"""
|
||||
_require_links_management()
|
||||
_validate_link_name(link_name)
|
||||
|
||||
if link_name not in settings.links:
|
||||
|
||||
@@ -27,7 +27,7 @@ def _run_callback(callback_name: str) -> None:
|
||||
|
||||
try:
|
||||
callback = settings.callbacks[callback_name]
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: _run_script(
|
||||
@@ -285,7 +285,7 @@ async def visualizer_devices(_: str = Depends(verify_token)) -> list[dict[str, s
|
||||
"""List available loopback audio devices for the visualizer."""
|
||||
from ..services.audio_analyzer import AudioAnalyzer
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(None, AudioAnalyzer.list_loopback_devices)
|
||||
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Any
|
||||
@@ -23,6 +25,22 @@ _script_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="script"
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def shutdown_script_executor() -> None:
|
||||
"""Shut down the dedicated executor cleanly on application teardown."""
|
||||
_script_executor.shutdown(wait=False, cancel_futures=True)
|
||||
|
||||
|
||||
def _require_scripts_management() -> None:
|
||||
if not settings.scripts_management:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=(
|
||||
"Scripts management is disabled. Set scripts_management: true"
|
||||
" in config.yaml to enable."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ScriptExecuteRequest(BaseModel):
|
||||
"""Request model for script execution with optional parameters."""
|
||||
|
||||
@@ -233,7 +251,7 @@ async def execute_script(
|
||||
|
||||
try:
|
||||
# Execute in dedicated thread pool to not block the default executor
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
result = await loop.run_in_executor(
|
||||
_script_executor,
|
||||
lambda: _run_script(
|
||||
@@ -285,8 +303,16 @@ def _run_script(
|
||||
start_time = time.time()
|
||||
env = None
|
||||
if extra_env:
|
||||
import os
|
||||
env = {**os.environ, **extra_env}
|
||||
|
||||
# Spawn the script in its own process group / job so a timeout kills the
|
||||
# whole tree, not just the shell (POSIX) and not just the parent (Windows).
|
||||
popen_kwargs: dict[str, Any] = {}
|
||||
if sys.platform == "win32":
|
||||
popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
else:
|
||||
popen_kwargs["start_new_session"] = True
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
@@ -296,6 +322,7 @@ def _run_script(
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
env=env,
|
||||
**popen_kwargs,
|
||||
)
|
||||
execution_time = time.time() - start_time
|
||||
return {
|
||||
@@ -455,7 +482,7 @@ async def create_script(
|
||||
Raises:
|
||||
HTTPException: If script already exists or name is invalid.
|
||||
"""
|
||||
# Validate name
|
||||
_require_scripts_management()
|
||||
_validate_script_name(script_name)
|
||||
|
||||
# Check if script already exists
|
||||
@@ -511,7 +538,7 @@ async def update_script(
|
||||
Raises:
|
||||
HTTPException: If script does not exist.
|
||||
"""
|
||||
# Validate name
|
||||
_require_scripts_management()
|
||||
_validate_script_name(script_name)
|
||||
|
||||
# Check if script exists
|
||||
@@ -565,7 +592,7 @@ async def delete_script(
|
||||
Raises:
|
||||
HTTPException: If script does not exist.
|
||||
"""
|
||||
# Validate name
|
||||
_require_scripts_management()
|
||||
_validate_script_name(script_name)
|
||||
|
||||
# Check if script exists
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Audio spectrum analyzer service using system loopback capture."""
|
||||
|
||||
import logging
|
||||
import math
|
||||
import platform
|
||||
import threading
|
||||
import time
|
||||
@@ -15,10 +16,25 @@ def _load_numpy():
|
||||
global _np
|
||||
if _np is None:
|
||||
try:
|
||||
import os
|
||||
import sys
|
||||
if sys.platform == 'win32':
|
||||
# Embedded Python doesn't auto-load DLLs from numpy.libs;
|
||||
# add the directory explicitly so libopenblas can be found.
|
||||
try:
|
||||
import importlib.util
|
||||
spec = importlib.util.find_spec('numpy')
|
||||
if spec and spec.submodule_search_locations:
|
||||
numpy_dir = list(spec.submodule_search_locations)[0]
|
||||
libs_dir = os.path.join(os.path.dirname(numpy_dir), 'numpy.libs')
|
||||
if os.path.isdir(libs_dir):
|
||||
os.add_dll_directory(libs_dir)
|
||||
except Exception:
|
||||
pass
|
||||
import numpy as np
|
||||
_np = np
|
||||
except ImportError:
|
||||
logger.info("numpy not installed - audio visualizer unavailable")
|
||||
except Exception as e:
|
||||
logger.warning("numpy unavailable - audio visualizer disabled: %s", e)
|
||||
return _np
|
||||
|
||||
|
||||
@@ -28,8 +44,8 @@ def _load_soundcard():
|
||||
try:
|
||||
import soundcard as sc
|
||||
_sc = sc
|
||||
except ImportError:
|
||||
logger.info("soundcard not installed - audio visualizer unavailable")
|
||||
except Exception as e:
|
||||
logger.warning("soundcard unavailable - audio visualizer disabled: %s", e)
|
||||
return _sc
|
||||
|
||||
|
||||
@@ -56,6 +72,24 @@ class AudioAnalyzer:
|
||||
self._lifecycle_lock = threading.Lock()
|
||||
self._data: dict | None = None
|
||||
self._current_device_name: str | None = None
|
||||
# Sticky "no usable device" flag — flipped to True if a capture
|
||||
# attempt fails because no loopback device exists. Prevents the
|
||||
# WebSocket manager from looping on start()/stop()/start() forever
|
||||
# when there's nothing to capture. Cleared by set_device().
|
||||
self._unavailable = False
|
||||
# Generation counter — bumped each time _data is refreshed.
|
||||
# Lets the broadcast loop dedupe without comparing dict identity
|
||||
# (which is fragile because we always allocate a new dict).
|
||||
self._data_seq = 0
|
||||
# Threading.Event signaled when new frame data is available.
|
||||
# The broadcast loop awaits this instead of polling on a timer,
|
||||
# so it wakes up exactly once per produced frame.
|
||||
self._data_event = threading.Event()
|
||||
# Slow AGC envelope so the spectrum reflects real dynamics
|
||||
# instead of being renormalized to peak=1.0 every frame.
|
||||
# A loud transient (e.g. notification beep) lifts the reference
|
||||
# for a few seconds afterwards; this is the price of real loudness.
|
||||
self._spectrum_ref = 0.01
|
||||
|
||||
# Pre-compute logarithmic bin edges
|
||||
self._bin_edges = self._compute_bin_edges()
|
||||
@@ -94,6 +128,14 @@ class AudioAnalyzer:
|
||||
return True
|
||||
if not self.available:
|
||||
return False
|
||||
if self._unavailable:
|
||||
# We already tried and failed to acquire a device. Don't
|
||||
# spin a new capture thread for each new subscriber.
|
||||
return False
|
||||
|
||||
# Reset AGC envelope so a long silent gap between sessions
|
||||
# doesn't make the first new transients clip at the ceiling.
|
||||
self._spectrum_ref = 0.01
|
||||
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._capture_loop, daemon=True)
|
||||
@@ -104,17 +146,30 @@ class AudioAnalyzer:
|
||||
"""Stop audio capture and cleanup."""
|
||||
with self._lifecycle_lock:
|
||||
self._running = False
|
||||
# Wake any waiter so it can observe _running and exit cleanly.
|
||||
self._data_event.set()
|
||||
if self._thread:
|
||||
self._thread.join(timeout=3.0)
|
||||
self._thread = None
|
||||
with self._lock:
|
||||
self._data = None
|
||||
self._data_event.clear()
|
||||
|
||||
def get_frequency_data(self) -> dict | None:
|
||||
"""Return latest frequency data (thread-safe). None if not running."""
|
||||
with self._lock:
|
||||
return self._data
|
||||
|
||||
def get_frequency_data_versioned(self) -> tuple[dict | None, int]:
|
||||
"""Return (data, seq) so callers can dedupe without identity tricks."""
|
||||
with self._lock:
|
||||
return self._data, self._data_seq
|
||||
|
||||
@property
|
||||
def data_event(self) -> threading.Event:
|
||||
"""Event signaled when a fresh frame is ready. Caller must clear()."""
|
||||
return self._data_event
|
||||
|
||||
@staticmethod
|
||||
def list_loopback_devices() -> list[dict[str, str]]:
|
||||
"""List all available loopback audio devices."""
|
||||
@@ -189,6 +244,9 @@ class AudioAnalyzer:
|
||||
|
||||
self.device_name = device_name
|
||||
self._current_device_name = None
|
||||
# Clear the "no device" sticky flag — the user is asking for a
|
||||
# different device so it's worth attempting capture again.
|
||||
self._unavailable = False
|
||||
|
||||
if was_running:
|
||||
return self.start()
|
||||
@@ -223,15 +281,28 @@ class AudioAnalyzer:
|
||||
if device is None:
|
||||
logger.warning("No loopback audio device found - visualizer disabled")
|
||||
self._running = False
|
||||
self._unavailable = True
|
||||
return
|
||||
|
||||
interval = 1.0 / self.target_fps
|
||||
window = np.hanning(self.chunk_size)
|
||||
# Float32 window — matches soundcard's typical buffer dtype and
|
||||
# halves FFT memory traffic vs. the default float64.
|
||||
window = np.hanning(self.chunk_size).astype(np.float32)
|
||||
|
||||
# Pre-compute bin edge pairs for vectorized grouping
|
||||
edges = self._bin_edges
|
||||
bin_starts = np.array([edges[i] for i in range(self.num_bins)], dtype=np.intp)
|
||||
bin_ends = np.array([max(edges[i + 1], edges[i] + 1) for i in range(self.num_bins)], dtype=np.intp)
|
||||
# Counts are constant — compute once.
|
||||
bin_counts = (bin_ends - bin_starts).astype(np.float32)
|
||||
|
||||
# Pre-allocate working buffers so the per-frame allocator churn
|
||||
# on the capture thread (which runs at target_fps Hz, hours on
|
||||
# end) drops to zero copies for these arrays.
|
||||
fft_size = self.chunk_size // 2 + 1
|
||||
windowed = np.empty(self.chunk_size, dtype=np.float32)
|
||||
cumsum = np.empty(fft_size + 1, dtype=np.float32)
|
||||
cumsum[0] = 0.0
|
||||
|
||||
try:
|
||||
with device.recorder(
|
||||
@@ -260,29 +331,65 @@ class AudioAnalyzer:
|
||||
time.sleep(interval)
|
||||
continue
|
||||
|
||||
# Apply window and compute FFT
|
||||
windowed = mono[:self.chunk_size] * window
|
||||
# Apply window in-place into the pre-allocated buffer.
|
||||
np.multiply(mono[:self.chunk_size], window, out=windowed)
|
||||
fft_mag = np.abs(np.fft.rfft(windowed))
|
||||
|
||||
# Group into logarithmic bins (vectorized via cumsum)
|
||||
cumsum = np.concatenate(([0.0], np.cumsum(fft_mag)))
|
||||
counts = bin_ends - bin_starts
|
||||
bins = (cumsum[bin_ends] - cumsum[bin_starts]) / counts
|
||||
# Group into logarithmic bins (vectorized via cumsum).
|
||||
# Write into the pre-allocated [1:] slice so cumsum[0]
|
||||
# stays 0.0 and we never allocate a new array.
|
||||
np.cumsum(fft_mag, out=cumsum[1:])
|
||||
bins = (cumsum[bin_ends] - cumsum[bin_starts]) / bin_counts
|
||||
|
||||
# Normalize to 0-1
|
||||
max_val = bins.max()
|
||||
if max_val > 0:
|
||||
bins *= (1.0 / max_val)
|
||||
# True loudness from time-domain RMS via single BLAS
|
||||
# dot — avoids astype() and ** allocations.
|
||||
mono32 = mono if mono.dtype == np.float32 else mono.astype(np.float32, copy=False)
|
||||
energy = float(np.dot(mono32, mono32))
|
||||
if energy > 1e-12:
|
||||
rms = (energy / mono32.size) ** 0.5
|
||||
db = 20.0 * math.log10(rms)
|
||||
# Map -60 dB..-6 dB to 0..1 (typical music range)
|
||||
level = max(0.0, min(1.0, (db + 60.0) / 54.0))
|
||||
else:
|
||||
level = 0.0
|
||||
|
||||
# Slow auto-gain: envelope follower with fast attack,
|
||||
# slow release. Quiet music yields small bars; loud
|
||||
# passages reach the top; the reference adapts over
|
||||
# seconds instead of resetting every frame.
|
||||
current_peak = float(bins.max())
|
||||
if current_peak > self._spectrum_ref:
|
||||
self._spectrum_ref += (current_peak - self._spectrum_ref) * 0.05
|
||||
else:
|
||||
self._spectrum_ref += (current_peak - self._spectrum_ref) * 0.005
|
||||
ref = max(self._spectrum_ref, 1e-4)
|
||||
np.divide(bins, ref, out=bins)
|
||||
np.clip(bins, 0.0, 1.5, out=bins)
|
||||
|
||||
# Bass energy: average of first 4 bins (~20-200Hz)
|
||||
bass = float(bins[:4].mean()) if self.num_bins >= 4 else 0.0
|
||||
|
||||
# Round for compact JSON
|
||||
frequencies = np.round(bins, 3).tolist()
|
||||
bass = round(bass, 3)
|
||||
# Quantize to 0..1000 ints — same wire fidelity as
|
||||
# 3-decimal floats but smaller GC churn on both ends
|
||||
# (frontend smooths anyway, so quantization is
|
||||
# invisible). JSON encodes ints faster than floats.
|
||||
frequencies = (bins * 1000.0).astype(np.int16).tolist()
|
||||
bass_i = int(bass * 1000.0)
|
||||
level_i = int(level * 1000.0)
|
||||
|
||||
new_data = {
|
||||
"frequencies": frequencies,
|
||||
"bass": bass_i,
|
||||
"level": level_i,
|
||||
# Wire-format flag: clients that see this know
|
||||
# values are 0..1000 ints, not 0..1 floats.
|
||||
"scale": 1000,
|
||||
}
|
||||
with self._lock:
|
||||
self._data = {"frequencies": frequencies, "bass": bass}
|
||||
self._data = new_data
|
||||
self._data_seq += 1
|
||||
# Wake any broadcast loop waiting on fresh data.
|
||||
self._data_event.set()
|
||||
|
||||
# Throttle to target FPS
|
||||
elapsed = time.monotonic() - t0
|
||||
|
||||
@@ -63,14 +63,28 @@ class BrowserService:
|
||||
if not base_path.is_dir():
|
||||
raise ValueError(f"Media folder path is not a directory: {base_path}")
|
||||
|
||||
# Handle relative vs absolute paths
|
||||
if requested_path.startswith("/") or requested_path.startswith("\\"):
|
||||
# Relative to folder root (remove leading slash)
|
||||
requested_path = requested_path.lstrip("/\\")
|
||||
# Reject absolute paths, drive letters, UNC paths, and NUL bytes outright.
|
||||
# Only true folder-relative paths are accepted.
|
||||
if "\x00" in requested_path:
|
||||
raise ValueError("Path contains NUL byte")
|
||||
|
||||
# Strip a single leading "/" or "\\" (legacy callers send "/sub/dir") but
|
||||
# then refuse anything that still looks absolute.
|
||||
cleaned = requested_path.lstrip("/\\")
|
||||
# Detect Windows drive letter like "C:/..." after stripping.
|
||||
if len(cleaned) >= 2 and cleaned[1] == ":":
|
||||
raise ValueError("Absolute paths are not allowed")
|
||||
# Detect raw UNC ("\\\\server\\share") — the lstrip above strips at most
|
||||
# one leading slash, so a UNC original starts with another "\\" or "/".
|
||||
if cleaned.startswith("\\") or cleaned.startswith("/"):
|
||||
raise ValueError("Absolute paths are not allowed")
|
||||
candidate = Path(cleaned) if cleaned else None
|
||||
if candidate is not None and candidate.is_absolute():
|
||||
raise ValueError("Absolute paths are not allowed")
|
||||
|
||||
# Build and resolve full path
|
||||
if requested_path:
|
||||
full_path = (base_path / requested_path).resolve()
|
||||
if cleaned:
|
||||
full_path = (base_path / cleaned).resolve()
|
||||
else:
|
||||
full_path = base_path
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Display brightness and power control service."""
|
||||
"""Display brightness, power, contrast, input-source and color-preset control."""
|
||||
|
||||
import ctypes
|
||||
import ctypes.wintypes
|
||||
@@ -6,10 +6,33 @@ import logging
|
||||
import platform
|
||||
import struct
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
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
|
||||
_monitorcontrol = None
|
||||
|
||||
@@ -32,7 +55,7 @@ def _load_monitorcontrol():
|
||||
import monitorcontrol
|
||||
_monitorcontrol = monitorcontrol
|
||||
except ImportError:
|
||||
logger.warning("monitorcontrol not installed - display power control unavailable")
|
||||
logger.warning("monitorcontrol not installed - DDC/CI control unavailable")
|
||||
return _monitorcontrol
|
||||
|
||||
|
||||
@@ -64,6 +87,18 @@ class MonitorInfo:
|
||||
manufacturer: str = ""
|
||||
resolution: str | None = None
|
||||
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:
|
||||
return {
|
||||
@@ -76,6 +111,18 @@ class MonitorInfo:
|
||||
"manufacturer": self.manufacturer,
|
||||
"resolution": self.resolution,
|
||||
"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
|
||||
|
||||
|
||||
# Cache for monitor list
|
||||
# Short TTL cache of the assembled monitor list (full response).
|
||||
_monitor_cache: list[MonitorInfo] | None = None
|
||||
_cache_time: float = 0
|
||||
_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
|
||||
|
||||
sbc = _load_sbc()
|
||||
@@ -159,7 +372,13 @@ def list_monitors(force_refresh: bool = False) -> list[MonitorInfo]:
|
||||
info_list = sbc.list_monitors_info()
|
||||
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()
|
||||
ddc_monitors = []
|
||||
if mc:
|
||||
@@ -181,25 +400,44 @@ def list_monitors(force_refresh: bool = False) -> list[MonitorInfo]:
|
||||
edid = info.get("edid", "")
|
||||
resolution = _parse_edid_resolution(edid) if edid else None
|
||||
|
||||
# Read power state via DDC/CI
|
||||
power_on = True
|
||||
static: dict = {}
|
||||
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):
|
||||
try:
|
||||
with ddc_monitors[i] as mon:
|
||||
power_mode = mon.get_power_mode()
|
||||
power_on = power_mode == mc.PowerMode.on
|
||||
except Exception:
|
||||
pass
|
||||
if i not in _static_cache:
|
||||
_static_cache[i] = _probe_static_open(mon, mc, i)
|
||||
static = _static_cache[i]
|
||||
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(
|
||||
id=i,
|
||||
name=name,
|
||||
brightness=brightness,
|
||||
power_supported=power_supported,
|
||||
power_on=power_on,
|
||||
power_on=dynamic.get("power_on", True),
|
||||
model=model,
|
||||
manufacturer=manufacturer,
|
||||
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:
|
||||
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))
|
||||
try:
|
||||
sbc.set_brightness(value, display=monitor_id)
|
||||
# Invalidate cache
|
||||
global _monitor_cache
|
||||
_monitor_cache = None
|
||||
_invalidate_cache()
|
||||
return True
|
||||
except Exception as 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:
|
||||
monitor.set_power_mode(mc.PowerMode.off_soft)
|
||||
|
||||
# Invalidate cache
|
||||
global _monitor_cache
|
||||
_monitor_cache = None
|
||||
_invalidate_cache()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Failed to set power for monitor %d: %s", monitor_id, e)
|
||||
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
|
||||
|
||||
@@ -151,22 +151,19 @@ class LinuxMediaController(MediaController):
|
||||
logger.error(f"Failed to toggle mute: {e}")
|
||||
return False
|
||||
|
||||
async def get_status(self) -> MediaStatus:
|
||||
"""Get current media playback status."""
|
||||
def _sync_get_status(self) -> MediaStatus:
|
||||
"""Synchronous status read (called from a worker thread)."""
|
||||
status = MediaStatus()
|
||||
|
||||
# Get system volume
|
||||
volume, muted = self._get_volume_pulseaudio()
|
||||
status.volume = volume
|
||||
status.muted = muted
|
||||
|
||||
# Get active player
|
||||
player_name = self._get_active_player()
|
||||
if player_name is None:
|
||||
status.state = MediaState.IDLE
|
||||
return status
|
||||
|
||||
# Get playback status
|
||||
playback_status = self._get_property(player_name, "PlaybackStatus")
|
||||
if playback_status == "Playing":
|
||||
status.state = MediaState.PLAYING
|
||||
@@ -177,114 +174,70 @@ class LinuxMediaController(MediaController):
|
||||
else:
|
||||
status.state = MediaState.IDLE
|
||||
|
||||
# Get metadata
|
||||
metadata = self._get_property(player_name, "Metadata")
|
||||
if metadata:
|
||||
status.title = str(metadata.get("xesam:title", "")) or None
|
||||
|
||||
artists = metadata.get("xesam:artist", [])
|
||||
if artists:
|
||||
status.artist = str(artists[0]) if isinstance(artists, list) else str(artists)
|
||||
|
||||
status.album = str(metadata.get("xesam:album", "")) or None
|
||||
status.album_art_url = str(metadata.get("mpris:artUrl", "")) or None
|
||||
|
||||
# Duration in microseconds
|
||||
length = metadata.get("mpris:length", 0)
|
||||
if length:
|
||||
status.duration = int(length) / 1_000_000
|
||||
|
||||
# Get position (in microseconds)
|
||||
position = self._get_property(player_name, "Position")
|
||||
if position is not None:
|
||||
status.position = int(position) / 1_000_000
|
||||
|
||||
# Get source name
|
||||
status.source = player_name.replace(self.MPRIS_PREFIX, "")
|
||||
|
||||
return status
|
||||
|
||||
async def play(self) -> bool:
|
||||
"""Resume playback."""
|
||||
async def get_status(self) -> MediaStatus:
|
||||
"""Get current media playback status (off the event loop)."""
|
||||
# pactl + DBus calls each take 5-100ms on a Pi and would block every
|
||||
# other coroutine on the server. Run them in a worker thread.
|
||||
return await asyncio.to_thread(self._sync_get_status)
|
||||
|
||||
def _call_player(self, method_name: str) -> bool:
|
||||
player_name = self._get_active_player()
|
||||
if player_name is None:
|
||||
return False
|
||||
try:
|
||||
player = self._get_player_interface(player_name)
|
||||
player.Play()
|
||||
getattr(player, method_name)()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to play: {e}")
|
||||
logger.error(f"Failed to call player.{method_name}: {e}")
|
||||
return False
|
||||
|
||||
async def play(self) -> bool:
|
||||
return await asyncio.to_thread(self._call_player, "Play")
|
||||
|
||||
async def pause(self) -> bool:
|
||||
"""Pause playback."""
|
||||
player_name = self._get_active_player()
|
||||
if player_name is None:
|
||||
return False
|
||||
try:
|
||||
player = self._get_player_interface(player_name)
|
||||
player.Pause()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to pause: {e}")
|
||||
return False
|
||||
return await asyncio.to_thread(self._call_player, "Pause")
|
||||
|
||||
async def stop(self) -> bool:
|
||||
"""Stop playback."""
|
||||
player_name = self._get_active_player()
|
||||
if player_name is None:
|
||||
return False
|
||||
try:
|
||||
player = self._get_player_interface(player_name)
|
||||
player.Stop()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to stop: {e}")
|
||||
return False
|
||||
return await asyncio.to_thread(self._call_player, "Stop")
|
||||
|
||||
async def next_track(self) -> bool:
|
||||
"""Skip to next track."""
|
||||
player_name = self._get_active_player()
|
||||
if player_name is None:
|
||||
return False
|
||||
try:
|
||||
player = self._get_player_interface(player_name)
|
||||
player.Next()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to skip next: {e}")
|
||||
return False
|
||||
return await asyncio.to_thread(self._call_player, "Next")
|
||||
|
||||
async def previous_track(self) -> bool:
|
||||
"""Go to previous track."""
|
||||
player_name = self._get_active_player()
|
||||
if player_name is None:
|
||||
return False
|
||||
try:
|
||||
player = self._get_player_interface(player_name)
|
||||
player.Previous()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to skip previous: {e}")
|
||||
return False
|
||||
return await asyncio.to_thread(self._call_player, "Previous")
|
||||
|
||||
async def set_volume(self, volume: int) -> bool:
|
||||
"""Set system volume."""
|
||||
return self._set_volume_pulseaudio(volume)
|
||||
return await asyncio.to_thread(self._set_volume_pulseaudio, volume)
|
||||
|
||||
async def toggle_mute(self) -> bool:
|
||||
"""Toggle mute state."""
|
||||
return self._toggle_mute_pulseaudio()
|
||||
return await asyncio.to_thread(self._toggle_mute_pulseaudio)
|
||||
|
||||
async def seek(self, position: float) -> bool:
|
||||
"""Seek to position in seconds."""
|
||||
def _sync_seek(self, position: float) -> bool:
|
||||
player_name = self._get_active_player()
|
||||
if player_name is None:
|
||||
return False
|
||||
try:
|
||||
player = self._get_player_interface(player_name)
|
||||
# MPRIS expects position in microseconds
|
||||
player.SetPosition(
|
||||
self._get_property(player_name, "Metadata").get("mpris:trackid", "/"),
|
||||
int(position * 1_000_000),
|
||||
@@ -294,6 +247,9 @@ class LinuxMediaController(MediaController):
|
||||
logger.error(f"Failed to seek: {e}")
|
||||
return False
|
||||
|
||||
async def seek(self, position: float) -> bool:
|
||||
return await asyncio.to_thread(self._sync_seek, position)
|
||||
|
||||
async def open_file(self, file_path: str) -> bool:
|
||||
"""Open a media file with the default system player (Linux).
|
||||
|
||||
|
||||
@@ -321,7 +321,7 @@ class ThumbnailService:
|
||||
|
||||
if suffix in AUDIO_EXTENSIONS:
|
||||
# Audio files - run in executor (sync operation)
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
thumbnail_data = await loop.run_in_executor(
|
||||
None,
|
||||
ThumbnailService.generate_audio_thumbnail,
|
||||
|
||||
@@ -70,16 +70,27 @@ class ConnectionManager:
|
||||
)
|
||||
|
||||
async def broadcast(self, message: dict[str, Any]) -> None:
|
||||
"""Broadcast a message to all connected clients concurrently."""
|
||||
"""Broadcast a message to all connected clients concurrently.
|
||||
|
||||
The payload is serialized once and pushed via ``send_text`` to every
|
||||
client, instead of having Starlette/Pydantic encode it N times via
|
||||
``send_json``.
|
||||
"""
|
||||
async with self._lock:
|
||||
connections = list(self._active_connections)
|
||||
|
||||
if not connections:
|
||||
return
|
||||
|
||||
try:
|
||||
payload = json.dumps(message, default=str)
|
||||
except (TypeError, ValueError) as e:
|
||||
logger.error("Failed to encode broadcast message: %s", e)
|
||||
return
|
||||
|
||||
async def _send(ws: WebSocket) -> WebSocket | None:
|
||||
try:
|
||||
await ws.send_json(message)
|
||||
await ws.send_text(payload)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug("Failed to send to client: %s", e)
|
||||
@@ -129,7 +140,7 @@ class ConnectionManager:
|
||||
async def _maybe_start_capture(self) -> None:
|
||||
"""Start audio capture if not already running (called on first subscriber)."""
|
||||
if self._audio_analyzer and not self._audio_analyzer.running:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
started = await loop.run_in_executor(None, self._audio_analyzer.start)
|
||||
if started:
|
||||
logger.info("Audio capture started (first subscriber)")
|
||||
@@ -139,7 +150,7 @@ class ConnectionManager:
|
||||
async def _maybe_stop_capture(self) -> None:
|
||||
"""Stop audio capture if running (called when last subscriber leaves)."""
|
||||
if self._audio_analyzer and self._audio_analyzer.running:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(None, self._audio_analyzer.stop)
|
||||
logger.info("Audio capture stopped (no subscribers)")
|
||||
|
||||
@@ -161,26 +172,48 @@ class ConnectionManager:
|
||||
self._audio_task = None
|
||||
|
||||
async def _audio_broadcast_loop(self) -> None:
|
||||
"""Background loop: read frequency data from analyzer and broadcast to subscribers."""
|
||||
from ..config import settings
|
||||
interval = 1.0 / settings.visualizer_fps
|
||||
"""Background loop: read frequency data from analyzer and broadcast to subscribers.
|
||||
|
||||
_last_data = None
|
||||
Event-driven: blocks on the analyzer's data_event so it wakes up
|
||||
exactly once per produced frame, instead of polling on a timer.
|
||||
Backstop sleep applies when capture is idle / has no subscribers.
|
||||
"""
|
||||
from ..config import settings
|
||||
idle_interval = 1.0 / max(1, settings.visualizer_fps)
|
||||
# Bounded wait so we still notice subscribe/unsubscribe transitions.
|
||||
wake_timeout = max(0.05, idle_interval)
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
last_seq = -1
|
||||
|
||||
while True:
|
||||
try:
|
||||
async with self._lock:
|
||||
subscribers = list(self._visualizer_subscribers)
|
||||
|
||||
if not subscribers or not self._audio_analyzer or not self._audio_analyzer.running:
|
||||
await asyncio.sleep(interval)
|
||||
analyzer = self._audio_analyzer
|
||||
if not subscribers or not analyzer or not analyzer.running:
|
||||
await asyncio.sleep(idle_interval)
|
||||
continue
|
||||
|
||||
data = self._audio_analyzer.get_frequency_data()
|
||||
if data is None or data is _last_data:
|
||||
await asyncio.sleep(interval)
|
||||
# Wait off-loop for a fresh frame. The capture thread sets
|
||||
# data_event after each FFT update; we clear it before the
|
||||
# next wait so we never burn a wake on stale data.
|
||||
ev = analyzer.data_event
|
||||
|
||||
def _wait() -> bool:
|
||||
return ev.wait(wake_timeout)
|
||||
|
||||
got = await loop.run_in_executor(None, _wait)
|
||||
if not got:
|
||||
# Timeout — loop around to re-check subscriber state.
|
||||
continue
|
||||
_last_data = data
|
||||
ev.clear()
|
||||
|
||||
data, seq = analyzer.get_frequency_data_versioned()
|
||||
if data is None or seq == last_seq:
|
||||
continue
|
||||
last_seq = seq
|
||||
|
||||
# Pre-serialize once for all subscribers (avoids per-client JSON encoding)
|
||||
text = json.dumps({"type": "audio_data", "data": data}, separators=(',', ':'))
|
||||
@@ -198,13 +231,11 @@ class ConnectionManager:
|
||||
for ws in failed:
|
||||
await self.disconnect(ws)
|
||||
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error("Error in audio broadcast: %s", e)
|
||||
await asyncio.sleep(interval)
|
||||
await asyncio.sleep(idle_interval)
|
||||
|
||||
def status_changed(
|
||||
self, old: dict[str, Any] | None, new: dict[str, Any]
|
||||
|
||||
@@ -15,6 +15,22 @@ logger = logging.getLogger(__name__)
|
||||
# Thread pool for WinRT operations (they don't play well with asyncio)
|
||||
_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="winrt")
|
||||
|
||||
# Cache an asyncio event loop per worker thread so the 500ms status poll
|
||||
# doesn't allocate + tear down a new loop on every tick. Creating a loop
|
||||
# every 0.5s churns CPU and leaks finalized loop references that linger in
|
||||
# WinRT callbacks. With this helper a thread reuses one loop forever and
|
||||
# we only pay the setup cost once per worker.
|
||||
_thread_local = threading.local()
|
||||
|
||||
|
||||
def _thread_loop() -> asyncio.AbstractEventLoop:
|
||||
loop = getattr(_thread_local, "loop", None)
|
||||
if loop is None or loop.is_closed():
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
_thread_local.loop = loop
|
||||
return loop
|
||||
|
||||
# Global storage for current album art (as bytes)
|
||||
_current_album_art_bytes: bytes | None = None
|
||||
|
||||
@@ -161,8 +177,6 @@ WINDOWS_AVAILABLE = WINSDK_AVAILABLE
|
||||
|
||||
def _sync_get_media_status() -> dict[str, Any]:
|
||||
"""Synchronously get media status (runs in thread pool)."""
|
||||
import asyncio
|
||||
|
||||
result = {
|
||||
"state": "idle",
|
||||
"title": None,
|
||||
@@ -174,9 +188,7 @@ def _sync_get_media_status() -> dict[str, Any]:
|
||||
}
|
||||
|
||||
try:
|
||||
# Create a new event loop for this thread
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
loop = _thread_loop()
|
||||
|
||||
try:
|
||||
# Get media session manager
|
||||
@@ -393,7 +405,8 @@ def _sync_get_media_status() -> dict[str, Any]:
|
||||
result["source"] = session.source_app_user_model_id
|
||||
|
||||
finally:
|
||||
loop.close()
|
||||
# Reuse the loop across calls — see _thread_loop above.
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting media status: {e}")
|
||||
@@ -439,35 +452,28 @@ def _find_best_session(manager, loop):
|
||||
|
||||
def _sync_media_command(command: str) -> bool:
|
||||
"""Synchronously execute a media command (runs in thread pool)."""
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
try:
|
||||
manager = loop.run_until_complete(MediaManager.request_async())
|
||||
if manager is None:
|
||||
return False
|
||||
|
||||
session = _find_best_session(manager, loop)
|
||||
if session is None:
|
||||
return False
|
||||
|
||||
if command == "play":
|
||||
return loop.run_until_complete(session.try_play_async())
|
||||
elif command == "pause":
|
||||
return loop.run_until_complete(session.try_pause_async())
|
||||
elif command == "stop":
|
||||
return loop.run_until_complete(session.try_stop_async())
|
||||
elif command == "next":
|
||||
return loop.run_until_complete(session.try_skip_next_async())
|
||||
elif command == "previous":
|
||||
return loop.run_until_complete(session.try_skip_previous_async())
|
||||
|
||||
loop = _thread_loop()
|
||||
manager = loop.run_until_complete(MediaManager.request_async())
|
||||
if manager is None:
|
||||
return False
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
session = _find_best_session(manager, loop)
|
||||
if session is None:
|
||||
return False
|
||||
|
||||
if command == "play":
|
||||
return loop.run_until_complete(session.try_play_async())
|
||||
elif command == "pause":
|
||||
return loop.run_until_complete(session.try_pause_async())
|
||||
elif command == "stop":
|
||||
return loop.run_until_complete(session.try_stop_async())
|
||||
elif command == "next":
|
||||
return loop.run_until_complete(session.try_skip_next_async())
|
||||
elif command == "previous":
|
||||
return loop.run_until_complete(session.try_skip_previous_async())
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error executing media command {command}: {e}")
|
||||
@@ -476,27 +482,20 @@ def _sync_media_command(command: str) -> bool:
|
||||
|
||||
def _sync_seek(position: float) -> bool:
|
||||
"""Synchronously seek to position."""
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
loop = _thread_loop()
|
||||
manager = loop.run_until_complete(MediaManager.request_async())
|
||||
if manager is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
manager = loop.run_until_complete(MediaManager.request_async())
|
||||
if manager is None:
|
||||
return False
|
||||
session = _find_best_session(manager, loop)
|
||||
if session is None:
|
||||
return False
|
||||
|
||||
session = _find_best_session(manager, loop)
|
||||
if session is None:
|
||||
return False
|
||||
|
||||
position_ticks = int(position * 10_000_000)
|
||||
return loop.run_until_complete(
|
||||
session.try_change_playback_position_async(position_ticks)
|
||||
)
|
||||
finally:
|
||||
loop.close()
|
||||
position_ticks = int(position * 10_000_000)
|
||||
return loop.run_until_complete(
|
||||
session.try_change_playback_position_async(position_ticks)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error seeking: {e}")
|
||||
@@ -559,7 +558,7 @@ class WindowsMediaController(MediaController):
|
||||
|
||||
# Get media info in thread pool (avoids asyncio/WinRT issues)
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
media_info = await asyncio.wait_for(
|
||||
loop.run_in_executor(_executor, _sync_get_media_status),
|
||||
timeout=5.0
|
||||
@@ -592,7 +591,7 @@ class WindowsMediaController(MediaController):
|
||||
async def _run_command(self, command: str) -> bool:
|
||||
"""Run a media command in the thread pool."""
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
return await asyncio.wait_for(
|
||||
loop.run_in_executor(_executor, _sync_media_command, command),
|
||||
timeout=5.0
|
||||
@@ -616,16 +615,15 @@ class WindowsMediaController(MediaController):
|
||||
"""Stop playback."""
|
||||
return await self._run_command("stop")
|
||||
|
||||
async def next_track(self) -> bool:
|
||||
"""Skip to next track."""
|
||||
# Get current title before skipping
|
||||
try:
|
||||
status = await self.get_status()
|
||||
old_title = status.title or ""
|
||||
except Exception:
|
||||
old_title = ""
|
||||
async def _skip_track(self, command: str) -> bool:
|
||||
# Read the current title from the position cache instead of doing a
|
||||
# full WinRT round-trip (which can take up to 5s) just for one field.
|
||||
with _position_lock:
|
||||
track_id = _position_cache.get("track_id") or ""
|
||||
# track_id is "title:artist:duration" — extract just the title.
|
||||
old_title = track_id.split(":", 1)[0] if track_id else ""
|
||||
|
||||
result = await self._run_command("next")
|
||||
result = await self._run_command(command)
|
||||
if result:
|
||||
with _position_lock:
|
||||
_track_skip_pending["active"] = True
|
||||
@@ -634,23 +632,13 @@ class WindowsMediaController(MediaController):
|
||||
logger.debug(f"Track skip initiated, old title: {old_title}")
|
||||
return result
|
||||
|
||||
async def next_track(self) -> bool:
|
||||
"""Skip to next track."""
|
||||
return await self._skip_track("next")
|
||||
|
||||
async def previous_track(self) -> bool:
|
||||
"""Go to previous track."""
|
||||
# Get current title before skipping
|
||||
try:
|
||||
status = await self.get_status()
|
||||
old_title = status.title or ""
|
||||
except Exception:
|
||||
old_title = ""
|
||||
|
||||
result = await self._run_command("previous")
|
||||
if result:
|
||||
with _position_lock:
|
||||
_track_skip_pending["active"] = True
|
||||
_track_skip_pending["old_title"] = old_title
|
||||
_track_skip_pending["skip_time"] = _time.time()
|
||||
logger.debug(f"Track skip initiated, old title: {old_title}")
|
||||
return result
|
||||
return await self._skip_track("previous")
|
||||
|
||||
async def set_volume(self, volume: int) -> bool:
|
||||
"""Set system volume."""
|
||||
@@ -680,7 +668,7 @@ class WindowsMediaController(MediaController):
|
||||
async def seek(self, position: float) -> bool:
|
||||
"""Seek to position in seconds."""
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
return await asyncio.wait_for(
|
||||
loop.run_in_executor(_executor, _sync_seek, position),
|
||||
timeout=5.0
|
||||
@@ -705,7 +693,7 @@ class WindowsMediaController(MediaController):
|
||||
"""
|
||||
try:
|
||||
import os
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(None, lambda: os.startfile(file_path))
|
||||
logger.info(f"Opened file with default player: {file_path}")
|
||||
return True
|
||||
|
||||
+5650
-238
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+221
-123
@@ -6,6 +6,7 @@
|
||||
<title>Media Server</title>
|
||||
<meta name="description" content="Remote media player control and file browser">
|
||||
<meta name="theme-color" content="#121212">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="Media Server">
|
||||
@@ -25,15 +26,15 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="mini-controls">
|
||||
<button class="mini-control-btn mini-nav-btn" onclick="previousTrack()" data-i18n-title="player.previous" title="Previous">
|
||||
<button class="mini-control-btn mini-nav-btn" data-onclick="previousTrack()" data-i18n-title="player.previous" title="Previous">
|
||||
<svg viewBox="0 0 24 24"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
|
||||
</button>
|
||||
<button class="mini-control-btn" onclick="togglePlayPause()" id="mini-btn-play-pause" title="Play/Pause">
|
||||
<button class="mini-control-btn" data-onclick="togglePlayPause()" id="mini-btn-play-pause" title="Play/Pause">
|
||||
<svg viewBox="0 0 24 24" id="mini-play-pause-icon">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="mini-control-btn mini-nav-btn" onclick="nextTrack()" data-i18n-title="player.next" title="Next">
|
||||
<button class="mini-control-btn mini-nav-btn" data-onclick="nextTrack()" data-i18n-title="player.next" title="Next">
|
||||
<svg viewBox="0 0 24 24"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -47,7 +48,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="mini-volume-container">
|
||||
<button class="mini-control-btn" onclick="toggleMute()" id="mini-btn-mute" title="Mute">
|
||||
<button class="mini-control-btn" data-onclick="toggleMute()" id="mini-btn-mute" title="Mute">
|
||||
<svg viewBox="0 0 24 24" id="mini-mute-icon">
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
|
||||
</svg>
|
||||
@@ -66,7 +67,7 @@
|
||||
<h2 data-i18n="app.title">Media Server</h2>
|
||||
<p data-i18n="auth.message">Enter your API token to connect to the media server.</p>
|
||||
<input type="text" id="token-input" data-i18n-placeholder="auth.placeholder" placeholder="Enter API Token" autocomplete="off">
|
||||
<button class="btn-connect" onclick="authenticate()" data-i18n="auth.connect">Connect</button>
|
||||
<button class="btn-connect" data-onclick="authenticate()" data-i18n="auth.connect">Connect</button>
|
||||
<div class="help-text">
|
||||
<p data-i18n="auth.help">To get your token, run:</p>
|
||||
<code>media-server --show-token</code>
|
||||
@@ -75,27 +76,38 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Folio marks at page corners -->
|
||||
<span class="folio tl"><span class="status-dot" id="status-dot" aria-live="polite"></span><span data-i18n="header.connected">Connected</span> · <span id="folio-host">Local 8765</span></span>
|
||||
<span class="folio tr"><span data-i18n="header.volume">Vol. I</span> — <span data-i18n="header.edition">Studio Reference</span> · <span id="version-label"></span></span>
|
||||
|
||||
<div class="container">
|
||||
<header>
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<span class="status-dot" id="status-dot" aria-live="polite"></span>
|
||||
<span class="version-label" id="version-label"></span>
|
||||
<div class="brand">
|
||||
<span class="brand-name" data-i18n="app.title">Media Server</span>
|
||||
<span class="brand-sub" data-i18n="header.edition_sub">Studio Reference Edition</span>
|
||||
</div>
|
||||
<div class="header-toolbar">
|
||||
<div id="headerLinks" class="header-links"></div>
|
||||
<a class="header-btn" href="/docs" target="_blank" title="API Documentation" aria-label="API Documentation">
|
||||
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm-1 2l5 5h-5V4zM6 20V4h5v7h7v9H6zm2-4h8v2H8v-2zm0-3h8v2H8v-2z"/></svg>
|
||||
</a>
|
||||
<button class="header-btn" data-onclick="showAboutDialog()" data-i18n-title="about.button_title" title="About" aria-label="About">
|
||||
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M11 7h2v2h-2V7zm0 4h2v6h-2v-6zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/></svg>
|
||||
</button>
|
||||
<div class="accent-picker">
|
||||
<button class="header-btn" onclick="toggleAccentPicker()" title="Accent color" aria-label="Accent color">
|
||||
<button class="header-btn" data-onclick="toggleAccentPicker()" title="Accent color" aria-label="Accent color">
|
||||
<span class="accent-dot" id="accentDot"></span>
|
||||
</button>
|
||||
<div class="accent-picker-dropdown" id="accentDropdown"></div>
|
||||
</div>
|
||||
<button class="header-btn" onclick="toggleDynamicBackground()" data-i18n-title="player.background" title="Dynamic background" aria-label="Dynamic background" id="bgToggle">
|
||||
<button class="header-btn" data-onclick="toggleDynamicBackground()" data-i18n-title="player.background" title="Dynamic background" aria-label="Dynamic background" id="bgToggle">
|
||||
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M12 3v10.55A4 4 0 1 0 14 17V7h4V3h-6z"/></svg>
|
||||
</button>
|
||||
<button class="header-btn" onclick="toggleTheme()" data-i18n-title="player.theme" title="Toggle theme" aria-label="Toggle theme" id="theme-toggle">
|
||||
<button class="header-btn" data-onclick="togglePlayerFullscreen()" data-i18n-title="player.fullscreen" title="Fullscreen player" aria-label="Fullscreen player" id="fullscreenToggle">
|
||||
<svg id="fullscreen-icon-enter" viewBox="0 0 24 24"><path fill="currentColor" d="M5 5h5v2H7v3H5V5zm9 0h5v5h-2V7h-3V5zm0 14v-2h3v-3h2v5h-5zM5 14h2v3h3v2H5v-5z"/></svg>
|
||||
<svg id="fullscreen-icon-exit" viewBox="0 0 24 24" style="display:none"><path fill="currentColor" d="M8 8H5v2H3V6a1 1 0 0 1 1-1h4v3zm8 0h3v2h2V6a1 1 0 0 0-1-1h-4v3zm0 8h3v-2h2v4a1 1 0 0 1-1 1h-4v-3zM8 16H5v-2H3v4a1 1 0 0 0 1 1h4v-3z"/></svg>
|
||||
</button>
|
||||
<button class="header-btn" data-onclick="toggleTheme()" data-i18n-title="player.theme" title="Toggle theme" aria-label="Toggle theme" id="theme-toggle">
|
||||
<svg id="theme-icon-sun" viewBox="0 0 24 24" style="display: none;">
|
||||
<path d="M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zM2 13h2c.55 0 1-.45 1-1s-.45-1-1-1H2c-.55 0-1 .45-1 1s.45 1 1 1zm18 0h2c.55 0 1-.45 1-1s-.45-1-1-1h-2c-.55 0-1 .45-1 1s.45 1 1 1zM11 2v2c0 .55.45 1 1 1s1-.45 1-1V2c0-.55-.45-1-1-1s-1 .45-1 1zm0 18v2c0 .55.45 1 1 1s1-.45 1-1v-2c0-.55-.45-1-1-1s-1 .45-1 1zM5.99 4.58c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0s.39-1.03 0-1.41L5.99 4.58zm12.37 12.37c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0 .39-.39.39-1.03 0-1.41l-1.06-1.06zm1.06-10.96c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06zM7.05 18.36c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06z"/>
|
||||
</svg>
|
||||
@@ -103,12 +115,12 @@
|
||||
<path d="M9 2c-1.05 0-2.05.16-3 .46 4.06 1.27 7 5.06 7 9.54 0 4.48-2.94 8.27-7 9.54.95.3 1.95.46 3 .46 5.52 0 10-4.48 10-10S14.52 2 9 2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<select id="locale-select" class="header-locale" onchange="changeLocale()" title="Change language">
|
||||
<select id="locale-select" class="header-locale" data-onchange="changeLocale()" title="Change language">
|
||||
<option value="en">EN</option>
|
||||
<option value="ru">RU</option>
|
||||
</select>
|
||||
<span class="header-toolbar-sep"></span>
|
||||
<button class="header-btn header-btn-logout" onclick="clearToken()" data-i18n-title="auth.logout.title" title="Clear saved token" aria-label="Logout">
|
||||
<button class="header-btn header-btn-logout" data-onclick="clearToken()" data-i18n-title="auth.logout.title" title="Clear saved token" aria-label="Logout">
|
||||
<svg viewBox="0 0 24 24"><path d="M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5zM4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4V5z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -124,106 +136,177 @@
|
||||
<!-- Connection Banner -->
|
||||
<div class="connection-banner hidden" id="connectionBanner">
|
||||
<span id="connectionBannerText"></span>
|
||||
<button class="connection-banner-btn" id="connectionBannerBtn" onclick="manualReconnect()" style="display: none;" data-i18n="connection.reconnect">Reconnect</button>
|
||||
<button class="connection-banner-btn" id="connectionBannerBtn" data-onclick="manualReconnect()" style="display: none;" data-i18n="connection.reconnect">Reconnect</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Bar -->
|
||||
<!-- Tab Bar (editorial: numbered, italic active) -->
|
||||
<div class="tab-bar" id="tabBar" role="tablist">
|
||||
<div class="tab-indicator" id="tabIndicator"></div>
|
||||
<button class="tab-btn active" data-tab="player" onclick="switchTab('player')" role="tab" aria-selected="true" aria-controls="panel-player" tabindex="0">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/></svg>
|
||||
<span data-i18n="tab.player">Player</span>
|
||||
<button class="tab-btn active" data-tab="player" data-onclick="switchTab('player')" role="tab" aria-selected="true" aria-controls="panel-player" tabindex="0">
|
||||
<span class="tab-num">01</span>
|
||||
<span data-i18n="tab.player">Now Spinning</span>
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="display" onclick="switchTab('display')" role="tab" aria-selected="false" aria-controls="panel-display" tabindex="-1">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><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>
|
||||
<button class="tab-btn" data-tab="display" data-onclick="switchTab('display')" role="tab" aria-selected="false" aria-controls="panel-display" tabindex="-1">
|
||||
<span class="tab-num">02</span>
|
||||
<span data-i18n="tab.display">Display</span>
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="browser" onclick="switchTab('browser')" role="tab" aria-selected="false" aria-controls="panel-browser" tabindex="-1">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>
|
||||
<span data-i18n="tab.browser">Browser</span>
|
||||
<button class="tab-btn" data-tab="browser" data-onclick="switchTab('browser')" role="tab" aria-selected="false" aria-controls="panel-browser" tabindex="-1">
|
||||
<span class="tab-num">03</span>
|
||||
<span data-i18n="tab.browser">Library</span>
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="quick-actions" onclick="switchTab('quick-actions')" role="tab" aria-selected="false" aria-controls="panel-quick-actions" tabindex="-1">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M7 2v11h3v9l7-12h-4l4-8z"/></svg>
|
||||
<button class="tab-btn" data-tab="quick-actions" data-onclick="switchTab('quick-actions')" role="tab" aria-selected="false" aria-controls="panel-quick-actions" tabindex="-1">
|
||||
<span class="tab-num">04</span>
|
||||
<span data-i18n="tab.quick_access">Quick Access</span>
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="settings" onclick="switchTab('settings')" role="tab" aria-selected="false" aria-controls="panel-settings" tabindex="-1">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.488.488 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>
|
||||
<button class="tab-btn" data-tab="settings" data-onclick="switchTab('settings')" role="tab" aria-selected="false" aria-controls="panel-settings" tabindex="-1">
|
||||
<span class="tab-num">05</span>
|
||||
<span data-i18n="tab.settings">Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="player-container" data-tab-content="player" role="tabpanel" id="panel-player">
|
||||
<div class="player-layout">
|
||||
<div class="album-art-container">
|
||||
<!-- Fullscreen-only chrome: floating top strip with kicker + exit. Auto-hides on idle. -->
|
||||
<div class="fs-chrome" id="fsChrome" aria-hidden="true">
|
||||
<div class="fs-chrome-mark">
|
||||
<span class="fs-chrome-edition" data-i18n="header.edition">Studio Reference</span>
|
||||
<span class="fs-chrome-sep">·</span>
|
||||
<span class="fs-chrome-kicker" data-i18n="player.kicker">Now Playing</span>
|
||||
</div>
|
||||
<button class="fs-chrome-exit" data-onclick="togglePlayerFullscreen()" data-i18n-title="player.fullscreen.exit" title="Exit fullscreen" aria-label="Exit fullscreen">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M8 8H5v2H3V6a1 1 0 0 1 1-1h4v3zm8 0h3v2h2V6a1 1 0 0 0-1-1h-4v3zm0 8h3v-2h2v4a1 1 0 0 1-1 1h-4v-3zM8 16H5v-2H3v4a1 1 0 0 0 1 1h4v-3z"/></svg>
|
||||
<span data-i18n="player.fullscreen.exit_short">Exit</span>
|
||||
<kbd class="fs-chrome-kbd">ESC</kbd>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Ambient album-art bloom: paints the room in the record's color while in fullscreen -->
|
||||
<div class="fs-bloom" id="fsBloom" aria-hidden="true">
|
||||
<img id="fs-bloom-art" src="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" alt="">
|
||||
</div>
|
||||
|
||||
<section class="now-playing player-layout">
|
||||
|
||||
<!-- Vinyl stage: cardstock sleeve + disc peeking out, plus tonearm -->
|
||||
<div class="vinyl-stage album-art-container">
|
||||
<img id="album-art-glow" class="album-art-glow" src="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" alt="" aria-hidden="true">
|
||||
<img id="album-art" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Crect fill='%23282828' width='300' height='300'/%3E%3Cpath fill='%236a6a6a' d='M150 80c-38.66 0-70 31.34-70 70s31.34 70 70 70 70-31.34 70-70-31.34-70-70-70zm0 20c27.614 0 50 22.386 50 50s-22.386 50-50 50-50-22.386-50-50 22.386-50 50-50zm0 30a20 20 0 100 40 20 20 0 000-40z'/%3E%3C/svg%3E" alt="Album Art">
|
||||
<div class="sleeve">
|
||||
<img id="album-art" src="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" alt="Album Art">
|
||||
<div class="sleeve-grain" aria-hidden="true"></div>
|
||||
<div class="sleeve-corner" aria-hidden="true"></div>
|
||||
</div>
|
||||
<div class="vinyl-wrap">
|
||||
<div class="vinyl">
|
||||
<div class="vinyl-label">
|
||||
<!-- Stylised record-label catalogue mark, not user-facing
|
||||
copy — intentionally not in the i18n bundle. -->
|
||||
<span class="vinyl-label-text">REF · 24</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<svg class="tonearm" viewBox="0 0 200 200" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="armGrad" x1="0" x2="1">
|
||||
<stop offset="0" stop-color="#6d5f44"/>
|
||||
<stop offset="0.5" stop-color="#d8c39a"/>
|
||||
<stop offset="1" stop-color="#8a7a5a"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="176" cy="24" r="14" fill="#2a241c" stroke="#9C835A" stroke-width="1.5"/>
|
||||
<circle cx="176" cy="24" r="6" fill="#5C5447"/>
|
||||
<circle cx="176" cy="24" r="2.5" fill="#E08038"/>
|
||||
<line x1="176" y1="24" x2="64" y2="136" stroke="url(#armGrad)" stroke-width="5" stroke-linecap="round"/>
|
||||
<rect x="180" y="14" width="14" height="20" fill="#3A3528" stroke="#9C835A" stroke-width="1"/>
|
||||
<rect x="56" y="128" width="22" height="18" rx="2" fill="#3A3528" stroke="#9C835A" stroke-width="1" transform="rotate(-45 67 137)"/>
|
||||
<circle cx="62" cy="138" r="3.5" fill="#E08038" opacity="0.95"/>
|
||||
<circle cx="62" cy="138" r="7" fill="none" stroke="#E08038" stroke-width="0.8" opacity="0.5"/>
|
||||
</svg>
|
||||
<canvas id="spectrogram-canvas" class="spectrogram-canvas" width="300" height="64"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="player-details">
|
||||
<div class="track-info">
|
||||
<div id="track-title" data-i18n="player.no_media">No media playing</div>
|
||||
<div id="artist"></div>
|
||||
<div id="album"></div>
|
||||
<div class="playback-state">
|
||||
<svg class="state-icon" id="state-icon" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"/>
|
||||
</svg>
|
||||
<span id="playback-state" data-i18n="state.idle">Idle</span>
|
||||
<!-- Track masthead -->
|
||||
<div class="track-masthead player-details">
|
||||
|
||||
<div class="kicker"><span data-i18n="player.kicker">Now Playing</span></div>
|
||||
|
||||
<h1 class="track-title" id="track-title" data-i18n="player.no_media">No media playing</h1>
|
||||
<div class="track-byline" id="artist"></div>
|
||||
<div class="track-album" id="album"></div>
|
||||
|
||||
<!-- 2-cell metadata grid -->
|
||||
<div class="meta-grid meta-grid-2">
|
||||
<div class="meta-cell">
|
||||
<div class="label" data-i18n="meta.state">State</div>
|
||||
<div class="value">
|
||||
<svg class="state-icon" id="state-icon" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"/>
|
||||
</svg>
|
||||
<span id="playback-state" data-i18n="state.idle">Idle</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="meta-cell">
|
||||
<div class="label" data-i18n="meta.source">Source</div>
|
||||
<div class="value source-value">
|
||||
<span class="source-icon" id="sourceIcon"></span>
|
||||
<span id="source" data-i18n="player.unknown_source">Unknown</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-container">
|
||||
<div class="time-display">
|
||||
<span id="current-time">0:00</span>
|
||||
<span id="total-time">0:00</span>
|
||||
<!-- Spectrum bars — driven by real audio when visualizer is active,
|
||||
CSS-animated synthetic motion otherwise. JS injects the spans. -->
|
||||
<div class="spectrum" id="player-spectrum" aria-hidden="true"></div>
|
||||
|
||||
<!-- Transport -->
|
||||
<div class="transport">
|
||||
<div class="progress-row">
|
||||
<span class="timecode elapsed" id="current-time">0:00</span>
|
||||
<div class="progress-track progress-bar" id="progress-bar" data-duration="0" role="slider" aria-label="Playback position" aria-valuemin="0" aria-valuemax="0" aria-valuenow="0">
|
||||
<div class="progress-fill" id="progress-fill"></div>
|
||||
</div>
|
||||
<span class="timecode" id="total-time">0:00</span>
|
||||
</div>
|
||||
<div class="progress-bar" id="progress-bar" data-duration="0" role="slider" aria-label="Playback position" aria-valuemin="0" aria-valuemax="0" aria-valuenow="0">
|
||||
<div class="progress-fill" id="progress-fill"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button onclick="previousTrack()" data-i18n-title="player.previous" title="Previous" id="btn-previous">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="primary" onclick="togglePlayPause()" data-i18n-title="player.play" title="Play/Pause" id="btn-play-pause">
|
||||
<svg viewBox="0 0 24 24" id="play-pause-icon">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button onclick="nextTrack()" data-i18n-title="player.next" title="Next" id="btn-next">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="volume-container">
|
||||
<button class="mute-btn" onclick="toggleMute()" data-i18n-title="player.mute" title="Mute" id="btn-mute">
|
||||
<svg viewBox="0 0 24 24" id="mute-icon">
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<input type="range" id="volume-slider" min="0" max="100" value="50" aria-label="Volume">
|
||||
<div class="volume-display" id="volume-display">50%</div>
|
||||
</div>
|
||||
|
||||
<div class="source-info">
|
||||
<span class="source-label"><span class="source-icon" id="sourceIcon"></span><span id="source" data-i18n="player.unknown_source">Unknown</span></span>
|
||||
<div class="player-toggles">
|
||||
<button class="vinyl-toggle-btn" onclick="toggleVinylMode()" id="vinylToggle" data-i18n-title="player.vinyl" title="Vinyl mode">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="12" cy="12" r="3" fill="currentColor"/><circle cx="12" cy="12" r="6.5" fill="none" stroke="currentColor" stroke-width="0.5" opacity="0.5"/></svg>
|
||||
<div class="controls">
|
||||
<button class="btn-trans" data-onclick="previousTrack()" data-i18n-title="player.previous" title="Previous" id="btn-previous">
|
||||
<svg viewBox="0 0 24 24"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
|
||||
</button>
|
||||
<button class="vinyl-toggle-btn" onclick="toggleVisualizer()" id="visualizerToggle" data-i18n-title="player.visualizer" title="Audio visualizer" style="display:none">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M3 18h2v-8H3v8zm4 0h2V6H7v12zm4 0h2V2h-2v16zm4 0h2v-6h-2v6zm4 0h2V9h-2v9z"/></svg>
|
||||
<button class="btn-trans primary" data-onclick="togglePlayPause()" data-i18n-title="player.play" title="Play/Pause" id="btn-play-pause">
|
||||
<svg viewBox="0 0 24 24" id="play-pause-icon"><path d="M8 5v14l11-7z"/></svg>
|
||||
</button>
|
||||
<button class="btn-trans" data-onclick="nextTrack()" data-i18n-title="player.next" title="Next" id="btn-next">
|
||||
<svg viewBox="0 0 24 24"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
|
||||
</button>
|
||||
|
||||
<div class="vu-cluster">
|
||||
<div class="vu-meter" aria-hidden="true">
|
||||
<div class="vu-needle" id="vuNeedle"></div>
|
||||
</div>
|
||||
<div class="vu-readout">
|
||||
<span>OUT <strong id="vu-out">SYS</strong></span>
|
||||
<span>VOL <strong id="vu-vol">50%</strong></span>
|
||||
</div>
|
||||
<!-- Volume control: mute + slim slider, integrated -->
|
||||
<div class="vu-volume">
|
||||
<button class="mute-btn" data-onclick="toggleMute()" data-i18n-title="player.mute" title="Mute" id="btn-mute">
|
||||
<svg viewBox="0 0 24 24" id="mute-icon">
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<input type="range" id="volume-slider" min="0" max="100" value="50" aria-label="Volume">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden but functional: legacy display + visualizer toggle. -->
|
||||
<div class="visually-hidden">
|
||||
<div id="volume-display">50%</div>
|
||||
<button data-onclick="toggleVisualizer()" id="visualizerToggle" data-i18n-title="player.visualizer" title="Audio visualizer" style="display:none">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M3 18h2v-8H3v8zm4 0h2V6H7v12zm4 0h2V2h-2v16zm4 0h2v-6h-2v6zm4 0h2V9h-2v9z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Media Browser Section -->
|
||||
@@ -235,34 +318,34 @@
|
||||
<div class="browser-toolbar" id="browserToolbar">
|
||||
<div class="browser-toolbar-left">
|
||||
<div class="view-toggle">
|
||||
<button class="view-toggle-btn active" id="viewGridBtn" onclick="setViewMode('grid')" data-i18n-title="browser.view_grid" title="Grid view">
|
||||
<button class="view-toggle-btn active" id="viewGridBtn" data-onclick="setViewMode('grid')" data-i18n-title="browser.view_grid" title="Grid view">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M3 3h8v8H3V3zm0 10h8v8H3v-8zm10-10h8v8h-8V3zm0 10h8v8h-8v-8z"/></svg>
|
||||
</button>
|
||||
<button class="view-toggle-btn" id="viewCompactBtn" onclick="setViewMode('compact')" data-i18n-title="browser.view_compact" title="Compact view">
|
||||
<button class="view-toggle-btn" id="viewCompactBtn" data-onclick="setViewMode('compact')" data-i18n-title="browser.view_compact" title="Compact view">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M2 2h5v5H2V2zm0 8h5v5H2v-5zm0 8h5v5H2v-5zm7-16h5v5H9V2zm0 8h5v5H9v-5zm0 8h5v5H9v-5zm7-16h5v5h-5V2zm0 8h5v5h-5v-5zm0 8h5v5h-5v-5z"/></svg>
|
||||
</button>
|
||||
<button class="view-toggle-btn" id="viewListBtn" onclick="setViewMode('list')" data-i18n-title="browser.view_list" title="List view">
|
||||
<button class="view-toggle-btn" id="viewListBtn" data-onclick="setViewMode('list')" data-i18n-title="browser.view_list" title="List view">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M3 4h18v2H3V4zm0 7h18v2H3v-2zm0 7h18v2H3v-2z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<button class="view-toggle-btn browser-refresh-btn" id="refreshBtn" onclick="refreshBrowser()" data-i18n-title="browser.refresh" title="Refresh">
|
||||
<button class="view-toggle-btn browser-refresh-btn" id="refreshBtn" data-onclick="refreshBrowser()" data-i18n-title="browser.refresh" title="Refresh">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M17.65 6.35A7.958 7.958 0 0012 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0112 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
|
||||
</button>
|
||||
<button class="browser-play-all-btn" id="playAllBtn" onclick="playAllFolder()" data-i18n-title="browser.play_all" title="Play All" style="display: none;">
|
||||
<button class="browser-play-all-btn" id="playAllBtn" data-onclick="playAllFolder()" data-i18n-title="browser.play_all" title="Play All" style="display: none;">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M8 5v14l11-7z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="browser-search-wrapper" id="browserSearchWrapper" style="display: none;">
|
||||
<svg class="browser-search-icon" viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0016 9.5 6.5 6.5 0 109.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
|
||||
<input type="text" id="browserSearchInput" class="browser-search-input" data-i18n-placeholder="browser.search" placeholder="Search..." oninput="onBrowserSearch()">
|
||||
<button class="browser-search-clear" id="browserSearchClear" onclick="clearBrowserSearch()" style="display: none;">
|
||||
<input type="text" id="browserSearchInput" class="browser-search-input" data-i18n-placeholder="browser.search" placeholder="Search..." data-oninput="onBrowserSearch()">
|
||||
<button class="browser-search-clear" id="browserSearchClear" data-onclick="clearBrowserSearch()" style="display: none;">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="browser-toolbar-right">
|
||||
<label class="items-per-page-label">
|
||||
<span data-i18n="browser.items_per_page">Items per page:</span>
|
||||
<select id="itemsPerPageSelect" onchange="onItemsPerPageChanged()">
|
||||
<select id="itemsPerPageSelect" data-onchange="onItemsPerPageChanged()">
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100" selected>100</option>
|
||||
@@ -283,13 +366,13 @@
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="pagination" id="browserPagination" style="display: none;">
|
||||
<button id="prevPage" onclick="previousPage()" data-i18n="browser.previous">Previous</button>
|
||||
<button id="prevPage" data-onclick="previousPage()" data-i18n="browser.previous">Previous</button>
|
||||
<div class="pagination-center">
|
||||
<span data-i18n="browser.page">Page</span>
|
||||
<input type="number" id="pageInput" class="page-input" min="1" value="1" onchange="goToPage()">
|
||||
<input type="number" id="pageInput" class="page-input" min="1" value="1" data-onchange="goToPage()">
|
||||
<span id="pageTotal">/ 1</span>
|
||||
</div>
|
||||
<button id="nextPage" onclick="nextPage()" data-i18n="browser.next">Next</button>
|
||||
<button id="nextPage" data-onclick="nextPage()" data-i18n="browser.next">Next</button>
|
||||
<span class="pagination-showing" id="paginationShowing"></span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -315,7 +398,7 @@
|
||||
<div class="audio-device-selector">
|
||||
<label>
|
||||
<span data-i18n="settings.audio.device">Loopback Device</span>
|
||||
<select id="audioDeviceSelect" onchange="onAudioDeviceChanged()">
|
||||
<select id="audioDeviceSelect" data-onchange="onAudioDeviceChanged()">
|
||||
<option value="" data-i18n="settings.audio.auto">Auto-detect</option>
|
||||
</select>
|
||||
</label>
|
||||
@@ -351,7 +434,7 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="add-card" onclick="showAddFolderDialog()">
|
||||
<div class="add-card" data-onclick="showAddFolderDialog()">
|
||||
<span class="add-card-icon">+</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -381,7 +464,7 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="add-card" onclick="showAddScriptDialog()">
|
||||
<div class="add-card" data-onclick="showAddScriptDialog()">
|
||||
<span class="add-card-icon">+</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -413,7 +496,7 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="add-card" onclick="showAddLinkDialog()">
|
||||
<div class="add-card" data-onclick="showAddLinkDialog()">
|
||||
<span class="add-card-icon">+</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -439,13 +522,13 @@
|
||||
<td colspan="4" class="empty-state">
|
||||
<div class="empty-state-illustration">
|
||||
<svg viewBox="0 0 64 64"><circle cx="32" cy="32" r="24"/><path d="M32 20v12l8 8"/></svg>
|
||||
<p>No callbacks configured. Click "Add" to create one.</p>
|
||||
<p data-i18n="callbacks.empty">No callbacks configured. Click "Add" to create one.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="add-card" onclick="showAddCallbackDialog()">
|
||||
<div class="add-card" data-onclick="showAddCallbackDialog()">
|
||||
<span class="add-card-icon">+</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -468,7 +551,7 @@
|
||||
<div class="dialog-header">
|
||||
<h3 id="dialogTitle" data-i18n="scripts.dialog.add">Add Script</h3>
|
||||
</div>
|
||||
<form id="scriptForm" onsubmit="saveScript(event)">
|
||||
<form id="scriptForm" data-onsubmit="saveScript(event)">
|
||||
<div class="dialog-body">
|
||||
<input type="hidden" id="scriptOriginalName">
|
||||
<input type="hidden" id="scriptIsEdit">
|
||||
@@ -510,13 +593,13 @@
|
||||
<div class="params-section">
|
||||
<div class="params-header">
|
||||
<span data-i18n="scripts.field.parameters">Parameters</span>
|
||||
<button type="button" class="btn-small" onclick="addParameterRow()" data-i18n="scripts.params.add">+ Add</button>
|
||||
<button type="button" class="btn-small" data-onclick="addParameterRow()" data-i18n="scripts.params.add">+ Add</button>
|
||||
</div>
|
||||
<div id="scriptParamsContainer"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button type="button" class="btn-secondary" onclick="closeScriptDialog()" data-i18n="scripts.button.cancel">Cancel</button>
|
||||
<button type="button" class="btn-secondary" data-onclick="closeScriptDialog()" data-i18n="scripts.button.cancel">Cancel</button>
|
||||
<button type="submit" class="btn-primary" data-i18n="scripts.button.save">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -525,14 +608,14 @@
|
||||
<!-- Script Parameters Input Dialog (shown before executing scripts with params) -->
|
||||
<dialog id="scriptParamsDialog">
|
||||
<div class="dialog-header">
|
||||
<h3 id="scriptParamsDialogTitle">Execute Script</h3>
|
||||
<h3 id="scriptParamsDialogTitle" data-i18n="scripts.params.execute">Execute Script</h3>
|
||||
</div>
|
||||
<form id="scriptParamsForm" onsubmit="submitScriptWithParams(event)">
|
||||
<form id="scriptParamsForm" data-onsubmit="submitScriptWithParams(event)">
|
||||
<div class="dialog-body">
|
||||
<div id="scriptParamsInputs"></div>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button type="button" class="btn-secondary" onclick="closeScriptParamsDialog()" data-i18n="scripts.button.cancel">Cancel</button>
|
||||
<button type="button" class="btn-secondary" data-onclick="closeScriptParamsDialog()" data-i18n="scripts.button.cancel">Cancel</button>
|
||||
<button type="submit" class="btn-primary" data-i18n="scripts.params.execute">Execute</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -543,7 +626,7 @@
|
||||
<div class="dialog-header">
|
||||
<h3 id="callbackDialogTitle" data-i18n="callbacks.dialog.add">Add Callback</h3>
|
||||
</div>
|
||||
<form id="callbackForm" onsubmit="saveCallback(event)">
|
||||
<form id="callbackForm" data-onsubmit="saveCallback(event)">
|
||||
<div class="dialog-body">
|
||||
<input type="hidden" id="callbackIsEdit">
|
||||
|
||||
@@ -581,7 +664,7 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button type="button" class="btn-secondary" onclick="closeCallbackDialog()" data-i18n="callbacks.button.cancel">Cancel</button>
|
||||
<button type="button" class="btn-secondary" data-onclick="closeCallbackDialog()" data-i18n="callbacks.button.cancel">Cancel</button>
|
||||
<button type="submit" class="btn-primary" data-i18n="callbacks.button.save">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -592,7 +675,7 @@
|
||||
<div class="dialog-header">
|
||||
<h3 id="linkDialogTitle" data-i18n="links.dialog.add">Add Link</h3>
|
||||
</div>
|
||||
<form id="linkForm" onsubmit="saveLink(event)">
|
||||
<form id="linkForm" data-onsubmit="saveLink(event)">
|
||||
<div class="dialog-body">
|
||||
<input type="hidden" id="linkOriginalName">
|
||||
<input type="hidden" id="linkIsEdit">
|
||||
@@ -627,7 +710,7 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button type="button" class="btn-secondary" onclick="closeLinkDialog()" data-i18n="links.button.cancel">Cancel</button>
|
||||
<button type="button" class="btn-secondary" data-onclick="closeLinkDialog()" data-i18n="links.button.cancel">Cancel</button>
|
||||
<button type="submit" class="btn-primary" data-i18n="links.button.save">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -654,7 +737,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button type="button" class="btn-secondary" onclick="closeExecutionDialog()" data-i18n="scripts.execution.close">Close</button>
|
||||
<button type="button" class="btn-secondary" data-onclick="closeExecutionDialog()" data-i18n="scripts.execution.close">Close</button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
@@ -663,7 +746,7 @@
|
||||
<div class="dialog-header">
|
||||
<h3 id="folderDialogTitle" data-i18n="browser.folder_dialog.title_add">Add Media Folder</h3>
|
||||
</div>
|
||||
<form id="folderForm" onsubmit="saveFolder(event)">
|
||||
<form id="folderForm" data-onsubmit="saveFolder(event)">
|
||||
<div class="dialog-body">
|
||||
<input type="hidden" id="folderIsEdit">
|
||||
<input type="hidden" id="folderOriginalId">
|
||||
@@ -690,7 +773,7 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button type="button" class="btn-secondary" onclick="closeFolderDialog()" data-i18n="browser.folder_dialog.cancel">Cancel</button>
|
||||
<button type="button" class="btn-secondary" data-onclick="closeFolderDialog()" data-i18n="browser.folder_dialog.cancel">Cancel</button>
|
||||
<button type="submit" class="btn-primary" data-i18n="browser.folder_dialog.save">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -708,16 +791,31 @@
|
||||
<!-- Toast Notifications -->
|
||||
<div class="toast-container" id="toast-container"></div>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer>
|
||||
<div>
|
||||
<span data-i18n="footer.created_by">Created by</span> <strong>Alexei Dolgolyov</strong>
|
||||
<span class="separator">•</span>
|
||||
<a href="mailto:dolgolyov.alexei@gmail.com">dolgolyov.alexei@gmail.com</a>
|
||||
<span class="separator">•</span>
|
||||
<a href="https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server" target="_blank" rel="noopener noreferrer" data-i18n="footer.source_code">Source Code</a>
|
||||
<!-- About Dialog -->
|
||||
<dialog id="aboutDialog" class="about-dialog">
|
||||
<div class="dialog-header">
|
||||
<h3 data-i18n="about.title">About</h3>
|
||||
</div>
|
||||
</footer>
|
||||
<div class="dialog-body">
|
||||
<p class="about-credit">
|
||||
<span data-i18n="about.created_by">Created by</span>
|
||||
<strong>Alexei Dolgolyov</strong>
|
||||
</p>
|
||||
<ul class="about-links">
|
||||
<li>
|
||||
<span class="about-links-label" data-i18n="about.email">Email</span>
|
||||
<a href="mailto:dolgolyov.alexei@gmail.com">dolgolyov.alexei@gmail.com</a>
|
||||
</li>
|
||||
<li>
|
||||
<span class="about-links-label" data-i18n="about.repository">Repository</span>
|
||||
<a href="https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server" target="_blank" rel="noopener noreferrer" data-i18n="about.source_code">Source Code</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button type="button" class="btn-secondary" data-onclick="closeAboutDialog()" data-i18n="dialog.close">Close</button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script src="/static/dist/app.bundle.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
VOLUME_THROTTLE_MS, VOLUME_RELEASE_DELAY_MS,
|
||||
changeLocale, t,
|
||||
setAuthRequired,
|
||||
showAboutDialog, closeAboutDialog,
|
||||
} from './core.js';
|
||||
|
||||
// Layer 1: Player (tabs, theme, accent, vinyl, visualizer, UI)
|
||||
@@ -21,11 +22,11 @@ import {
|
||||
activeTab, switchTab, updateTabIndicator, setMiniPlayerVisible,
|
||||
initTheme, toggleTheme, initAccentColor, applyAccentColor,
|
||||
renderAccentSwatches, selectAccentColor, toggleAccentPicker, lightenColor,
|
||||
toggleVinylMode, applyVinylMode,
|
||||
visualizerEnabled, visualizerAvailable,
|
||||
visualizerEnabled, visualizerAvailable, setVisualizerEnabled,
|
||||
checkVisualizerAvailability, toggleVisualizer, applyVisualizerMode,
|
||||
loadAudioDevices, onAudioDeviceChanged,
|
||||
setupProgressDrag, updateUI, updatePlaybackState, stopPositionInterpolation,
|
||||
togglePlayerFullscreen, initPlayerFullscreen,
|
||||
} from './player.js';
|
||||
|
||||
// Layer 2: WebSocket
|
||||
@@ -62,6 +63,8 @@ import {
|
||||
|
||||
import {
|
||||
loadDisplayMonitors, onDisplayBrightnessInput, onDisplayBrightnessChange,
|
||||
onDisplayContrastInput, onDisplayContrastChange,
|
||||
onDisplayInputSourceChange, onDisplayColorPresetChange, onDisplayPictureModeChange,
|
||||
toggleDisplayPower, loadHeaderLinks, loadLinksTable,
|
||||
showAddLinkDialog, showEditLinkDialog, closeLinkDialog, saveLink, deleteLinkConfirm,
|
||||
linkFormDirty, setLinkFormDirty,
|
||||
@@ -96,10 +99,12 @@ Object.assign(window, {
|
||||
switchTab,
|
||||
// Theme & accent
|
||||
toggleTheme, toggleAccentPicker, selectAccentColor, lightenColor,
|
||||
// Vinyl & visualizer
|
||||
toggleVinylMode, toggleVisualizer,
|
||||
// Visualizer (vinyl spin is structural CSS — no toggle)
|
||||
toggleVisualizer,
|
||||
// Background
|
||||
toggleDynamicBackground,
|
||||
// Fullscreen
|
||||
togglePlayerFullscreen,
|
||||
// Auth
|
||||
authenticate, clearToken, manualReconnect,
|
||||
// Locale
|
||||
@@ -124,9 +129,13 @@ Object.assign(window, {
|
||||
saveLink, deleteLinkConfirm,
|
||||
// Display
|
||||
loadDisplayMonitors, onDisplayBrightnessInput, onDisplayBrightnessChange,
|
||||
onDisplayContrastInput, onDisplayContrastChange,
|
||||
onDisplayInputSourceChange, onDisplayColorPresetChange, onDisplayPictureModeChange,
|
||||
toggleDisplayPower,
|
||||
// Audio device
|
||||
onAudioDeviceChanged,
|
||||
// About
|
||||
showAboutDialog, closeAboutDialog,
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
@@ -150,25 +159,116 @@ HTMLDialogElement.prototype.showModal = function (...args) {
|
||||
return result;
|
||||
};
|
||||
|
||||
// CSP-safe replacement for inline on* handlers. HTML uses data-onclick,
|
||||
// data-onchange, data-oninput, data-onsubmit with simple call expressions
|
||||
// like "fn()", "fn('arg')", "fn(event)". We parse those at startup and
|
||||
// attach proper addEventListener calls so script-src 'self' stays strict.
|
||||
const INLINE_HANDLER_EVENTS = {
|
||||
'data-onclick': 'click',
|
||||
'data-onchange': 'change',
|
||||
'data-oninput': 'input',
|
||||
'data-onsubmit': 'submit',
|
||||
};
|
||||
|
||||
function parseInlineHandlerArg(token) {
|
||||
const t = token.trim();
|
||||
if (t === '') return { kind: 'empty' };
|
||||
if (t === 'event') return { kind: 'event' };
|
||||
if (t === 'true') return { kind: 'literal', value: true };
|
||||
if (t === 'false') return { kind: 'literal', value: false };
|
||||
if (t === 'null') return { kind: 'literal', value: null };
|
||||
if (/^-?\d+(\.\d+)?$/.test(t)) return { kind: 'literal', value: Number(t) };
|
||||
if ((t.startsWith("'") && t.endsWith("'")) || (t.startsWith('"') && t.endsWith('"'))) {
|
||||
return { kind: 'literal', value: t.slice(1, -1) };
|
||||
}
|
||||
console.warn('inline-handler: unsupported arg token', token);
|
||||
return { kind: 'literal', value: undefined };
|
||||
}
|
||||
|
||||
function compileInlineHandler(expr) {
|
||||
const m = expr.match(/^\s*([A-Za-z_$][\w$]*)\s*\((.*)\)\s*;?\s*$/s);
|
||||
if (!m) {
|
||||
console.warn('inline-handler: unparsable expression', expr);
|
||||
return null;
|
||||
}
|
||||
const fnName = m[1];
|
||||
const argsRaw = m[2].trim();
|
||||
const argTokens = argsRaw === '' ? [] : argsRaw.split(',').map(s => s.trim());
|
||||
const parsedArgs = argTokens.map(parseInlineHandlerArg);
|
||||
return function (event) {
|
||||
const fn = window[fnName];
|
||||
if (typeof fn !== 'function') {
|
||||
console.error('inline-handler: missing global function', fnName);
|
||||
return;
|
||||
}
|
||||
const args = parsedArgs.map(a => a.kind === 'event' ? event : a.value);
|
||||
return fn.apply(this, args);
|
||||
};
|
||||
}
|
||||
|
||||
function wireInlineHandlers(root) {
|
||||
for (const [attr, eventName] of Object.entries(INLINE_HANDLER_EVENTS)) {
|
||||
const nodes = root.querySelectorAll(`[${attr}]`);
|
||||
for (const el of nodes) {
|
||||
const expr = el.getAttribute(attr);
|
||||
const handler = compileInlineHandler(expr);
|
||||
if (handler) el.addEventListener(eventName, handler);
|
||||
el.removeAttribute(attr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('DOMContentLoaded', async () => {
|
||||
// Cache DOM references
|
||||
cacheDom();
|
||||
|
||||
// Wire CSP-safe inline-handler stand-ins from index.html
|
||||
wireInlineHandlers(document);
|
||||
|
||||
// Initialize theme and accent color
|
||||
initTheme();
|
||||
initAccentColor();
|
||||
initPlayerFullscreen();
|
||||
|
||||
// Register service worker for PWA installability
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
}
|
||||
|
||||
// Initialize vinyl mode
|
||||
applyVinylMode();
|
||||
// Build the editorial spectrum bars. Fewer, fatter bars read better
|
||||
// than many thin ones at this column width. JS-managed so we can
|
||||
// drive heights from real audio data when available.
|
||||
const spectrumRoot = document.getElementById('player-spectrum');
|
||||
if (spectrumRoot && !spectrumRoot.children.length) {
|
||||
const SPECTRUM_BARS = 40;
|
||||
// CSS repeat() doesn't accept a var() for its count — set the
|
||||
// grid column template from JS so it always matches the bar
|
||||
// count and stretches each bar to claim 1fr of the row.
|
||||
spectrumRoot.style.gridTemplateColumns =
|
||||
`repeat(${SPECTRUM_BARS}, minmax(0, 1fr))`;
|
||||
const frag = document.createDocumentFragment();
|
||||
for (let i = 0; i < SPECTRUM_BARS; i++) {
|
||||
const s = document.createElement('span');
|
||||
// Pseudo-random initial scaleY for the synthetic CSS-only
|
||||
// animation (used while no real audio is flowing).
|
||||
const scale = (0.25 + Math.abs(Math.sin(i * 0.7)) * 0.70).toFixed(2);
|
||||
s.style.setProperty('--bar-h-scale', scale);
|
||||
s.style.setProperty('--bar-delay', (-Math.random() * 1.1).toFixed(2) + 's');
|
||||
frag.appendChild(s);
|
||||
}
|
||||
spectrumRoot.appendChild(frag);
|
||||
}
|
||||
|
||||
// Initialize audio visualizer
|
||||
// Initialize audio visualizer — auto-enable when supported so the
|
||||
// spectrum shows real audio out of the box.
|
||||
checkVisualizerAvailability().then(() => {
|
||||
if (visualizerEnabled && visualizerAvailable) {
|
||||
if (!visualizerAvailable) return;
|
||||
// First install: opt the user in by default since the spectrum
|
||||
// is the centerpiece of the player view.
|
||||
const stored = localStorage.getItem('visualizerEnabled');
|
||||
const shouldEnable = stored === null ? true : stored === 'true';
|
||||
if (shouldEnable) {
|
||||
setVisualizerEnabled(true); // updates the let in player.js
|
||||
applyVisualizerMode();
|
||||
}
|
||||
});
|
||||
@@ -368,6 +468,16 @@ window.addEventListener('DOMContentLoaded', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
// About dialog backdrop click to close
|
||||
const aboutDialog = document.getElementById('aboutDialog');
|
||||
if (aboutDialog) {
|
||||
aboutDialog.addEventListener('click', (e) => {
|
||||
if (e.target === aboutDialog) {
|
||||
closeAboutDialog();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Delegated click handlers for link table actions (XSS-safe)
|
||||
document.getElementById('linksTableBody').addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('[data-action]');
|
||||
|
||||
@@ -236,27 +236,54 @@ export function updateBackgroundColors() {
|
||||
|
||||
// ---- Render loop ----
|
||||
|
||||
// Cached step into the bins array; recomputed only when bins.length
|
||||
// changes (which happens at most once after the first audio frame
|
||||
// arrives or when num_bins is reconfigured).
|
||||
let bgBinsLength = -1;
|
||||
let bgBinsStep = 1;
|
||||
// Last applied resolution — drawing with stale viewport is harmless,
|
||||
// but we still need to refresh the uniform after the resize listener
|
||||
// has updated the canvas.
|
||||
let bgLastResW = -1;
|
||||
let bgLastResH = -1;
|
||||
|
||||
function renderBackgroundFrame() {
|
||||
bgAnimFrame = requestAnimationFrame(renderBackgroundFrame);
|
||||
|
||||
const gl = bgGL;
|
||||
if (!gl || !bgUniforms) return;
|
||||
|
||||
resizeBackgroundCanvas();
|
||||
gl.viewport(0, 0, bgCanvas.width, bgCanvas.height);
|
||||
// Resize listener already keeps canvas dimensions in sync — only
|
||||
// touch the viewport when the canvas actually changed size, so the
|
||||
// per-frame path doesn't read window.innerWidth (a layout-flushing
|
||||
// property).
|
||||
if (bgCanvas.width !== bgLastResW || bgCanvas.height !== bgLastResH) {
|
||||
bgLastResW = bgCanvas.width;
|
||||
bgLastResH = bgCanvas.height;
|
||||
gl.viewport(0, 0, bgLastResW, bgLastResH);
|
||||
gl.uniform2f(bgUniforms.resolution, bgLastResW, bgLastResH);
|
||||
}
|
||||
|
||||
const time = performance.now() / 1000 - bgStartTime;
|
||||
|
||||
// Smooth audio data from the imported frequencyData (shared with visualizer)
|
||||
// Smooth audio data from the imported frequencyData (shared with visualizer).
|
||||
// Backend may send float bins (legacy) or int×1000 (new); .scale tells us which.
|
||||
if (frequencyData && frequencyData.frequencies) {
|
||||
const bins = frequencyData.frequencies;
|
||||
const step = Math.max(1, Math.floor(bins.length / BG_BAND_COUNT));
|
||||
const scale = frequencyData.scale && frequencyData.scale > 0
|
||||
? 1.0 / frequencyData.scale : 1.0;
|
||||
if (bins.length !== bgBinsLength) {
|
||||
bgBinsLength = bins.length;
|
||||
bgBinsStep = Math.max(1, Math.floor(bgBinsLength / BG_BAND_COUNT));
|
||||
}
|
||||
const step = bgBinsStep;
|
||||
for (let i = 0; i < BG_BAND_COUNT; i++) {
|
||||
const idx = Math.min(i * step, bins.length - 1);
|
||||
const target = bins[idx] || 0;
|
||||
let idx = i * step;
|
||||
if (idx >= bgBinsLength) idx = bgBinsLength - 1;
|
||||
const target = (bins[idx] || 0) * scale;
|
||||
bgSmoothedBands[i] += (target - bgSmoothedBands[i]) * (1 - BG_SMOOTHING);
|
||||
}
|
||||
const targetBass = frequencyData.bass || 0;
|
||||
const targetBass = (frequencyData.bass || 0) * scale;
|
||||
bgSmoothedBass += (targetBass - bgSmoothedBass) * (1 - BG_SMOOTHING);
|
||||
} else {
|
||||
// Gentle decay when no audio
|
||||
@@ -267,7 +294,6 @@ function renderBackgroundFrame() {
|
||||
}
|
||||
|
||||
// Set uniforms (locations cached at init, colors cached on change)
|
||||
gl.uniform2f(bgUniforms.resolution, bgCanvas.width, bgCanvas.height);
|
||||
gl.uniform1f(bgUniforms.time, time);
|
||||
gl.uniform1f(bgUniforms.bass, bgSmoothedBass);
|
||||
gl.uniform1fv(bgUniforms.bands, bgSmoothedBands);
|
||||
|
||||
@@ -66,12 +66,14 @@ function showRootFolders() {
|
||||
// Hide search at root level
|
||||
showBrowserSearch(false);
|
||||
|
||||
// Render breadcrumb with just "Home" (not clickable at root)
|
||||
// Render breadcrumb with just "Home" (already at root — not interactive).
|
||||
const breadcrumb = document.getElementById('breadcrumb');
|
||||
breadcrumb.innerHTML = '';
|
||||
const root = document.createElement('span');
|
||||
root.className = 'breadcrumb-item breadcrumb-home';
|
||||
root.innerHTML = '<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>';
|
||||
root.setAttribute('aria-current', 'page');
|
||||
root.setAttribute('aria-label', t('browser.home') || 'Home');
|
||||
root.innerHTML = '<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>';
|
||||
breadcrumb.appendChild(root);
|
||||
|
||||
// Hide play all button and pagination
|
||||
@@ -133,8 +135,10 @@ function showRootFolders() {
|
||||
}
|
||||
|
||||
async function browsePath(folderId, path, offset = 0, nocache = false) {
|
||||
// Clear search when navigating
|
||||
// Clear search when navigating; bump browse generation so in-flight
|
||||
// thumbnail fetches from the previous folder can be discarded.
|
||||
showBrowserSearch(false);
|
||||
bumpBrowseGen();
|
||||
|
||||
try {
|
||||
if (!hasCredentials()) return;
|
||||
@@ -195,10 +199,13 @@ function renderBreadcrumbs(currentPathStr, parentPath) {
|
||||
const parts = (currentPathStr || '').split('/').filter(p => p);
|
||||
let path = '/';
|
||||
|
||||
// Home link (back to folder list)
|
||||
const home = document.createElement('span');
|
||||
// Home link (back to folder list) — use a real <button> so it's
|
||||
// keyboard-focusable and reachable by screen readers.
|
||||
const home = document.createElement('button');
|
||||
home.type = 'button';
|
||||
home.className = 'breadcrumb-item breadcrumb-home';
|
||||
home.innerHTML = '<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>';
|
||||
home.setAttribute('aria-label', t('browser.home') || 'Home');
|
||||
home.innerHTML = '<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>';
|
||||
home.onclick = () => showRootFolders();
|
||||
breadcrumb.appendChild(home);
|
||||
|
||||
@@ -512,16 +519,34 @@ function formatBitrate(bps) {
|
||||
return Math.round(bps / 1000) + ' kbps';
|
||||
}
|
||||
|
||||
// Bump this whenever the user changes folder/path so in-flight fetches from
|
||||
// the previous view can be ignored when they finally resolve.
|
||||
let _browseGen = 0;
|
||||
function bumpBrowseGen() { return ++_browseGen; }
|
||||
function currentBrowseGen() { return _browseGen; }
|
||||
|
||||
function buildRelativeFilePath(relativePath, fileName) {
|
||||
const base = (relativePath === '/' || relativePath === '') ? '' : relativePath.replace(/\/$/, '');
|
||||
return base + '/' + fileName;
|
||||
}
|
||||
|
||||
async function loadThumbnail(imgElement, fileName) {
|
||||
const myGen = currentBrowseGen();
|
||||
const folderId = currentFolderId;
|
||||
const relPath = buildRelativeFilePath(currentPath, fileName);
|
||||
const cacheKey = `${folderId}|${relPath}`;
|
||||
try {
|
||||
if (!hasCredentials()) return;
|
||||
|
||||
const absolutePath = buildAbsolutePath(currentFolderId, currentPath, fileName);
|
||||
// Note: the imgElement is intentionally NOT in the DOM yet when
|
||||
// renderBrowserGrid/renderBrowserList call us — it's still inside a
|
||||
// detached wrapper. Don't bail on isConnected here; rely on the
|
||||
// post-await checks below, which correctly catch navigation away.
|
||||
|
||||
// Check cache first
|
||||
if (thumbnailCache.has(absolutePath)) {
|
||||
const cachedUrl = thumbnailCache.get(absolutePath);
|
||||
if (thumbnailCache.has(cacheKey)) {
|
||||
const cachedUrl = thumbnailCache.get(cacheKey);
|
||||
imgElement.onload = () => {
|
||||
if (!imgElement.isConnected) return;
|
||||
imgElement.classList.remove('loading');
|
||||
imgElement.classList.add('loaded');
|
||||
};
|
||||
@@ -529,17 +554,24 @@ async function loadThumbnail(imgElement, fileName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const encodedPath = encodeURIComponent(absolutePath);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
folder_id: folderId,
|
||||
path: relPath,
|
||||
size: 'medium',
|
||||
});
|
||||
const response = await fetch(
|
||||
`/api/browser/thumbnail?path=${encodedPath}&size=medium`,
|
||||
`/api/browser/thumbnail?${params.toString()}`,
|
||||
{ headers: getAuthHeaders() }
|
||||
);
|
||||
|
||||
// Drop the response if the user has since navigated away.
|
||||
if (myGen !== currentBrowseGen() || !imgElement.isConnected) return;
|
||||
|
||||
if (response.status === 200) {
|
||||
const blob = await response.blob();
|
||||
if (myGen !== currentBrowseGen() || !imgElement.isConnected) return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
thumbnailCache.set(absolutePath, url);
|
||||
thumbnailCache.set(cacheKey, url);
|
||||
|
||||
// Evict oldest entries when cache exceeds limit
|
||||
if (thumbnailCache.size > THUMBNAIL_CACHE_MAX) {
|
||||
@@ -548,13 +580,11 @@ async function loadThumbnail(imgElement, fileName) {
|
||||
thumbnailCache.delete(oldest);
|
||||
}
|
||||
|
||||
// Wait for image to actually load before showing it
|
||||
imgElement.onload = () => {
|
||||
imgElement.classList.remove('loading');
|
||||
imgElement.classList.add('loaded');
|
||||
};
|
||||
|
||||
// Revoke previous blob URL if not managed by cache
|
||||
if (imgElement.src && imgElement.src.startsWith('blob:')) {
|
||||
let isCached = false;
|
||||
for (const cachedUrl of thumbnailCache.values()) {
|
||||
@@ -564,8 +594,8 @@ async function loadThumbnail(imgElement, fileName) {
|
||||
}
|
||||
imgElement.src = url;
|
||||
} else {
|
||||
// Fallback to icon (204 = no thumbnail available)
|
||||
const parent = imgElement.parentElement;
|
||||
if (!parent) return;
|
||||
const isList = parent.classList.contains('browser-list-icon');
|
||||
imgElement.remove();
|
||||
if (isList) {
|
||||
@@ -579,7 +609,7 @@ async function loadThumbnail(imgElement, fileName) {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading thumbnail:', error);
|
||||
imgElement.classList.remove('loading');
|
||||
if (imgElement.isConnected) imgElement.classList.remove('loading');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -601,12 +631,12 @@ async function playMediaFile(fileName) {
|
||||
try {
|
||||
if (!hasCredentials()) return;
|
||||
|
||||
const absolutePath = buildAbsolutePath(currentFolderId, currentPath, fileName);
|
||||
const relativePath = buildRelativeFilePath(currentPath, fileName);
|
||||
|
||||
const response = await fetch('/api/browser/play', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
||||
body: JSON.stringify({ path: absolutePath })
|
||||
body: JSON.stringify({ folder_id: currentFolderId, path: relativePath })
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to play file');
|
||||
|
||||
@@ -81,7 +81,7 @@ async function _loadCallbacksTableImpl() {
|
||||
`).join('');
|
||||
} catch (error) {
|
||||
console.error('Error loading callbacks:', error);
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="empty-state" style="color: var(--error);">Failed to load callbacks</td></tr>';
|
||||
tbody.innerHTML = `<tr><td colspan="4" class="empty-state" style="color: var(--error);">${escapeHtml(t('callbacks.msg.load_failed'))}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ export async function showEditCallbackDialog(callbackName) {
|
||||
const callback = callbacksList.find(c => c.name === callbackName);
|
||||
|
||||
if (!callback) {
|
||||
showToast('Callback not found', 'error');
|
||||
showToast(t('callbacks.msg.not_found'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ export async function showEditCallbackDialog(callbackName) {
|
||||
dialog.showModal();
|
||||
} catch (error) {
|
||||
console.error('Error loading callback for edit:', error);
|
||||
showToast('Failed to load callback details', 'error');
|
||||
showToast(t('callbacks.msg.load_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,9 +175,10 @@ export async function saveCallback(event) {
|
||||
shell: true
|
||||
};
|
||||
|
||||
const encodedName = encodeURIComponent(callbackName);
|
||||
const endpoint = isEdit ?
|
||||
`/api/callbacks/update/${callbackName}` :
|
||||
`/api/callbacks/create/${callbackName}`;
|
||||
`/api/callbacks/update/${encodedName}` :
|
||||
`/api/callbacks/create/${encodedName}`;
|
||||
|
||||
const method = isEdit ? 'PUT' : 'POST';
|
||||
|
||||
@@ -191,16 +192,16 @@ export async function saveCallback(event) {
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
showToast(`Callback ${isEdit ? 'updated' : 'created'} successfully`, 'success');
|
||||
showToast(t(isEdit ? 'callbacks.msg.updated' : 'callbacks.msg.created'), 'success');
|
||||
callbackFormDirty = false;
|
||||
closeCallbackDialog();
|
||||
loadCallbacksTable();
|
||||
} else {
|
||||
showToast(result.detail || `Failed to ${isEdit ? 'update' : 'create'} callback`, 'error');
|
||||
showToast(result.detail || t(isEdit ? 'callbacks.msg.update_failed' : 'callbacks.msg.create_failed'), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving callback:', error);
|
||||
showToast(`Error ${isEdit ? 'updating' : 'creating'} callback`, 'error');
|
||||
showToast(t(isEdit ? 'callbacks.msg.update_failed' : 'callbacks.msg.create_failed'), 'error');
|
||||
} finally {
|
||||
if (submitBtn) submitBtn.disabled = false;
|
||||
}
|
||||
@@ -212,7 +213,7 @@ export async function deleteCallbackConfirm(callbackName) {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/callbacks/delete/${callbackName}`, {
|
||||
const response = await fetch(`/api/callbacks/delete/${encodeURIComponent(callbackName)}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
@@ -220,13 +221,13 @@ export async function deleteCallbackConfirm(callbackName) {
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
showToast('Callback deleted successfully', 'success');
|
||||
showToast(t('callbacks.msg.deleted'), 'success');
|
||||
loadCallbacksTable();
|
||||
} else {
|
||||
showToast(result.detail || 'Failed to delete callback', 'error');
|
||||
showToast(result.detail || t('callbacks.msg.delete_failed'), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting callback:', error);
|
||||
showToast('Error deleting callback', 'error');
|
||||
showToast(t('callbacks.msg.delete_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +116,8 @@ export function cacheDom() {
|
||||
dom.progressFill = document.getElementById('progress-fill');
|
||||
dom.currentTime = document.getElementById('current-time');
|
||||
dom.totalTime = document.getElementById('total-time');
|
||||
dom.metaElapsed = document.getElementById('meta-elapsed');
|
||||
dom.metaLength = document.getElementById('meta-length');
|
||||
dom.progressBar = document.getElementById('progress-bar');
|
||||
dom.miniProgressFill = document.getElementById('mini-progress-fill');
|
||||
dom.miniCurrentTime = document.getElementById('mini-current-time');
|
||||
@@ -138,7 +140,10 @@ export function cacheDom() {
|
||||
|
||||
// Timing constants
|
||||
export const VOLUME_THROTTLE_MS = 16;
|
||||
export const POSITION_INTERPOLATION_MS = 100;
|
||||
// 250ms is plenty for sub-second progress; the inline updateProgress
|
||||
// also short-circuits when the rounded second hasn't moved, so there's
|
||||
// no visible difference for the user.
|
||||
export const POSITION_INTERPOLATION_MS = 250;
|
||||
export const SEARCH_DEBOUNCE_MS = 200;
|
||||
export const TOAST_DURATION_MS = 3000;
|
||||
export const WS_BACKOFF_BASE_MS = 3000;
|
||||
@@ -150,6 +155,7 @@ export const VOLUME_RELEASE_DELAY_MS = 500;
|
||||
// Shared state (accessed across multiple modules)
|
||||
export let ws = null;
|
||||
export function setWs(value) { ws = value; }
|
||||
export function getWs() { return ws; }
|
||||
export let currentState = 'idle';
|
||||
export function setCurrentState(value) { currentState = value; }
|
||||
export let currentDuration = 0;
|
||||
@@ -317,6 +323,8 @@ export async function fetchVersion() {
|
||||
const label = document.getElementById('version-label');
|
||||
if (data.version) {
|
||||
label.textContent = `v${data.version}`;
|
||||
const folioVersion = document.getElementById('folio-version');
|
||||
if (folioVersion) folioVersion.textContent = `v${data.version}`;
|
||||
}
|
||||
if (data.update_available) {
|
||||
showUpdateBanner(data.update_available);
|
||||
@@ -390,6 +398,16 @@ export function closeDialog(dialog) {
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
export function showAboutDialog() {
|
||||
const dialog = document.getElementById('aboutDialog');
|
||||
if (dialog) dialog.showModal();
|
||||
}
|
||||
|
||||
export function closeAboutDialog() {
|
||||
const dialog = document.getElementById('aboutDialog');
|
||||
if (dialog) closeDialog(dialog);
|
||||
}
|
||||
|
||||
export function showConfirm(message) {
|
||||
return new Promise((resolve) => {
|
||||
const dialog = document.getElementById('confirmDialog');
|
||||
@@ -496,6 +514,12 @@ export function setVolume(volume) {
|
||||
sendCommand('volume', { volume: volume });
|
||||
}
|
||||
}
|
||||
// Reset the de-dupe cache whenever the server reports a fresh volume value
|
||||
// (e.g., another client moved the slider). Otherwise the user can end up
|
||||
// unable to "set volume back to the value we last sent" after a remote change.
|
||||
export function notifyRemoteVolume(volume) {
|
||||
lastSentVolume = volume;
|
||||
}
|
||||
|
||||
export function toggleMute() {
|
||||
sendCommand('mute');
|
||||
@@ -519,23 +543,68 @@ function _persistMdiCache() {
|
||||
try { localStorage.setItem('mdiIconCache', JSON.stringify(mdiIconCache)); } catch {}
|
||||
}
|
||||
|
||||
// Strict iconify MDI slug — used to reject anything that could be path-traversal
|
||||
// or query injection before we even hit the network.
|
||||
const MDI_SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||
|
||||
function sanitizeSvg(rawSvg) {
|
||||
// Parse the SVG and strip anything that could execute script. Anything
|
||||
// unparseable returns null so callers fall back to the placeholder.
|
||||
try {
|
||||
const doc = new DOMParser().parseFromString(rawSvg, 'image/svg+xml');
|
||||
const root = doc.documentElement;
|
||||
if (!root || root.tagName.toLowerCase() !== 'svg' || root.querySelector('parsererror')) {
|
||||
return null;
|
||||
}
|
||||
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
|
||||
const toRemove = [];
|
||||
let node = walker.currentNode;
|
||||
while (node) {
|
||||
const tag = node.tagName?.toLowerCase();
|
||||
if (tag === 'script' || tag === 'foreignobject') {
|
||||
toRemove.push(node);
|
||||
} else if (node.attributes) {
|
||||
for (const attr of Array.from(node.attributes)) {
|
||||
const name = attr.name.toLowerCase();
|
||||
if (name.startsWith('on') ||
|
||||
((name === 'href' || name === 'xlink:href') &&
|
||||
/^\s*(javascript|data):/i.test(attr.value))) {
|
||||
node.removeAttribute(attr.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
node = walker.nextNode();
|
||||
}
|
||||
toRemove.forEach((el) => el.remove());
|
||||
return root.outerHTML;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const PLACEHOLDER_SVG = '<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>';
|
||||
|
||||
export async function fetchMdiIcon(iconName) {
|
||||
const name = iconName.replace(/^mdi:/, '');
|
||||
const name = String(iconName || '').replace(/^mdi:/, '');
|
||||
if (!MDI_SLUG_RE.test(name)) return PLACEHOLDER_SVG;
|
||||
if (mdiIconCache[name]) return mdiIconCache[name];
|
||||
|
||||
try {
|
||||
const response = await fetch(`https://api.iconify.design/mdi/${name}.svg?width=16&height=16`);
|
||||
const response = await fetch(`https://api.iconify.design/mdi/${encodeURIComponent(name)}.svg?width=16&height=16`);
|
||||
if (response.ok) {
|
||||
const svg = await response.text();
|
||||
mdiIconCache[name] = svg;
|
||||
_persistMdiCache();
|
||||
return svg;
|
||||
const raw = await response.text();
|
||||
const safe = sanitizeSvg(raw);
|
||||
if (safe) {
|
||||
mdiIconCache[name] = safe;
|
||||
_persistMdiCache();
|
||||
return safe;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch MDI icon:', name, e);
|
||||
}
|
||||
|
||||
return '<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>';
|
||||
return PLACEHOLDER_SVG;
|
||||
}
|
||||
|
||||
export async function resolveMdiIcons(container) {
|
||||
|
||||
+359
-20
@@ -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 { IconSelect } from './icon-select.js';
|
||||
|
||||
let displayBrightnessTimers = {};
|
||||
let displayContrastTimers = {};
|
||||
let _displayIconSelects = [];
|
||||
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() {
|
||||
if (!hasCredentials()) return;
|
||||
|
||||
@@ -14,7 +109,7 @@ export async function loadDisplayMonitors() {
|
||||
if (!container) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/display/monitors?refresh=true', {
|
||||
const response = await fetch('/api/display/monitors', {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
@@ -36,7 +131,13 @@ export async function loadDisplayMonitors() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Destroy IconSelects from a previous render so listeners + popups
|
||||
// don't pile up.
|
||||
_displayIconSelects.forEach(inst => { try { inst.destroy(); } catch (_) {} });
|
||||
_displayIconSelects = [];
|
||||
|
||||
container.innerHTML = '';
|
||||
const pendingIconSelects = [];
|
||||
monitors.forEach(monitor => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'display-monitor-card';
|
||||
@@ -47,17 +148,136 @@ export async function loadDisplayMonitors() {
|
||||
|
||||
let powerBtn = '';
|
||||
if (monitor.power_supported) {
|
||||
// Inline onclick with string-interpolated monitor.name is a DOM-XSS
|
||||
// foot-gun if the OS ever reports a name containing quotes / angle
|
||||
// brackets. Use a delegated click handler bound to data-* attrs.
|
||||
powerBtn = `
|
||||
<button class="display-power-btn ${monitor.power_on ? 'on' : 'off'}" id="power-btn-${monitor.id}"
|
||||
onclick="toggleDisplayPower(${monitor.id}, '${monitor.name.replace(/'/g, "\\'")}')"
|
||||
title="${monitor.power_on ? t('display.power_off') : t('display.power_on')}">
|
||||
data-action="toggle-power" data-monitor-id="${monitor.id}"
|
||||
title="${escapeHtml(monitor.power_on ? t('display.power_off') : t('display.power_on'))}">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M13 3h-2v10h2V3zm4.83 2.17l-1.42 1.42A6.92 6.92 0 0119 12c0 3.87-3.13 7-7 7s-7-3.13-7-7c0-2.27 1.08-4.28 2.76-5.56L6.34 5.02A8.95 8.95 0 003 12c0 4.97 4.03 9 9 9s9-4.03 9-9a8.95 8.95 0 00-3.17-6.83z"/></svg>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
const details = [monitor.resolution, monitor.manufacturer].filter(Boolean).join(' \u00B7 ');
|
||||
const detailsHtml = details ? `<span class="display-monitor-details">${details}</span>` : '';
|
||||
const primaryBadge = monitor.is_primary ? `<span class="display-primary-badge">${t('display.primary')}</span>` : '';
|
||||
const detailsHtml = details ? `<span class="display-monitor-details">${escapeHtml(details)}</span>` : '';
|
||||
const primaryBadge = monitor.is_primary
|
||||
? `<span class="display-primary-badge" title="${t('display.primary')}" aria-label="${t('display.primary')}">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
|
||||
<path fill="currentColor" d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
|
||||
</svg>
|
||||
</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}"
|
||||
oninput="onDisplayContrastInput(${monitor.id}, this.value)"
|
||||
onchange="onDisplayContrastChange(${monitor.id}, this.value)">
|
||||
<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 = `
|
||||
<div class="display-monitor-header">
|
||||
@@ -65,23 +285,50 @@ export async function loadDisplayMonitors() {
|
||||
<path fill="currentColor" d="M20 3H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h6v2H8v2h8v-2h-2v-2h6c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 12H4V5h16v10z"/>
|
||||
</svg>
|
||||
<div class="display-monitor-info">
|
||||
<span class="display-monitor-name">${monitor.name}${primaryBadge}</span>
|
||||
<span class="display-monitor-name"><span class="display-monitor-name-text">${escapeHtml(monitor.name)}</span>${primaryBadge}</span>
|
||||
${detailsHtml}
|
||||
</div>
|
||||
${powerBtn}
|
||||
</div>
|
||||
<div class="display-brightness-control">
|
||||
<svg class="display-brightness-icon" viewBox="0 0 24 24" width="16" height="16">
|
||||
<div class="display-slider-row display-brightness-control">
|
||||
<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"/>
|
||||
</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>
|
||||
<input type="range" class="display-slider display-brightness-slider" min="0" max="100" value="${brightnessValue}" ${brightnessDisabled}
|
||||
oninput="onDisplayBrightnessInput(${monitor.id}, this.value)"
|
||||
onchange="onDisplayBrightnessChange(${monitor.id}, this.value)">
|
||||
<span class="display-brightness-value" id="brightness-val-${monitor.id}">${brightnessValue}%</span>
|
||||
</div>`;
|
||||
<span class="display-slider-value display-brightness-value" id="brightness-val-${monitor.id}">${brightnessValue}%</span>
|
||||
</div>
|
||||
${contrastRow}
|
||||
${tuningBlock}`;
|
||||
|
||||
container.appendChild(card);
|
||||
});
|
||||
|
||||
// Bind a single delegated click handler for the power buttons.
|
||||
// Avoids inline onclick="..." with interpolated monitor data.
|
||||
container.removeEventListener('click', _onPowerButtonClick);
|
||||
container.addEventListener('click', _onPowerButtonClick);
|
||||
|
||||
// Enhance every tuning <select> with an IconSelect now that the
|
||||
// cards are in the DOM (IconSelect needs offsetParent + sibling).
|
||||
pendingIconSelects.forEach(({ kind, monitorId, items }) => {
|
||||
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) {
|
||||
console.error('Failed to load display monitors:', e);
|
||||
}
|
||||
@@ -118,7 +365,98 @@ 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);
|
||||
}
|
||||
|
||||
export async function toggleDisplayPower(monitorId) {
|
||||
const btn = document.getElementById(`power-btn-${monitorId}`);
|
||||
const isOn = btn && btn.classList.contains('on');
|
||||
const newState = !isOn;
|
||||
@@ -136,13 +474,13 @@ export async function toggleDisplayPower(monitorId, monitorName) {
|
||||
btn.classList.toggle('off', !newState);
|
||||
btn.title = newState ? t('display.power_off') : t('display.power_on');
|
||||
}
|
||||
showToast(newState ? 'Monitor turned on' : 'Monitor turned off', 'success');
|
||||
showToast(t(newState ? 'display.msg.power_on' : 'display.msg.power_off'), 'success');
|
||||
} else {
|
||||
showToast('Failed to change monitor power', 'error');
|
||||
showToast(t('display.msg.power_failed'), 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to set display power:', e);
|
||||
showToast('Failed to change monitor power', 'error');
|
||||
showToast(t('display.msg.power_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,7 +577,7 @@ async function _loadLinksTableImpl() {
|
||||
resolveMdiIcons(tbody);
|
||||
} catch (error) {
|
||||
console.error('Error loading links:', error);
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="empty-state" style="color: var(--error);">Failed to load links</td></tr>';
|
||||
tbody.innerHTML = `<tr><td colspan="4" class="empty-state" style="color: var(--error);">${escapeHtml(t('links.msg.load_failed'))}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -342,9 +680,10 @@ export async function saveLink(event) {
|
||||
description: document.getElementById('linkDescription').value || ''
|
||||
};
|
||||
|
||||
const encodedName = encodeURIComponent(linkName);
|
||||
const endpoint = isEdit ?
|
||||
`/api/links/update/${linkName}` :
|
||||
`/api/links/create/${linkName}`;
|
||||
`/api/links/update/${encodedName}` :
|
||||
`/api/links/create/${encodedName}`;
|
||||
|
||||
const method = isEdit ? 'PUT' : 'POST';
|
||||
|
||||
@@ -378,7 +717,7 @@ export async function deleteLinkConfirm(linkName) {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/links/delete/${linkName}`, {
|
||||
const response = await fetch(`/api/links/delete/${encodeURIComponent(linkName)}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
+689
-180
File diff suppressed because it is too large
Load Diff
@@ -150,7 +150,7 @@ async function executeScript(scriptName, buttonElement) {
|
||||
|
||||
async function _doExecuteScript(scriptName, params) {
|
||||
try {
|
||||
const response = await fetch(`/api/scripts/execute/${scriptName}`, {
|
||||
const response = await fetch(`/api/scripts/execute/${encodeURIComponent(scriptName)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
||||
body: JSON.stringify({ params })
|
||||
@@ -393,7 +393,7 @@ async function _loadScriptsTableImpl() {
|
||||
resolveMdiIcons(tbody);
|
||||
} catch (error) {
|
||||
console.error('Error loading scripts:', error);
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="empty-state" style="color: var(--error);">Failed to load scripts</td></tr>';
|
||||
tbody.innerHTML = `<tr><td colspan="5" class="empty-state" style="color: var(--error);">${escapeHtml(t('scripts.msg.load_failed'))}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -433,7 +433,7 @@ export async function showEditScriptDialog(scriptName) {
|
||||
const script = scriptsList.find(s => s.name === scriptName);
|
||||
|
||||
if (!script) {
|
||||
showToast('Script not found', 'error');
|
||||
showToast(t('scripts.msg.not_found'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -470,7 +470,7 @@ export async function showEditScriptDialog(scriptName) {
|
||||
dialog.showModal();
|
||||
} catch (error) {
|
||||
console.error('Error loading script for edit:', error);
|
||||
showToast('Failed to load script details', 'error');
|
||||
showToast(t('scripts.msg.load_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -508,9 +508,10 @@ export async function saveScript(event) {
|
||||
parameters: _collectParameterDefinitions(),
|
||||
};
|
||||
|
||||
const encodedName = encodeURIComponent(scriptName);
|
||||
const endpoint = isEdit ?
|
||||
`/api/scripts/update/${scriptName}` :
|
||||
`/api/scripts/create/${scriptName}`;
|
||||
`/api/scripts/update/${encodedName}` :
|
||||
`/api/scripts/create/${encodedName}`;
|
||||
|
||||
const method = isEdit ? 'PUT' : 'POST';
|
||||
|
||||
@@ -524,15 +525,15 @@ export async function saveScript(event) {
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
showToast(`Script ${isEdit ? 'updated' : 'created'} successfully`, 'success');
|
||||
showToast(t(isEdit ? 'scripts.msg.updated' : 'scripts.msg.created'), 'success');
|
||||
scriptFormDirty = false;
|
||||
closeScriptDialog();
|
||||
} else {
|
||||
showToast(result.detail || `Failed to ${isEdit ? 'update' : 'create'} script`, 'error');
|
||||
showToast(result.detail || t(isEdit ? 'scripts.msg.update_failed' : 'scripts.msg.create_failed'), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving script:', error);
|
||||
showToast(`Error ${isEdit ? 'updating' : 'creating'} script`, 'error');
|
||||
showToast(t(isEdit ? 'scripts.msg.update_failed' : 'scripts.msg.create_failed'), 'error');
|
||||
} finally {
|
||||
if (submitBtn) submitBtn.disabled = false;
|
||||
}
|
||||
@@ -544,7 +545,7 @@ export async function deleteScriptConfirm(scriptName) {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/scripts/delete/${scriptName}`, {
|
||||
const response = await fetch(`/api/scripts/delete/${encodeURIComponent(scriptName)}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
@@ -552,13 +553,13 @@ export async function deleteScriptConfirm(scriptName) {
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
showToast('Script deleted successfully', 'success');
|
||||
showToast(t('scripts.msg.deleted'), 'success');
|
||||
} else {
|
||||
showToast(result.detail || 'Failed to delete script', 'error');
|
||||
showToast(result.detail || t('scripts.msg.delete_failed'), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting script:', error);
|
||||
showToast('Error deleting script', 'error');
|
||||
showToast(t('scripts.msg.delete_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -581,23 +582,23 @@ function showExecutionResult(name, result, type = 'script') {
|
||||
const outputPre = document.getElementById('executionOutput');
|
||||
const errorPre = document.getElementById('executionError');
|
||||
|
||||
title.textContent = `Execution Result: ${name}`;
|
||||
title.textContent = `${t('execution.result')}: ${name}`;
|
||||
|
||||
const success = result.success && result.exit_code === 0;
|
||||
const statusClass = success ? 'success' : 'error';
|
||||
const statusText = success ? 'Success' : 'Failed';
|
||||
const statusText = t(success ? 'execution.success' : 'execution.failed');
|
||||
|
||||
statusDiv.innerHTML = `
|
||||
<div class="status-item ${statusClass}">
|
||||
<label>Status</label>
|
||||
<value>${statusText}</value>
|
||||
<label>${escapeHtml(t('execution.status'))}</label>
|
||||
<value>${escapeHtml(statusText)}</value>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<label>Exit Code</label>
|
||||
<label>${escapeHtml(t('execution.exit_code'))}</label>
|
||||
<value>${result.exit_code !== undefined ? result.exit_code : 'N/A'}</value>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<label>Duration</label>
|
||||
<label>${escapeHtml(t('execution.duration'))}</label>
|
||||
<value>${result.execution_time !== undefined && result.execution_time !== null ? result.execution_time.toFixed(3) + 's' : 'N/A'}</value>
|
||||
</div>
|
||||
`;
|
||||
@@ -606,7 +607,7 @@ function showExecutionResult(name, result, type = 'script') {
|
||||
if (result.stdout && result.stdout.trim()) {
|
||||
outputPre.textContent = result.stdout;
|
||||
} else {
|
||||
outputPre.textContent = '(no output)';
|
||||
outputPre.textContent = t('execution.no_output');
|
||||
outputPre.style.fontStyle = 'italic';
|
||||
outputPre.style.color = 'var(--text-secondary)';
|
||||
}
|
||||
@@ -642,11 +643,11 @@ async function _executeScriptDebugWithParams(scriptName, params) {
|
||||
const title = document.getElementById('executionDialogTitle');
|
||||
const statusDiv = document.getElementById('executionStatus');
|
||||
|
||||
title.textContent = `Executing: ${scriptName}`;
|
||||
title.textContent = `${t('execution.executing')}: ${scriptName}`;
|
||||
statusDiv.innerHTML = `
|
||||
<div class="status-item">
|
||||
<label>Status</label>
|
||||
<value><span class="loading-spinner"></span> Running...</value>
|
||||
<label>${escapeHtml(t('execution.status'))}</label>
|
||||
<value><span class="loading-spinner"></span> ${escapeHtml(t('execution.running'))}</value>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('outputSection').style.display = 'none';
|
||||
@@ -813,11 +814,11 @@ export async function executeCallbackDebug(callbackName) {
|
||||
const title = document.getElementById('executionDialogTitle');
|
||||
const statusDiv = document.getElementById('executionStatus');
|
||||
|
||||
title.textContent = `Executing: ${callbackName}`;
|
||||
title.textContent = `${t('execution.executing')}: ${callbackName}`;
|
||||
statusDiv.innerHTML = `
|
||||
<div class="status-item">
|
||||
<label>Status</label>
|
||||
<value><span class="loading-spinner"></span> Running...</value>
|
||||
<label>${escapeHtml(t('execution.status'))}</label>
|
||||
<value><span class="loading-spinner"></span> ${escapeHtml(t('execution.running'))}</value>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('outputSection').style.display = 'none';
|
||||
@@ -826,7 +827,7 @@ export async function executeCallbackDebug(callbackName) {
|
||||
dialog.showModal();
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/callbacks/execute/${callbackName}`, {
|
||||
const response = await fetch(`/api/callbacks/execute/${encodeURIComponent(callbackName)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }
|
||||
});
|
||||
|
||||
@@ -3,19 +3,37 @@
|
||||
// ============================================================
|
||||
|
||||
import {
|
||||
dom, t, showToast, setWs,
|
||||
dom, t, setWs, getWs,
|
||||
WS_BACKOFF_BASE_MS, WS_BACKOFF_MAX_MS,
|
||||
WS_MAX_RECONNECT_ATTEMPTS, WS_PING_INTERVAL_MS,
|
||||
authRequired, showUpdateBanner,
|
||||
} from './core.js';
|
||||
import { updateUI, visualizerEnabled, visualizerAvailable, setFrequencyData, stopPositionInterpolation, loadAudioDevices } from './player.js';
|
||||
import { updateUI, setFrequencyData, stopPositionInterpolation, loadAudioDevices } from './player.js';
|
||||
import { loadScripts, loadScriptsTable, displayQuickAccess } from './scripts.js';
|
||||
import { loadCallbacksTable } from './callbacks.js';
|
||||
import { loadHeaderLinks, loadLinksTable } from './links.js';
|
||||
|
||||
let reconnectTimeout = null;
|
||||
let pingInterval = null;
|
||||
let wsReconnectAttempts = 0;
|
||||
// Track the ping interval against the socket that owns it so we never leak
|
||||
// a timer if connectWebSocket() is called while a previous socket is still
|
||||
// alive. The pair is wiped on close to avoid double-clear races.
|
||||
let activeSocket = null;
|
||||
let activePingInterval = null;
|
||||
|
||||
function clearReconnect() {
|
||||
if (reconnectTimeout) {
|
||||
clearTimeout(reconnectTimeout);
|
||||
reconnectTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearPing() {
|
||||
if (activePingInterval) {
|
||||
clearInterval(activePingInterval);
|
||||
activePingInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function showAuthForm(errorMessage = '') {
|
||||
const overlay = document.getElementById('auth-overlay');
|
||||
@@ -47,19 +65,24 @@ export function authenticate() {
|
||||
|
||||
export function clearToken() {
|
||||
localStorage.removeItem('media_server_token');
|
||||
// Access ws via import
|
||||
import('./core.js').then(core => {
|
||||
if (core.ws) {
|
||||
core.ws.close();
|
||||
}
|
||||
});
|
||||
const current = getWs();
|
||||
if (current) {
|
||||
try { current.close(1000, 'token cleared'); } catch { /* ignore */ }
|
||||
}
|
||||
showAuthForm(t('auth.cleared'));
|
||||
}
|
||||
|
||||
export function connectWebSocket(token) {
|
||||
if (pingInterval) {
|
||||
clearInterval(pingInterval);
|
||||
pingInterval = null;
|
||||
// Always cancel a pending reconnect first — otherwise a user-triggered
|
||||
// reconnect can race a scheduled one and create two live sockets.
|
||||
clearReconnect();
|
||||
clearPing();
|
||||
|
||||
// Close any previous socket cleanly before opening a new one.
|
||||
const previous = activeSocket;
|
||||
activeSocket = null;
|
||||
if (previous && previous.readyState <= WebSocket.OPEN) {
|
||||
try { previous.close(1000, 'reconnecting'); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
@@ -67,6 +90,7 @@ export function connectWebSocket(token) {
|
||||
const wsUrl = token ? `${wsBase}?token=${encodeURIComponent(token)}` : wsBase;
|
||||
|
||||
const newWs = new WebSocket(wsUrl);
|
||||
activeSocket = newWs;
|
||||
setWs(newWs);
|
||||
|
||||
newWs.onopen = () => {
|
||||
@@ -81,13 +105,16 @@ export function connectWebSocket(token) {
|
||||
loadLinksTable();
|
||||
loadHeaderLinks();
|
||||
loadAudioDevices();
|
||||
if (visualizerEnabled && visualizerAvailable) {
|
||||
newWs.send(JSON.stringify({ type: 'enable_visualizer' }));
|
||||
}
|
||||
};
|
||||
|
||||
newWs.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(event.data);
|
||||
} catch (err) {
|
||||
console.warn('Ignoring malformed WebSocket frame:', err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'status' || msg.type === 'status_update') {
|
||||
updateUI(msg.data);
|
||||
@@ -119,6 +146,13 @@ export function connectWebSocket(token) {
|
||||
updateConnectionStatus(false);
|
||||
stopPositionInterpolation();
|
||||
|
||||
// Drop this socket's ping interval. Guard so we don't kill a newer
|
||||
// socket's interval if reconnect already started.
|
||||
if (activeSocket === newWs) {
|
||||
clearPing();
|
||||
activeSocket = null;
|
||||
}
|
||||
|
||||
if (event.code === 4001) {
|
||||
localStorage.removeItem('media_server_token');
|
||||
showAuthForm(t('auth.invalid'));
|
||||
@@ -136,7 +170,9 @@ export function connectWebSocket(token) {
|
||||
showConnectionBanner(t('connection.reconnecting').replace('{attempt}', wsReconnectAttempts), false);
|
||||
}
|
||||
|
||||
clearReconnect();
|
||||
reconnectTimeout = setTimeout(() => {
|
||||
reconnectTimeout = null;
|
||||
const savedToken = localStorage.getItem('media_server_token');
|
||||
if (savedToken || !authRequired) {
|
||||
connectWebSocket(savedToken || '');
|
||||
@@ -148,9 +184,10 @@ export function connectWebSocket(token) {
|
||||
}
|
||||
};
|
||||
|
||||
pingInterval = setInterval(() => {
|
||||
if (newWs && newWs.readyState === WebSocket.OPEN) {
|
||||
newWs.send(JSON.stringify({ type: 'ping' }));
|
||||
clearPing();
|
||||
activePingInterval = setInterval(() => {
|
||||
if (newWs.readyState === WebSocket.OPEN) {
|
||||
try { newWs.send(JSON.stringify({ type: 'ping' })); } catch { /* ignore */ }
|
||||
}
|
||||
}, WS_PING_INTERVAL_MS);
|
||||
}
|
||||
@@ -185,3 +222,23 @@ export function manualReconnect() {
|
||||
connectWebSocket(savedToken || '');
|
||||
}
|
||||
}
|
||||
|
||||
// When the browser regains connectivity or the tab becomes visible again,
|
||||
// drop the backoff and reconnect immediately rather than waiting out the
|
||||
// current timer.
|
||||
function reconnectIfNeeded() {
|
||||
const current = activeSocket;
|
||||
if (current && (current.readyState === WebSocket.OPEN || current.readyState === WebSocket.CONNECTING)) {
|
||||
return;
|
||||
}
|
||||
const savedToken = localStorage.getItem('media_server_token');
|
||||
if (savedToken || !authRequired) {
|
||||
wsReconnectAttempts = 0;
|
||||
connectWebSocket(savedToken || '');
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('online', reconnectIfNeeded);
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden) reconnectIfNeeded();
|
||||
});
|
||||
|
||||
@@ -19,12 +19,23 @@
|
||||
"player.status.connected": "Connected",
|
||||
"player.status.disconnected": "Disconnected",
|
||||
"player.no_media": "No media playing",
|
||||
"player.kicker": "Now Playing",
|
||||
"player.modes": "Modes",
|
||||
"header.connected": "Connected",
|
||||
"header.volume": "Vol. I",
|
||||
"header.edition": "Studio Reference",
|
||||
"header.edition_sub": "Studio Reference Edition",
|
||||
"meta.state": "State",
|
||||
"meta.source": "Source",
|
||||
"player.title_unavailable": "Title unavailable",
|
||||
"player.source": "Source:",
|
||||
"player.unknown_source": "Unknown",
|
||||
"player.vinyl": "Vinyl mode",
|
||||
"player.visualizer": "Audio visualizer",
|
||||
"player.background": "Dynamic background",
|
||||
"player.fullscreen": "Fullscreen player",
|
||||
"player.fullscreen.exit": "Exit fullscreen",
|
||||
"player.fullscreen.exit_short": "Exit",
|
||||
"state.playing": "Playing",
|
||||
"state.paused": "Paused",
|
||||
"state.stopped": "Stopped",
|
||||
@@ -126,8 +137,8 @@
|
||||
"callbacks.msg.list_failed": "Failed to load callbacks",
|
||||
"callbacks.confirm.delete": "Are you sure you want to delete the callback \"{name}\"?",
|
||||
"callbacks.confirm.unsaved": "You have unsaved changes. Are you sure you want to discard them?",
|
||||
"tab.player": "Player",
|
||||
"tab.browser": "Browser",
|
||||
"tab.player": "Now Spinning",
|
||||
"tab.browser": "Library",
|
||||
"tab.quick_access": "Quick Access",
|
||||
"tab.settings": "Settings",
|
||||
"tab.display": "Display",
|
||||
@@ -150,6 +161,31 @@
|
||||
"display.power_on": "Turn on",
|
||||
"display.power_off": "Turn off",
|
||||
"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.home": "Home",
|
||||
"browser.manage_folders": "Manage Folders",
|
||||
@@ -248,8 +284,13 @@
|
||||
"links.msg.load_failed": "Failed to load link details",
|
||||
"links.confirm.delete": "Are you sure you want to delete the link \"{name}\"?",
|
||||
"links.confirm.unsaved": "You have unsaved changes. Are you sure you want to discard them?",
|
||||
"footer.created_by": "Created by",
|
||||
"footer.source_code": "Source Code",
|
||||
"about.button_title": "About",
|
||||
"about.title": "About",
|
||||
"about.created_by": "Created by",
|
||||
"about.email": "Email",
|
||||
"about.repository": "Repository",
|
||||
"about.source_code": "Source Code",
|
||||
"dialog.close": "Close",
|
||||
"update.available": "Update available: v{version}",
|
||||
"update.view_release": "View Release"
|
||||
}
|
||||
|
||||
@@ -19,12 +19,23 @@
|
||||
"player.status.connected": "Подключено",
|
||||
"player.status.disconnected": "Отключено",
|
||||
"player.no_media": "Медиа не воспроизводится",
|
||||
"player.kicker": "Сейчас играет",
|
||||
"player.modes": "Режимы",
|
||||
"header.connected": "Подключено",
|
||||
"header.volume": "Том I",
|
||||
"header.edition": "Studio Reference",
|
||||
"header.edition_sub": "Studio Reference Edition",
|
||||
"meta.state": "Состояние",
|
||||
"meta.source": "Источник",
|
||||
"player.title_unavailable": "Название недоступно",
|
||||
"player.source": "Источник:",
|
||||
"player.unknown_source": "Неизвестно",
|
||||
"player.vinyl": "Режим винила",
|
||||
"player.visualizer": "Аудио визуализатор",
|
||||
"player.background": "Динамический фон",
|
||||
"player.fullscreen": "Полноэкранный режим",
|
||||
"player.fullscreen.exit": "Выйти из полного экрана",
|
||||
"player.fullscreen.exit_short": "Выйти",
|
||||
"state.playing": "Воспроизведение",
|
||||
"state.paused": "Пауза",
|
||||
"state.stopped": "Остановлено",
|
||||
@@ -126,8 +137,8 @@
|
||||
"callbacks.msg.list_failed": "Не удалось загрузить обратные вызовы",
|
||||
"callbacks.confirm.delete": "Вы уверены, что хотите удалить обратный вызов \"{name}\"?",
|
||||
"callbacks.confirm.unsaved": "У вас есть несохраненные изменения. Вы уверены, что хотите отменить их?",
|
||||
"tab.player": "Плеер",
|
||||
"tab.browser": "Браузер",
|
||||
"tab.player": "Сейчас играет",
|
||||
"tab.browser": "Библиотека",
|
||||
"tab.quick_access": "Быстрый Доступ",
|
||||
"tab.settings": "Настройки",
|
||||
"tab.display": "Дисплей",
|
||||
@@ -150,6 +161,31 @@
|
||||
"display.power_on": "Включить",
|
||||
"display.power_off": "Выключить",
|
||||
"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.home": "Главная",
|
||||
"browser.manage_folders": "Управление папками",
|
||||
@@ -248,8 +284,13 @@
|
||||
"links.msg.load_failed": "Не удалось загрузить данные ссылки",
|
||||
"links.confirm.delete": "Вы уверены, что хотите удалить ссылку \"{name}\"?",
|
||||
"links.confirm.unsaved": "У вас есть несохраненные изменения. Вы уверены, что хотите отменить их?",
|
||||
"footer.created_by": "Создано",
|
||||
"footer.source_code": "Исходный код",
|
||||
"about.button_title": "О программе",
|
||||
"about.title": "О программе",
|
||||
"about.created_by": "Создано",
|
||||
"about.email": "Эл. почта",
|
||||
"about.repository": "Репозиторий",
|
||||
"about.source_code": "Исходный код",
|
||||
"dialog.close": "Закрыть",
|
||||
"update.available": "Доступно обновление: v{version}",
|
||||
"update.view_release": "Перейти к релизу"
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,8 @@
|
||||
// Minimal service worker for PWA installability.
|
||||
// Minimal service worker for PWA installability only.
|
||||
// This app requires a live WebSocket connection, so offline caching is not useful.
|
||||
// All fetch requests are passed through to the network.
|
||||
// We intentionally do NOT register a `fetch` handler — a pass-through handler
|
||||
// forces every navigation through the SW for no benefit and breaks the
|
||||
// browser's normal HTTP cache + error semantics.
|
||||
|
||||
self.addEventListener('install', () => {
|
||||
self.skipWaiting();
|
||||
@@ -9,7 +11,3 @@ self.addEventListener('install', () => {
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
event.respondWith(fetch(event.request));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,911 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vinyl Variants · Studio Reference</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/icons/icon.svg">
|
||||
|
||||
<style>
|
||||
/* ───────── Local fonts (re-using main app's woff2 files) ───── */
|
||||
@font-face {
|
||||
font-family: 'Fraunces';
|
||||
font-style: italic;
|
||||
font-weight: 300 900;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/Fraunces-italic-latin.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Fraunces';
|
||||
font-style: normal;
|
||||
font-weight: 300 900;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/Fraunces-latin.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Geist';
|
||||
font-style: normal;
|
||||
font-weight: 300 700;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/Geist-latin.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Geist Mono';
|
||||
font-style: normal;
|
||||
font-weight: 300 600;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/GeistMono-latin.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* ───────── Tokens (Studio Reference, dark) ───── */
|
||||
:root {
|
||||
--bg-deep: #0E0D0B;
|
||||
--bg-paper: #18150F;
|
||||
--bg-card: #211E18;
|
||||
--bg-card-2: #26211A;
|
||||
--bg-rule: #2E2820;
|
||||
--ink: #F2EBDC;
|
||||
--ink-soft: #D6CDB9;
|
||||
--ink-mute: #9C937F;
|
||||
--ink-faint: #5C5447;
|
||||
--ink-ghost: #3A3528;
|
||||
--copper: #E08038;
|
||||
--copper-hi: #F4A064;
|
||||
--copper-lo: #B0561F;
|
||||
--copper-glow: rgba(224, 128, 56, 0.35);
|
||||
--rule: rgba(242, 235, 220, 0.08);
|
||||
--rule-strong: rgba(242, 235, 220, 0.18);
|
||||
--serif: 'Fraunces', Georgia, serif;
|
||||
--sans: 'Geist', system-ui, sans-serif;
|
||||
--mono: 'Geist Mono', ui-monospace, monospace;
|
||||
--ease: cubic-bezier(.2, .7, .2, 1);
|
||||
--ease-out: cubic-bezier(.16, 1, .3, 1);
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
html { background: var(--bg-deep); }
|
||||
body {
|
||||
font-family: var(--sans);
|
||||
background: var(--bg-deep);
|
||||
color: var(--ink);
|
||||
min-height: 100vh;
|
||||
padding: 56px 36px 80px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
/* Film grain */
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
opacity: 0.05;
|
||||
mix-blend-mode: overlay;
|
||||
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.95 0 0 0 0 0.92 0 0 0 0 0.86 0 0 0 0.7 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* ───────── Page header (editorial) ───── */
|
||||
header.page-head {
|
||||
max-width: 1320px;
|
||||
margin: 0 auto 48px;
|
||||
text-align: center;
|
||||
}
|
||||
.kicker {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.32em;
|
||||
text-transform: uppercase;
|
||||
color: var(--copper);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
.kicker::before, .kicker::after {
|
||||
content: "";
|
||||
height: 1px;
|
||||
width: 40px;
|
||||
background: var(--copper);
|
||||
opacity: 0.6;
|
||||
}
|
||||
h1 {
|
||||
font-family: var(--serif);
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-size: clamp(36px, 5vw, 56px);
|
||||
line-height: 1;
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 14px;
|
||||
font-variation-settings: 'opsz' 144;
|
||||
}
|
||||
.subtitle {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-mute);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.return-link {
|
||||
display: inline-block;
|
||||
margin-top: 24px;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-faint);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid var(--ink-faint);
|
||||
padding-bottom: 2px;
|
||||
transition: all 200ms var(--ease);
|
||||
}
|
||||
.return-link:hover { color: var(--copper); border-color: var(--copper); }
|
||||
|
||||
/* ───────── Variant grid ───── */
|
||||
.grid {
|
||||
max-width: 1320px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
|
||||
gap: 56px 40px;
|
||||
}
|
||||
|
||||
article.variant {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.stage {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
width: 100%;
|
||||
background:
|
||||
radial-gradient(ellipse at center, var(--bg-card-2) 0%, var(--bg-deep) 80%);
|
||||
border: 1px solid var(--rule);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: visible;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.label-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.label-num {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.2em;
|
||||
color: var(--copper);
|
||||
}
|
||||
.label-name {
|
||||
font-family: var(--serif);
|
||||
font-style: italic;
|
||||
font-size: 22px;
|
||||
font-weight: 400;
|
||||
font-variation-settings: 'opsz' 60;
|
||||
flex: 1;
|
||||
}
|
||||
.label-tag {
|
||||
font-family: var(--mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-faint);
|
||||
padding: 3px 8px;
|
||||
border: 1px solid var(--rule-strong);
|
||||
}
|
||||
.tag-css { color: var(--jade, #7AB294); border-color: rgba(122, 178, 148, 0.3); }
|
||||
.tag-needs-js { color: var(--copper); border-color: var(--copper-lo); }
|
||||
|
||||
p.descr {
|
||||
font-family: var(--sans);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
p.descr strong {
|
||||
color: var(--ink);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ───────── Shared vinyl base ───── */
|
||||
.vinyl {
|
||||
position: relative;
|
||||
width: 86%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
background:
|
||||
radial-gradient(circle at 50% 50%,
|
||||
#0a0907 0%, #0a0907 18%,
|
||||
#1a1611 18.3%, #0a0907 18.6%,
|
||||
#14110c 22%, #0a0907 22.3%,
|
||||
#14110c 26%, #0a0907 26.3%,
|
||||
#14110c 30%, #0a0907 30.3%,
|
||||
#14110c 34%, #0a0907 34.3%,
|
||||
#14110c 38%, #0a0907 38.3%,
|
||||
#14110c 42%, #0a0907 42.3%,
|
||||
#14110c 46%, #0a0907 46.3%,
|
||||
#1c1812 47%, #0a0907 100%);
|
||||
box-shadow:
|
||||
inset 0 0 60px rgba(0, 0, 0, 0.7),
|
||||
0 30px 80px rgba(0, 0, 0, 0.6),
|
||||
0 6px 20px rgba(0, 0, 0, 0.5);
|
||||
animation: spin 14s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.vinyl::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 12%;
|
||||
border-radius: 50%;
|
||||
background:
|
||||
conic-gradient(from 0deg,
|
||||
rgba(255,255,255,0.04) 0deg,
|
||||
transparent 30deg,
|
||||
rgba(255,255,255,0.06) 90deg,
|
||||
transparent 150deg,
|
||||
rgba(255,255,255,0.03) 210deg,
|
||||
transparent 270deg,
|
||||
rgba(255,255,255,0.05) 330deg,
|
||||
transparent 360deg);
|
||||
mix-blend-mode: screen;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.vinyl-label {
|
||||
position: absolute;
|
||||
inset: 28%;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
inset 0 0 24px rgba(0, 0, 0, 0.4),
|
||||
0 0 0 4px var(--bg-deep),
|
||||
0 0 0 5px var(--copper-lo);
|
||||
background: var(--bg-card);
|
||||
z-index: 1;
|
||||
}
|
||||
.vinyl-label::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 8%; height: 8%;
|
||||
top: 46%; left: 46%;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-deep);
|
||||
box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.1);
|
||||
z-index: 3;
|
||||
}
|
||||
.vinyl-label img,
|
||||
.vinyl-label svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Album art (shared SVG used by every variant) */
|
||||
.album-art {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Tonearm (decorative, on every stage so they read as "now playing") */
|
||||
.tonearm {
|
||||
position: absolute;
|
||||
top: -4%;
|
||||
right: -2%;
|
||||
width: 50%;
|
||||
height: 50%;
|
||||
pointer-events: none;
|
||||
transform-origin: 88% 12%;
|
||||
transform: rotate(0deg);
|
||||
z-index: 5;
|
||||
filter: drop-shadow(0 4px 12px rgba(0,0,0,0.5));
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
ORIGINAL — current shipping look (control)
|
||||
════════════════════════════════════════════════════════════════ */
|
||||
.v0 .stage { /* nothing extra */ }
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
VARIANT 1 — Sleeve frame
|
||||
Vinyl peeks out of a square cardstock sleeve.
|
||||
════════════════════════════════════════════════════════════════ */
|
||||
.v1 .stage {
|
||||
background:
|
||||
radial-gradient(ellipse at center, #1a1611 0%, var(--bg-deep) 80%);
|
||||
}
|
||||
.v1 .sleeve-stage {
|
||||
position: relative;
|
||||
width: 90%;
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.v1 .sleeve {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 6%;
|
||||
width: 70%;
|
||||
aspect-ratio: 1;
|
||||
background: var(--bg-card-2);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(0,0,0,0.4),
|
||||
inset 4px 4px 24px rgba(0,0,0,0.35),
|
||||
-2px 8px 24px rgba(0,0,0,0.5),
|
||||
-4px 16px 40px rgba(0,0,0,0.35);
|
||||
z-index: 3;
|
||||
/* Casually-placed tilt — like a sleeve set down on a console */
|
||||
transform: rotate(-3.2deg);
|
||||
transform-origin: 60% 60%;
|
||||
/* worn-edge cardstock effect */
|
||||
filter: contrast(1.05) brightness(0.97);
|
||||
}
|
||||
.v1 .sleeve::before {
|
||||
/* Cardstock paper grain */
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.2' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.10 0 0 0 0 0.08 0 0 0 0 0.06 0 0 0 0.7 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
mix-blend-mode: multiply;
|
||||
pointer-events: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.v1 .sleeve::after {
|
||||
/* Ring-wear: faint circle from the LP rubbing the cardstock */
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 6%;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(0,0,0,0.25);
|
||||
box-shadow:
|
||||
inset 0 0 12px rgba(0,0,0,0.18),
|
||||
inset 0 0 0 1px rgba(255,255,255,0.04);
|
||||
pointer-events: none;
|
||||
}
|
||||
.v1 .sleeve-art {
|
||||
position: absolute;
|
||||
inset: 6%;
|
||||
z-index: 1;
|
||||
filter: contrast(0.88) saturate(0.6) brightness(0.88);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.v1 .sleeve-art svg { width: 100%; height: 100%; }
|
||||
/* Worn corner notch */
|
||||
.v1 .sleeve-corner {
|
||||
position: absolute;
|
||||
width: 14%;
|
||||
height: 14%;
|
||||
bottom: -1px;
|
||||
right: -1px;
|
||||
background: var(--bg-deep);
|
||||
clip-path: polygon(100% 0, 100% 100%, 0 100%);
|
||||
opacity: 0.7;
|
||||
z-index: 4;
|
||||
}
|
||||
.v1 .vinyl-wrap {
|
||||
position: absolute;
|
||||
right: -2%;
|
||||
top: 16%;
|
||||
width: 70%;
|
||||
aspect-ratio: 1;
|
||||
z-index: 2;
|
||||
}
|
||||
.v1 .vinyl-wrap .vinyl {
|
||||
width: 100%;
|
||||
}
|
||||
.v1 .vinyl-label {
|
||||
/* Smaller label since the disc here is showing; album art lives on sleeve */
|
||||
inset: 32%;
|
||||
background: #2E2820;
|
||||
box-shadow:
|
||||
inset 0 0 18px rgba(0,0,0,0.4),
|
||||
0 0 0 3px var(--bg-deep),
|
||||
0 0 0 4px var(--copper-lo);
|
||||
}
|
||||
.v1 .vinyl-label::before {
|
||||
/* Plain-color label with faux pressing imprint */
|
||||
content: "REF · 24";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.3em;
|
||||
color: var(--copper);
|
||||
z-index: 2;
|
||||
}
|
||||
.v1 .tonearm {
|
||||
right: -8%;
|
||||
top: 8%;
|
||||
width: 44%;
|
||||
height: 44%;
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
VARIANT 2 — Sheen + paper grain + dead-wax + off-center
|
||||
The high-impact variant.
|
||||
════════════════════════════════════════════════════════════════ */
|
||||
.v2 .vinyl-label {
|
||||
/* Slightly off-center spindle for "pressed off-axis" feel */
|
||||
inset: 27% 27% 29% 29%;
|
||||
}
|
||||
.v2 .vinyl-label::after {
|
||||
/* Spindle hole offset 1.5% from true center */
|
||||
top: 47%;
|
||||
left: 47.5%;
|
||||
}
|
||||
/* Paper grain on the label, multiplied so it sits inside the print */
|
||||
.v2 .vinyl-label .label-grain {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.6' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.05 0 0 0 0 0.04 0 0 0 0 0.03 0 0 0 0.55 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
mix-blend-mode: multiply;
|
||||
pointer-events: none;
|
||||
z-index: 4;
|
||||
}
|
||||
/* Dead-wax: micro-text engraved between the label and the run-out groove */
|
||||
.v2 .dead-wax {
|
||||
position: absolute;
|
||||
inset: 21%;
|
||||
border-radius: 50%;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
/* Animation OFF the disc — engraving is part of the press, so it does spin with the vinyl */
|
||||
animation: spin 14s linear infinite;
|
||||
}
|
||||
.v2 .dead-wax svg { width: 100%; height: 100%; }
|
||||
/* Reflection sweep — fixed in viewer space, not rotating with the disc */
|
||||
.v2 .sheen {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
background:
|
||||
conic-gradient(from 110deg,
|
||||
transparent 0deg,
|
||||
rgba(255, 245, 220, 0) 30deg,
|
||||
rgba(255, 245, 220, 0.07) 60deg,
|
||||
rgba(255, 245, 220, 0.14) 80deg,
|
||||
rgba(255, 245, 220, 0.07) 100deg,
|
||||
transparent 140deg,
|
||||
transparent 280deg,
|
||||
rgba(255, 245, 220, 0.04) 305deg,
|
||||
rgba(255, 245, 220, 0.08) 320deg,
|
||||
rgba(255, 245, 220, 0.04) 335deg,
|
||||
transparent 360deg);
|
||||
mix-blend-mode: screen;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
VARIANT 3 — Tone-graded album art (duotone)
|
||||
════════════════════════════════════════════════════════════════ */
|
||||
.v3 .vinyl-label .album-art {
|
||||
filter:
|
||||
saturate(0.35)
|
||||
sepia(0.45)
|
||||
hue-rotate(345deg)
|
||||
brightness(0.85)
|
||||
contrast(1.18);
|
||||
}
|
||||
.v3 .vinyl-label::before {
|
||||
/* Subtle copper duotone overlay tints the highlights */
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(135deg,
|
||||
rgba(224, 128, 56, 0.18) 0%,
|
||||
rgba(31, 78, 61, 0.10) 50%,
|
||||
rgba(0,0,0,0.18) 100%);
|
||||
mix-blend-mode: overlay;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
.v3 .vinyl-label::after {
|
||||
z-index: 4;
|
||||
}
|
||||
.v3 .vinyl-label .vignette {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(circle at 50% 45%,
|
||||
transparent 35%,
|
||||
rgba(0,0,0,0.45) 100%);
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
VARIANT 4 — Sleeve-to-disc reveal animation
|
||||
(Hover the card to see the disc slide out)
|
||||
════════════════════════════════════════════════════════════════ */
|
||||
.v4 .sleeve-stage {
|
||||
position: relative;
|
||||
width: 90%;
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.v4 .sleeve {
|
||||
position: absolute;
|
||||
left: 14%;
|
||||
top: 12%;
|
||||
width: 72%;
|
||||
aspect-ratio: 1;
|
||||
background: var(--bg-card-2);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(0,0,0,0.4),
|
||||
-2px 6px 18px rgba(0,0,0,0.5);
|
||||
z-index: 4;
|
||||
overflow: hidden;
|
||||
}
|
||||
.v4 .sleeve::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.2' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.10 0 0 0 0 0.08 0 0 0 0 0.06 0 0 0 0.5 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
mix-blend-mode: multiply;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
.v4 .sleeve-art {
|
||||
width: 100%; height: 100%;
|
||||
filter: contrast(0.92) saturate(0.7) brightness(0.92);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.v4 .vinyl-slot {
|
||||
position: absolute;
|
||||
left: 14%;
|
||||
top: 12%;
|
||||
width: 72%;
|
||||
aspect-ratio: 1;
|
||||
z-index: 3;
|
||||
transition: transform 1.2s var(--ease-out);
|
||||
}
|
||||
.v4 .vinyl-slot .vinyl {
|
||||
width: 100%;
|
||||
animation-play-state: paused;
|
||||
transition: animation-play-state 0.4s;
|
||||
}
|
||||
.v4 .stage:hover .vinyl-slot {
|
||||
transform: translateX(46%);
|
||||
}
|
||||
.v4 .stage:hover .vinyl-slot .vinyl {
|
||||
animation-play-state: running;
|
||||
}
|
||||
.v4 .hover-hint {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-family: var(--mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.24em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-faint);
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
.v4 .stage:hover .hover-hint { opacity: 0.4; }
|
||||
|
||||
/* Note row at top of every variant */
|
||||
.note {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 14px;
|
||||
font-family: var(--mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-faint);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* ───────── Mobile ───── */
|
||||
@media (max-width: 720px) {
|
||||
body { padding: 36px 16px 60px; }
|
||||
.grid { gap: 36px 20px; grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="page-head">
|
||||
<div class="kicker">Studio Reference · Album Art Variants</div>
|
||||
<h1>Vinyl Cover Treatments</h1>
|
||||
<p class="subtitle">Five renderings of the same disc · Hover variant 04 for the sleeve reveal</p>
|
||||
<a class="return-link" href="/">← Return to player</a>
|
||||
</header>
|
||||
|
||||
<div class="grid">
|
||||
|
||||
<!-- ═════════ ORIGINAL ═════════ -->
|
||||
<article class="variant v0">
|
||||
<div class="stage">
|
||||
<span class="note">As shipping</span>
|
||||
<div class="vinyl">
|
||||
<div class="vinyl-label">
|
||||
<svg viewBox="0 0 400 400" class="album-art" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bgA" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#1F4E3D"/>
|
||||
<stop offset="55%" stop-color="#143E2F"/>
|
||||
<stop offset="100%" stop-color="#0a2519"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="vigA" cx="50%" cy="50%" r="70%">
|
||||
<stop offset="55%" stop-color="rgba(0,0,0,0)"/>
|
||||
<stop offset="100%" stop-color="rgba(0,0,0,0.55)"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect width="400" height="400" fill="url(#bgA)"/>
|
||||
<g stroke="#e08038" stroke-width="1" fill="none" opacity="0.55">
|
||||
<circle cx="200" cy="200" r="60"/>
|
||||
<circle cx="200" cy="200" r="100"/>
|
||||
<circle cx="200" cy="200" r="140"/>
|
||||
<circle cx="200" cy="200" r="180"/>
|
||||
</g>
|
||||
<text x="200" y="195" text-anchor="middle" font-family="serif" font-style="italic" fill="#f2ebdc" font-size="34">Reference</text>
|
||||
<text x="200" y="225" text-anchor="middle" font-family="monospace" fill="#e08038" font-size="11" letter-spacing="4">VOL · I</text>
|
||||
<text x="200" y="368" text-anchor="middle" font-family="monospace" fill="#9c937f" font-size="9" letter-spacing="6" opacity="0.6">STUDIO PRESSING</text>
|
||||
<rect width="400" height="400" fill="url(#vigA)"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<svg class="tonearm" viewBox="0 0 200 200" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="armGrad0" x1="0" x2="1">
|
||||
<stop offset="0" stop-color="#3a3528"/>
|
||||
<stop offset="0.5" stop-color="#9C937F"/>
|
||||
<stop offset="1" stop-color="#5C5447"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="176" cy="24" r="14" fill="#1a1611" stroke="#3A3528" stroke-width="1"/>
|
||||
<circle cx="176" cy="24" r="6" fill="#3A3528"/>
|
||||
<circle cx="176" cy="24" r="2" fill="#E08038"/>
|
||||
<line x1="176" y1="24" x2="64" y2="136" stroke="url(#armGrad0)" stroke-width="3.5" stroke-linecap="round"/>
|
||||
<rect x="180" y="14" width="14" height="20" fill="#26211A" stroke="#3A3528"/>
|
||||
<rect x="56" y="128" width="22" height="18" rx="2" fill="#26211A" stroke="#3A3528" transform="rotate(-45 67 137)"/>
|
||||
<circle cx="62" cy="138" r="3" fill="#E08038" opacity="0.8"/>
|
||||
<circle cx="62" cy="138" r="6" fill="none" stroke="#E08038" stroke-width="0.5" opacity="0.4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="label-row">
|
||||
<span class="label-num">00</span>
|
||||
<span class="label-name">Original</span>
|
||||
<span class="label-tag tag-css">control</span>
|
||||
</div>
|
||||
<p class="descr">Current shipping vinyl: pressed grooves, copper-bordered label rim, full album art on the label. Reference baseline for everything below.</p>
|
||||
</article>
|
||||
|
||||
<!-- ═════════ VARIANT 1 — SLEEVE FRAME ═════════ -->
|
||||
<article class="variant v1">
|
||||
<div class="stage">
|
||||
<span class="note">CSS only</span>
|
||||
<div class="sleeve-stage">
|
||||
<div class="sleeve">
|
||||
<div class="sleeve-art">
|
||||
<svg viewBox="0 0 400 400" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bgB" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#1F4E3D"/>
|
||||
<stop offset="55%" stop-color="#143E2F"/>
|
||||
<stop offset="100%" stop-color="#0a2519"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="400" height="400" fill="url(#bgB)"/>
|
||||
<g stroke="#e08038" stroke-width="1" fill="none" opacity="0.55">
|
||||
<circle cx="200" cy="200" r="60"/>
|
||||
<circle cx="200" cy="200" r="100"/>
|
||||
<circle cx="200" cy="200" r="140"/>
|
||||
<circle cx="200" cy="200" r="180"/>
|
||||
</g>
|
||||
<text x="200" y="195" text-anchor="middle" font-family="serif" font-style="italic" fill="#f2ebdc" font-size="34">Reference</text>
|
||||
<text x="200" y="225" text-anchor="middle" font-family="monospace" fill="#e08038" font-size="11" letter-spacing="4">VOL · I</text>
|
||||
<text x="200" y="368" text-anchor="middle" font-family="monospace" fill="#9c937f" font-size="9" letter-spacing="6" opacity="0.6">STUDIO PRESSING</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="sleeve-corner"></div>
|
||||
</div>
|
||||
<div class="vinyl-wrap">
|
||||
<div class="vinyl">
|
||||
<div class="vinyl-label"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<svg class="tonearm" viewBox="0 0 200 200" aria-hidden="true">
|
||||
<use href="#armGrad0"/>
|
||||
<circle cx="176" cy="24" r="14" fill="#1a1611" stroke="#3A3528" stroke-width="1"/>
|
||||
<circle cx="176" cy="24" r="6" fill="#3A3528"/>
|
||||
<circle cx="176" cy="24" r="2" fill="#E08038"/>
|
||||
<line x1="176" y1="24" x2="80" y2="120" stroke="#9C937F" stroke-width="3.5" stroke-linecap="round"/>
|
||||
<rect x="180" y="14" width="14" height="20" fill="#26211A" stroke="#3A3528"/>
|
||||
<rect x="72" y="112" width="22" height="18" rx="2" fill="#26211A" stroke="#3A3528" transform="rotate(-45 83 121)"/>
|
||||
<circle cx="78" cy="122" r="3" fill="#E08038" opacity="0.8"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="label-row">
|
||||
<span class="label-num">01</span>
|
||||
<span class="label-name">Sleeve Frame</span>
|
||||
<span class="label-tag tag-css">CSS only</span>
|
||||
</div>
|
||||
<p class="descr">Vinyl peeks out of a square cardstock <strong>sleeve</strong> — paper grain, ring-wear circle, worn-corner notch. The album art lives on the sleeve; the disc gets a plain typographic label. Reads instantly as "record on a turntable", not "spinning disc."</p>
|
||||
</article>
|
||||
|
||||
<!-- ═════════ VARIANT 2 — SHEEN + GRAIN + DEAD-WAX ═════════ -->
|
||||
<article class="variant v2">
|
||||
<div class="stage">
|
||||
<span class="note">CSS only · highest ROI</span>
|
||||
<div class="vinyl">
|
||||
<div class="dead-wax">
|
||||
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<path id="dwPath" d="M 50,50 m -36,0 a 36,36 0 1,1 72,0 a 36,36 0 1,1 -72,0"/>
|
||||
</defs>
|
||||
<text font-family="monospace" font-size="2.4" fill="#3a3528" letter-spacing="0.45" opacity="0.85">
|
||||
<textPath href="#dwPath">· STUDIO REFERENCE PRESSING · A-SIDE · MASTER LACQUER 24-S · DOLG.AD MASTERED · ½ SPEED</textPath>
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="vinyl-label">
|
||||
<svg viewBox="0 0 400 400" class="album-art" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bgC" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#1F4E3D"/>
|
||||
<stop offset="55%" stop-color="#143E2F"/>
|
||||
<stop offset="100%" stop-color="#0a2519"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="400" height="400" fill="url(#bgC)"/>
|
||||
<g stroke="#e08038" stroke-width="1" fill="none" opacity="0.55">
|
||||
<circle cx="200" cy="200" r="60"/>
|
||||
<circle cx="200" cy="200" r="100"/>
|
||||
<circle cx="200" cy="200" r="140"/>
|
||||
<circle cx="200" cy="200" r="180"/>
|
||||
</g>
|
||||
<text x="200" y="195" text-anchor="middle" font-family="serif" font-style="italic" fill="#f2ebdc" font-size="34">Reference</text>
|
||||
<text x="200" y="225" text-anchor="middle" font-family="monospace" fill="#e08038" font-size="11" letter-spacing="4">VOL · I</text>
|
||||
<text x="200" y="368" text-anchor="middle" font-family="monospace" fill="#9c937f" font-size="9" letter-spacing="6" opacity="0.6">STUDIO PRESSING</text>
|
||||
</svg>
|
||||
<div class="label-grain"></div>
|
||||
</div>
|
||||
<div class="sheen"></div>
|
||||
</div>
|
||||
<svg class="tonearm" viewBox="0 0 200 200" aria-hidden="true">
|
||||
<circle cx="176" cy="24" r="14" fill="#1a1611" stroke="#3A3528" stroke-width="1"/>
|
||||
<circle cx="176" cy="24" r="6" fill="#3A3528"/>
|
||||
<circle cx="176" cy="24" r="2" fill="#E08038"/>
|
||||
<line x1="176" y1="24" x2="64" y2="136" stroke="#9C937F" stroke-width="3.5" stroke-linecap="round"/>
|
||||
<rect x="180" y="14" width="14" height="20" fill="#26211A" stroke="#3A3528"/>
|
||||
<rect x="56" y="128" width="22" height="18" rx="2" fill="#26211A" stroke="#3A3528" transform="rotate(-45 67 137)"/>
|
||||
<circle cx="62" cy="138" r="3" fill="#E08038" opacity="0.8"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="label-row">
|
||||
<span class="label-num">02</span>
|
||||
<span class="label-name">Sheen, Grain & Dead-Wax</span>
|
||||
<span class="label-tag tag-css">CSS only</span>
|
||||
</div>
|
||||
<p class="descr">Three layers added to the existing vinyl: a <strong>fixed reflection sweep</strong> (doesn't rotate with the disc — the studio-light look), <strong>paper grain</strong> on the label so the print sits in cardstock, and a <strong>dead-wax engraving</strong> of the master‑lacquer code spinning with the disc. Off-center spindle by 1.5%. Highest visual ROI for the smallest amount of new code.</p>
|
||||
</article>
|
||||
|
||||
<!-- ═════════ VARIANT 3 — TONE-GRADED ═════════ -->
|
||||
<article class="variant v3">
|
||||
<div class="stage">
|
||||
<span class="note">CSS only</span>
|
||||
<div class="vinyl">
|
||||
<div class="vinyl-label">
|
||||
<svg viewBox="0 0 400 400" class="album-art" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bgD" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#1F4E3D"/>
|
||||
<stop offset="55%" stop-color="#143E2F"/>
|
||||
<stop offset="100%" stop-color="#0a2519"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="400" height="400" fill="url(#bgD)"/>
|
||||
<g stroke="#e08038" stroke-width="1" fill="none" opacity="0.55">
|
||||
<circle cx="200" cy="200" r="60"/>
|
||||
<circle cx="200" cy="200" r="100"/>
|
||||
<circle cx="200" cy="200" r="140"/>
|
||||
<circle cx="200" cy="200" r="180"/>
|
||||
</g>
|
||||
<text x="200" y="195" text-anchor="middle" font-family="serif" font-style="italic" fill="#f2ebdc" font-size="34">Reference</text>
|
||||
<text x="200" y="225" text-anchor="middle" font-family="monospace" fill="#e08038" font-size="11" letter-spacing="4">VOL · I</text>
|
||||
<text x="200" y="368" text-anchor="middle" font-family="monospace" fill="#9c937f" font-size="9" letter-spacing="6" opacity="0.6">STUDIO PRESSING</text>
|
||||
</svg>
|
||||
<div class="vignette"></div>
|
||||
</div>
|
||||
</div>
|
||||
<svg class="tonearm" viewBox="0 0 200 200" aria-hidden="true">
|
||||
<circle cx="176" cy="24" r="14" fill="#1a1611" stroke="#3A3528" stroke-width="1"/>
|
||||
<circle cx="176" cy="24" r="6" fill="#3A3528"/>
|
||||
<circle cx="176" cy="24" r="2" fill="#E08038"/>
|
||||
<line x1="176" y1="24" x2="64" y2="136" stroke="#9C937F" stroke-width="3.5" stroke-linecap="round"/>
|
||||
<rect x="180" y="14" width="14" height="20" fill="#26211A" stroke="#3A3528"/>
|
||||
<rect x="56" y="128" width="22" height="18" rx="2" fill="#26211A" stroke="#3A3528" transform="rotate(-45 67 137)"/>
|
||||
<circle cx="62" cy="138" r="3" fill="#E08038" opacity="0.8"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="label-row">
|
||||
<span class="label-num">03</span>
|
||||
<span class="label-name">Tone-Graded Cover</span>
|
||||
<span class="label-tag tag-css">CSS only</span>
|
||||
</div>
|
||||
<p class="descr">Same disc, but the album art on the label is <strong>color-graded</strong> — duotone copper/emerald, deeper saturation drop, vignette around the label rim. Effect: every album cover ends up looking like it came from the same pressing plant, matching the Studio Reference chrome.</p>
|
||||
</article>
|
||||
|
||||
<!-- ═════════ VARIANT 4 — SLEEVE-TO-DISC REVEAL ═════════ -->
|
||||
<article class="variant v4">
|
||||
<div class="stage">
|
||||
<span class="note">CSS hover · JS in production</span>
|
||||
<div class="sleeve-stage">
|
||||
<div class="sleeve">
|
||||
<div class="sleeve-art">
|
||||
<svg viewBox="0 0 400 400" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bgE" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#1F4E3D"/>
|
||||
<stop offset="55%" stop-color="#143E2F"/>
|
||||
<stop offset="100%" stop-color="#0a2519"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="400" height="400" fill="url(#bgE)"/>
|
||||
<g stroke="#e08038" stroke-width="1" fill="none" opacity="0.55">
|
||||
<circle cx="200" cy="200" r="60"/>
|
||||
<circle cx="200" cy="200" r="100"/>
|
||||
<circle cx="200" cy="200" r="140"/>
|
||||
<circle cx="200" cy="200" r="180"/>
|
||||
</g>
|
||||
<text x="200" y="195" text-anchor="middle" font-family="serif" font-style="italic" fill="#f2ebdc" font-size="34">Reference</text>
|
||||
<text x="200" y="225" text-anchor="middle" font-family="monospace" fill="#e08038" font-size="11" letter-spacing="4">VOL · I</text>
|
||||
<text x="200" y="368" text-anchor="middle" font-family="monospace" fill="#9c937f" font-size="9" letter-spacing="6" opacity="0.6">STUDIO PRESSING</text>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vinyl-slot">
|
||||
<div class="vinyl">
|
||||
<div class="vinyl-label"></div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="hover-hint">Hover to play</span>
|
||||
</div>
|
||||
<svg class="tonearm" viewBox="0 0 200 200" aria-hidden="true">
|
||||
<circle cx="176" cy="24" r="14" fill="#1a1611" stroke="#3A3528" stroke-width="1"/>
|
||||
<circle cx="176" cy="24" r="6" fill="#3A3528"/>
|
||||
<circle cx="176" cy="24" r="2" fill="#E08038"/>
|
||||
<line x1="176" y1="24" x2="64" y2="136" stroke="#9C937F" stroke-width="3.5" stroke-linecap="round"/>
|
||||
<rect x="180" y="14" width="14" height="20" fill="#26211A" stroke="#3A3528"/>
|
||||
<rect x="56" y="128" width="22" height="18" rx="2" fill="#26211A" stroke="#3A3528" transform="rotate(-45 67 137)"/>
|
||||
<circle cx="62" cy="138" r="3" fill="#E08038" opacity="0.8"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="label-row">
|
||||
<span class="label-num">04</span>
|
||||
<span class="label-name">Sleeve-to-Disc Reveal</span>
|
||||
<span class="label-tag tag-needs-js">needs JS</span>
|
||||
</div>
|
||||
<p class="descr"><strong>Hover this card</strong> — the disc slides out of the sleeve and starts spinning. In production, this would be wired to the play/pause state: paused = tucked-in sleeve view, playing = disc revealed and spinning. Most evocative, also the most code (animation choreography + state coupling).</p>
|
||||
</article>
|
||||
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "media-server-frontend",
|
||||
"version": "0.1.5",
|
||||
"version": "0.2.5",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "media-server-frontend",
|
||||
"version": "0.1.5",
|
||||
"version": "0.2.5",
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.27.4"
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "media-server-frontend",
|
||||
"version": "0.1.5",
|
||||
"version": "0.2.5",
|
||||
"private": true,
|
||||
"description": "Frontend build tooling for media server WebUI",
|
||||
"scripts": {
|
||||
|
||||
+4
-5
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "media-server"
|
||||
version = "0.1.5"
|
||||
version = "0.2.5"
|
||||
description = "REST API server for controlling system-wide media playback"
|
||||
readme = "README.md"
|
||||
license = { text = "MIT" }
|
||||
@@ -32,6 +32,8 @@ dependencies = [
|
||||
"pyyaml>=6.0",
|
||||
"mutagen>=1.47.0",
|
||||
"pillow>=10.0.0",
|
||||
"soundcard>=0.4.0",
|
||||
"numpy>=1.24.0,<2.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -41,13 +43,10 @@ windows = [
|
||||
"comtypes>=1.2.0",
|
||||
"pycaw>=20230407",
|
||||
"screen-brightness-control>=0.20.0",
|
||||
"wmi>=1.5.1",
|
||||
"monitorcontrol>=3.0.0",
|
||||
"pystray>=0.19.0",
|
||||
]
|
||||
visualizer = [
|
||||
"soundcard>=0.4.0",
|
||||
"numpy>=1.24.0,<2.0",
|
||||
]
|
||||
dev = [
|
||||
"pytest>=7.0",
|
||||
"pytest-asyncio>=0.21",
|
||||
|
||||
+94
-26
@@ -1,35 +1,103 @@
|
||||
# Restart the Media Server
|
||||
# Stop any running instance
|
||||
$procs = Get-Process -Name 'media-server' -ErrorAction SilentlyContinue
|
||||
foreach ($p in $procs) {
|
||||
Write-Host "Stopping server (PID $($p.Id))..."
|
||||
Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
if ($procs) { Start-Sleep -Seconds 2 }
|
||||
# Restart the Media Server.
|
||||
#
|
||||
# Robust against the two ways the server gets started:
|
||||
# - Installer build: %LOCALAPPDATA%\Media Server\media-server.bat
|
||||
# (runs as python.exe -m media_server.main)
|
||||
# - Dev editable install: media-server console script on PATH
|
||||
# (runs as media-server.exe)
|
||||
#
|
||||
# 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
|
||||
$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"
|
||||
}
|
||||
param(
|
||||
[ValidateSet('auto', 'dev', 'installer')]
|
||||
[string]$Mode = 'auto',
|
||||
[int]$Port = 8765
|
||||
)
|
||||
|
||||
$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
|
||||
Write-Host "Starting server..."
|
||||
Start-Process -FilePath 'media-server' `
|
||||
-WorkingDirectory 'c:\Users\Alexei\Documents\haos-integration-media-player\media-server' `
|
||||
-WindowStyle Hidden
|
||||
# --- Stop whatever is listening on the port ---------------------------------
|
||||
$listenerPids = @()
|
||||
try {
|
||||
$conns = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue
|
||||
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
|
||||
|
||||
# Verify it's running
|
||||
$check = Get-Process -Name 'media-server' -ErrorAction SilentlyContinue
|
||||
if ($check) {
|
||||
Write-Host "Server started (PID $($check[0].Id))"
|
||||
# --- Verify it's listening ---------------------------------------------------
|
||||
$verify = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue
|
||||
if ($verify) {
|
||||
$vpid = $verify[0].OwningProcess
|
||||
$vproc = Get-Process -Id $vpid -ErrorAction SilentlyContinue
|
||||
Write-Host "Server listening on port $Port (PID $vpid, $($vproc.ProcessName))"
|
||||
} else {
|
||||
Write-Host "WARNING: Server does not appear to be running!"
|
||||
Write-Warning "Server is not listening on port $Port yet - check logs."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Tests for AudioAnalyzer.
|
||||
|
||||
Covers the pure-Python pieces that don't need real audio hardware:
|
||||
- Logarithmic FFT bin edge layout
|
||||
- Slow-AGC envelope follower (attack vs release behaviour)
|
||||
- Lifecycle reset of the AGC reference on start()
|
||||
|
||||
Tests are skipped when numpy isn't installed in the host environment
|
||||
so they don't block CI on a minimal interpreter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from media_server.services.audio_analyzer import AudioAnalyzer, _load_numpy
|
||||
|
||||
np = _load_numpy()
|
||||
needs_numpy = pytest.mark.skipif(np is None, reason="numpy not available")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def analyzer() -> AudioAnalyzer:
|
||||
return AudioAnalyzer(num_bins=16, sample_rate=44100, chunk_size=1024)
|
||||
|
||||
|
||||
# ── _compute_bin_edges ────────────────────────────────────────────
|
||||
|
||||
|
||||
@needs_numpy
|
||||
def test_bin_edges_count_matches_num_bins_plus_one(analyzer: AudioAnalyzer) -> None:
|
||||
edges = analyzer._compute_bin_edges()
|
||||
assert len(edges) == analyzer.num_bins + 1
|
||||
|
||||
|
||||
@needs_numpy
|
||||
def test_bin_edges_are_monotonic_non_decreasing(analyzer: AudioAnalyzer) -> None:
|
||||
edges = analyzer._compute_bin_edges()
|
||||
assert all(edges[i] <= edges[i + 1] for i in range(len(edges) - 1))
|
||||
|
||||
|
||||
@needs_numpy
|
||||
def test_bin_edges_stay_within_fft_size(analyzer: AudioAnalyzer) -> None:
|
||||
edges = analyzer._compute_bin_edges()
|
||||
fft_size = analyzer.chunk_size // 2 + 1
|
||||
assert max(edges) <= fft_size - 1
|
||||
assert min(edges) >= 0
|
||||
|
||||
|
||||
# ── AGC envelope follower (the new behaviour) ─────────────────────
|
||||
|
||||
|
||||
def _step_envelope(analyzer: AudioAnalyzer, peak: float) -> float:
|
||||
"""Run one frame of the AGC update with a known peak value.
|
||||
|
||||
Mirrors the math inside _capture_loop without spinning up a real
|
||||
capture thread or requiring numpy: pure Python on a single float.
|
||||
"""
|
||||
if peak > analyzer._spectrum_ref:
|
||||
analyzer._spectrum_ref += (peak - analyzer._spectrum_ref) * 0.05
|
||||
else:
|
||||
analyzer._spectrum_ref += (peak - analyzer._spectrum_ref) * 0.005
|
||||
return analyzer._spectrum_ref
|
||||
|
||||
|
||||
def test_agc_initial_reference_is_quiet(analyzer: AudioAnalyzer) -> None:
|
||||
assert analyzer._spectrum_ref == pytest.approx(0.01)
|
||||
|
||||
|
||||
def test_agc_attacks_quickly_toward_loud_signal(analyzer: AudioAnalyzer) -> None:
|
||||
# Drive 30 frames of a loud signal; reference should climb sharply.
|
||||
for _ in range(30):
|
||||
_step_envelope(analyzer, peak=1.0)
|
||||
# 30 frames of attack=0.05 brings (1 - 0.99^30) ≈ 0.78 of the way to 1.0.
|
||||
assert analyzer._spectrum_ref > 0.5
|
||||
assert analyzer._spectrum_ref < 1.0
|
||||
|
||||
|
||||
def test_agc_releases_slowly_toward_quiet_signal(analyzer: AudioAnalyzer) -> None:
|
||||
analyzer._spectrum_ref = 1.0
|
||||
for _ in range(30):
|
||||
_step_envelope(analyzer, peak=0.0)
|
||||
# Release coefficient is 0.005 — after 30 frames we should have shed
|
||||
# only ~14% of the headroom, not snap back to silent.
|
||||
assert analyzer._spectrum_ref > 0.7
|
||||
assert analyzer._spectrum_ref < 1.0
|
||||
|
||||
|
||||
def test_agc_is_asymmetric_attack_faster_than_release(analyzer: AudioAnalyzer) -> None:
|
||||
a = AudioAnalyzer()
|
||||
b = AudioAnalyzer()
|
||||
a._spectrum_ref = 0.5
|
||||
b._spectrum_ref = 0.5
|
||||
# One attack frame toward 1.0
|
||||
_step_envelope(a, peak=1.0)
|
||||
# One release frame toward 0.0 (same magnitude of error: 0.5)
|
||||
_step_envelope(b, peak=0.0)
|
||||
attack_delta = a._spectrum_ref - 0.5
|
||||
release_delta = 0.5 - b._spectrum_ref
|
||||
# Attack coefficient (0.05) is 10× the release coefficient (0.005).
|
||||
assert attack_delta == pytest.approx(release_delta * 10, rel=1e-6)
|
||||
|
||||
|
||||
# ── start() lifecycle reset ──────────────────────────────────────
|
||||
|
||||
|
||||
def test_start_resets_spectrum_ref_when_unavailable(
|
||||
monkeypatch: pytest.MonkeyPatch, analyzer: AudioAnalyzer
|
||||
) -> None:
|
||||
"""Even when start() returns False (no hardware), the AGC state
|
||||
should remain at the documented quiet baseline."""
|
||||
# Force unavailable so start() short-circuits without spawning a thread.
|
||||
monkeypatch.setattr(
|
||||
AudioAnalyzer, "available", property(lambda self: False)
|
||||
)
|
||||
analyzer._spectrum_ref = 0.95 # leftover from prior session
|
||||
started = analyzer.start()
|
||||
assert started is False
|
||||
# start() returned early before the reset — by design (no capture
|
||||
# means no need to renormalize). Document the contract.
|
||||
assert analyzer._spectrum_ref == 0.95
|
||||
|
||||
|
||||
def test_start_resets_spectrum_ref_when_available(
|
||||
monkeypatch: pytest.MonkeyPatch, analyzer: AudioAnalyzer
|
||||
) -> None:
|
||||
"""When capture actually starts, leftover AGC state from a prior
|
||||
session must be cleared so the first transients don't clip."""
|
||||
monkeypatch.setattr(
|
||||
AudioAnalyzer, "available", property(lambda self: True)
|
||||
)
|
||||
# Stub out the thread so we don't actually spin up a capture loop.
|
||||
monkeypatch.setattr(
|
||||
"media_server.services.audio_analyzer.threading.Thread",
|
||||
lambda *a, **kw: type("T", (), {"start": lambda self: None})(),
|
||||
)
|
||||
analyzer._spectrum_ref = 0.95 # leftover from prior session
|
||||
try:
|
||||
started = analyzer.start()
|
||||
assert started is True
|
||||
assert analyzer._spectrum_ref == pytest.approx(0.01)
|
||||
finally:
|
||||
analyzer._running = False
|
||||
|
||||
|
||||
# ── get_frequency_data thread-safe contract ───────────────────────
|
||||
|
||||
|
||||
def test_get_frequency_data_returns_none_before_capture(
|
||||
analyzer: AudioAnalyzer,
|
||||
) -> None:
|
||||
assert analyzer.get_frequency_data() is None
|
||||
Reference in New Issue
Block a user