184 Commits

Author SHA1 Message Date
alexei.dolgolyov 2ddbb93537 ci: seed config before linux-smoke launch so the server actually serves
Lint & Test / test (push) Successful in 12s
Lint & Test / linux-smoke (push) Successful in 20s
With the PyGObject girepository-2.0 fix in place, the linux-smoke step
ran its server-boot assertion for the first time and failed: on a fresh
runner the first-run bootstrap writes a default config and calls
sys.exit(0) ("First run: generated default config ... then restart")
instead of serving, so /api/health never came up and the 15s wait
timed out.

That exit-on-first-run is deliberate product behavior (never silently
start in insecure no-auth mode), so adjust the test rather than the app:
invoke the server once to seed the config (it exits 0 before binding the
port), then launch it for real. /api/health requires no auth, so the
auto-generated token is irrelevant to the check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:34:00 +03:00
alexei.dolgolyov b7e50455ad ci: fix Linux build — install libgirepository-2.0-dev for PyGObject
Lint & Test / test (push) Successful in 16s
Lint & Test / linux-smoke (push) Failing after 49s
PyGObject >= 3.52 dropped the standalone gobject-introspection
girepository-1.0 dependency and now builds against girepository-2.0,
which was merged into GLib 2.80. The linux extra pins PyGObject>=3.46
with no upper bound, so pip resolves the newest release (3.56.3) and
meson aborts metadata generation with:

  Dependency 'girepository-2.0' is required but not found.

because CI only installed the old libgirepository1.0-dev.

Swap libgirepository1.0-dev -> libgirepository-2.0-dev (shipped by
GLib 2.80 on the ubuntu-latest / 24.04 runner) across all three Linux
pip-install paths so they stay in sync:

- test.yml: the failing linux-smoke job.
- release.yml: build-linux, which would otherwise ship a broken
  Linux tarball on the next tag.
- build.yml: build-linux had no system-deps step at all; added the
  matching apt install so the manual artifact build works too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:18:51 +03:00
alexei.dolgolyov 0006620eb5 ci: temporarily disable macOS build job (no runner available)
Lint & Test / test (push) Successful in 13s
Lint & Test / linux-smoke (push) Failing after 29s
The Gitea instance currently has no macOS runner attached, so the
build-macos job was failing visibly on every release even with
continue-on-error: true, and the release body advertised macOS
downloads that were never produced.

- Gate build-macos with `if: false` (job is preserved verbatim
  except for the gate, so re-enablement is a one-line delete).
- Drop the macOS rows from the Downloads table generated by the
  create-release job. Kept as commented-out lines inside the heredoc.

Both changes carry a `TODO(macos-runner)` marker — `grep` for it
to find every site that needs flipping when a macOS runner is
connected.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:34:09 +03:00
alexei.dolgolyov e7a3f62a9a chore: release v0.4.0
Lint & Test / test (push) Has been skipped
Lint & Test / linux-smoke (push) Has been skipped
Release / create-release (push) Successful in 3s
Release / build-windows (push) Successful in 1m9s
Release / build-linux (push) Successful in 1m10s
Release / build-macos (push) Has been cancelled
v0.4.0
2026-05-28 17:27:37 +03:00
alexei.dolgolyov d798fedf55 feat(icon): redesign app icon as "Beacon" and ship multi-resolution ICO
Lint & Test / test (push) Successful in 20s
Lint & Test / linux-smoke (push) Failing after 34s
- Replace generic Spotify-green circle with a refined "Beacon" design:
  squircle + deep-teal diagonal gradient (#0B3D3B → #1A6B5E) + warm
  parchment play triangle (#F5F1E8) with drop shadow, top sheen, and
  ghosted echo-chevrons hinting at broadcast/stream
- Grow icon.ico from a single 16×16 frame (208 B) to a 10-frame
  multi-resolution ICO (16/20/24/32/40/48/64/96/128/256, ~37 KB) so
  Windows no longer upscales 16×16 into mush for the installer chrome,
  Start Menu, desktop shortcuts, Alt+Tab, and File Explorer tiles
- Add scripts/generate-icon.py: SVG is the source of truth; resvg-py
  rasterizes every ICO size; Pillow packs the multi-resolution ICO
- Update tray.py to pick a 64×64 frame from the new ICO and update its
  procedural fallback to the same Beacon palette so a missing ICO no
  longer regresses the tray to the old Spotify-green circle
- Add resvg-py to [dev] deps (build-time only)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:18:58 +03:00
alexei.dolgolyov ddf4a6cb29 feat: production-ready Linux & macOS support
Lint & Test / test (push) Successful in 20s
Lint & Test / linux-smoke (push) Failing after 34s
- Add `linux` (dbus-python, PyGObject, python-xlib) and `macos`
  (pyobjc) extras to pyproject.toml with sys_platform markers; move
  cross-platform screen-brightness-control + monitorcontrol to base deps.
- build-dist-linux.sh: install `.[linux]`, pkg-config pre-flight for
  dbus-1/glib-2.0, emit a systemd unit with DBUS_SESSION_BUS_ADDRESS +
  XDG_RUNTIME_DIR + ReadWritePaths for ~/.config and ~/.cache so MPRIS
  works and audit-log / thumbnail writes aren't blocked by ProtectHome.
- New build-dist-macos.sh + per-user LaunchAgent installer producing
  MediaServer-vX.Y-macos-{arm64,x86_64}.tar.gz.
- Templated media-server.service updated to match the dist layout with
  proper session-bus env vars and a writable state-dir grant.
- install_linux.sh: drop dead requirements.txt path; install via
  `pip install ".[linux]"` and pre-create the writable state dirs.
- Cross-platform album artwork: abstract MediaController.get_album_art()
  with Linux (mpris:artUrl, file:// + http(s)://) and macOS (Spotify URL)
  impls; routes/media artwork endpoint now awaits the controller.
- LinuxMediaController connects to the session bus lazily — failure no
  longer crashes lifespan startup; MPRIS calls return idle until the bus
  is reachable. Logged once at INFO with a hint about
  `loginctl enable-linger`.
- Startup preflight on Linux warns if DBUS_SESSION_BUS_ADDRESS or
  XDG_RUNTIME_DIR is unset and informs the user when Wayland disables
  the foreground probe.
- /api/media/visualizer/status now reports a per-OS unavailable_reason.
- tray._confirm guarded against ctypes.windll on non-Windows.
- config.example.yaml: per-OS commented script examples; on_turn_off
  default is now a no-op echo (used to silently fail off Windows).
- README: replace stale `pip install -r requirements.txt` instructions
  with the new extras; add systemd lingering doc + troubleshooting
  section; add macOS LaunchAgent section.
- CI: new linux-smoke job (installs `.[linux]`, boots the server under
  dbus-run-session, asserts /api/health). Release workflow gains
  apt-deps step for the Linux build and a best-effort macOS build job.
2026-05-26 12:17:30 +03:00
alexei.dolgolyov 82710c6457 chore: release v0.3.1
Lint & Test / test (push) Has been skipped
Release / create-release (push) Successful in 4s
Release / build-linux (push) Successful in 28s
Release / build-windows (push) Successful in 52s
v0.3.1
2026-05-25 23:45:08 +03:00
alexei.dolgolyov 9b9a2b5c9f fix(ws): accept same-origin WebSocket connections in default Origin allow-list
When `cors_origins` was unset, the WS endpoint only allowed
`http://localhost:<port>` and `http://127.0.0.1:<port>` as origins, so a
browser opening the UI via the LAN IP (e.g. `http://192.168.2.100:8765`
when bound to `0.0.0.0`) had its WebSocket closed with code 4003 and
never recovered — leaving the Web UI in a permanent reconnect loop.

Also accept any `Origin` whose authority matches the request's `Host`
header (both `http://` and `https://` schemes). Same-origin is by
definition not CSWSH, so the cross-origin defence added in v0.3.0
remains intact for genuine third-party LAN pages.
2026-05-25 23:44:57 +03:00
alexei.dolgolyov b023d72165 chore: release v0.3.0
Lint & Test / test (push) Has been skipped
Release / create-release (push) Successful in 4s
Release / build-linux (push) Successful in 1m29s
Release / build-windows (push) Successful in 1m42s
v0.3.0
2026-05-22 22:41:11 +03:00
alexei.dolgolyov d131ba461c fix: production-readiness hardening — security, perf, a11y, observability
Lint & Test / test (push) Successful in 20s
Security
- Default scripts_management, callbacks_management, links_management, and
  media_folders_management to False so a leaked token cannot escalate to RCE
  through admin CRUD endpoints.
- TokenSpec + scope hierarchy (read | control | admin); legacy bare-string
  api_tokens entries promote to admin for back-compat. Management endpoints
  now require admin scope.
- WebSocket subprotocol auth (Sec-WebSocket-Protocol: media-server.token.<T>)
  preferred over ?token= query so the token no longer lands in URL/history/
  Referer; query fallback retained for HA integration back-compat.
- Origin allow-list check on the WS endpoint (CSWSH defence).
- In-process token-bucket rate limiter: 5/min for failed auths,
  10/min for /api/scripts/execute and /api/callbacks/execute.
- shell=False subprocess path (shlex.split) + per-parameter regex `pattern`
  in ScriptParameterConfig to harden shell=true scripts against parameter
  injection (Windows cmd.exe env-var expansion).
- CSP gains form-action, worker-src, manifest-src directives.
- Refuse cors_origins=["*"] at startup; strip token=... from uvicorn access
  logs; validate Gitea release tag against strict SemVer regex.
- noopener noreferrer + no-referrer referrerpolicy on every outbound link.
- icacls hardening of config.yaml on Windows (current user + SYSTEM +
  Administrators only); 0600 still enforced on POSIX.
- WS volume handler clamps input and never drops the socket on bad messages.

Performance
- Album-art read in windows_media gated by track key — was decoding the
  WinRT thumbnail twice per second regardless of track changes.
- /api/media/artwork returns content-derived ETag + Cache-Control so the
  browser sends If-None-Match and gets 304s on track repeats.
- Foreground-service ctypes argtypes hoisted to one-time module init
  (was re-declaring ~14 prototypes per probe).
- display_service _static_cache keyed by (edid_hash, ...) tuple with
  eviction of disappeared monitors — fixes stale capabilities on hot-plug
  swaps where the new topology has the same monitor count.
- Visualizer rAF loop paused on document.hidden, resumed on visible.

Reliability / bug fixes
- Lifespan rewritten as try/yield/finally so a partial-startup failure
  cannot orphan background tasks or executors.
- _run_callback in routes/media.py keeps a strong task ref (GC-safe) and
  uses the dedicated callback executor instead of the default pool.
- macos_media.set_volume() no longer always returns True.
- TrayManager._restart_requested initialised in __init__; set before
  signalling exit so the main thread observes it correctly.
- Missing static_dir now logs a WARNING instead of silent UI disable.

UX / accessibility / PWA
- manifest.json theme_color and background_color match the Studio Reference
  base (#0E0D0B); added id and scope for PWA installability.
- ARIA on mini-player icon buttons; inner SVGs marked aria-hidden.
- OS mediaSession API wired so headset / lockscreen / Bluetooth buttons
  drive play/pause/next/prev/seek and show track metadata + artwork.

Observability
- X-Request-ID middleware (accept upstream id if it matches a safe regex,
  otherwise UUID4); request_id_var added to ContextVars and included in
  every log line alongside the token label.
- Audit log (append-only JSONL) for every script + callback execution,
  including the on_play/on_pause/etc. event callbacks. Background-thread
  writer; queue capped; flushed in lifespan teardown.

Deployment
- proxy_headers + forwarded_allow_ips plumbed through Settings →
  uvicorn.Config for reverse-proxy installs.
- HTTPS support via ssl_certfile + ssl_keyfile (+ optional password);
  startup refuses to launch with only one of the pair set.
- Thumbnail cache moved from project-root .cache to
  %LOCALAPPDATA%/media-server/cache (Windows) and
  $XDG_CACHE_HOME/media-server/thumbnails (POSIX).

Tests
- 35 new tests across auth scopes, rate limiter, browser path traversal
  (../ NUL UNC absolute), script-param validation incl. regex, Gitea tag
  whitelist, config atomic write + POSIX perms. 47 passed / 4 skipped.
2026-05-22 22:25:54 +03:00
alexei.dolgolyov 450f9fe1ee chore: release v0.2.7
Lint & Test / test (push) Has been skipped
Release / create-release (push) Successful in 3s
Release / build-linux (push) Successful in 30s
Release / build-windows (push) Successful in 52s
v0.2.7
2026-05-19 01:34:36 +03:00
alexei.dolgolyov e1c8474271 fix(csp): wire display sliders and accent picker without inline on*
Lint & Test / test (push) Successful in 10s
The display brightness/contrast sliders and the accent color picker
rendered dynamic HTML with inline oninput/onchange/onclick attributes,
which are blocked by the script-src 'self' CSP — so display settings
were silently un-clickable from the WebUI.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:28:04 +03:00
alexei.dolgolyov 0d07f7f1f4 chore: release v0.2.3
Lint & Test / test (push) Has been skipped
Release / create-release (push) Successful in 4s
Release / build-linux (push) Successful in 30s
Release / build-windows (push) Successful in 1m21s
v0.2.3
2026-05-01 19:41:41 +03:00
alexei.dolgolyov 372e4eb11f fix(displays): keep primary-display star visible on long monitor names
Wrapping overflow:hidden + ellipsis on the parent flex container
clipped the favourite star whenever the monitor name was long enough
to truncate. Move the truncation rules onto a new inner span around
the name text only, and add flex-shrink:0 to the badge so it always
renders in full.
2026-05-01 19:40:12 +03:00
alexei.dolgolyov d27484a46d ui(player): square vinyl stage, brighter tonearm, tilted sleeve
- Restore 1:1 aspect-ratio on .vinyl-stage; the previous 1:0.85
  override created an inconsistent crop on resize. Replace the
  tonearm sibling's aspect-ratio with explicit height:36% so its
  vertical span tracks the stage instead of its own width.
- Brighten the tonearm SVG: lighter pivot/arm gradient stops,
  thicker stroke widths, stronger cartridge highlight.
- Add a subtle -2deg tilt to the sleeve so it reads as physically
  resting on the disc rather than rectilinearly composed.
2026-05-01 19:40:04 +03:00
alexei.dolgolyov 261a14c575 chore: release v0.2.2
Lint & Test / test (push) Has been skipped
Release / create-release (push) Successful in 3s
Release / build-linux (push) Successful in 33s
Release / build-windows (push) Successful in 1m22s
v0.2.2
2026-05-01 17:15:24 +03:00
alexei.dolgolyov e7372b0ccb chore: wire up code-review-graph MCP server
Lint & Test / test (push) Successful in 11s
- Add .mcp.json registering code-review-graph (uvx, stdio)
- Document the MCP tools in CLAUDE.md so the assistant prefers
  graph queries over Grep/Glob/Read for structural exploration
- Ignore .code-review-graph/ index directory
2026-05-01 11:28:22 +03:00
alexei.dolgolyov ec5178142e ui(player): replace footer with About dialog + reclaim dead space
- Move colophon (credit/email/source link) from sticky footer into
  a dedicated About dialog, opened from a new header button
- Drop ~64px of bottom container padding now that the footer is gone
- Loosen vinyl-stage aspect-ratio (1:1 -> 1:0.85) so the disc no
  longer leaves a tall empty band below the sleeve
- Switch tonearm height: 36% to aspect-ratio: 1 to keep proportions
  consistent across the new stage ratio
- Add about.* / dialog.close i18n keys for EN and RU
- Add vinyl-variants-mockup.html as next design reference target
2026-05-01 11:28:10 +03:00
alexei.dolgolyov 46af2bb8cc chore: release v0.2.1
Lint & Test / test (push) Has been skipped
Release / create-release (push) Successful in 3s
Release / build-linux (push) Successful in 32s
Release / build-windows (push) Successful in 49s
v0.2.1
2026-04-25 20:23:01 +03:00
alexei.dolgolyov 25a492d5dd ui(player): meaningful caps for tablet/small-desktop range + tighter footer
Lint & Test / test (push) Successful in 17s
The 720–1240 px viewport range was a "strange zone": below 1240 the layout
is single-column, but above 720 none of the Pocket Edition rules fire, so
the vinyl stage stretched to full content width (~1100 px) and the masthead
ran to a 1000 px+ measure on a small-desktop window.

Caps now degrade in three steps:
- ≤ 720 px: vinyl 460 px / 92% width (mobile hero unchanged)
- 721–1240 px: vinyl 480 px, masthead 640 px, both centered
- ≥ 1241 px: two-column layout (no caps needed; grid does it)

Also reduce the bottom dead space:
- footer margin-top 80 → 36, inner top padding 28 → 20
- .container bottom padding 140 → 64 (desktop) / 56 (mobile)

And a small mobile-volume fix in the same range:
- .controls flex-wrap nowrap → wrap so the vu-cluster can take its own row
- vu-cluster gets flex-basis 100 % (forces own row in the wrapping flexbox)
- volume-slider drops the max-width: 200 cap so it fills the row width
- vinyl-stage on mobile bumped 320 → 460 px / 78% → 92% width
2026-04-25 20:19:37 +03:00
alexei.dolgolyov f4be2bdb89 fix(player): wire accent picker to editorial copper palette + visual polish
Lint & Test / test (push) Successful in 9s
The accent picker only mutated --accent / --accent-hover, but the redesign
reads everything off --copper, --copper-hi, --copper-lo, --copper-glow.
--accent was a one-way alias of --copper, so picking a color did nothing.

Frontend (player.js):
- applyAccentColor now drives --copper, --copper-hi, --copper-lo, and a
  new --copper-rgb triplet (used by every soft tint / glow on both themes)
- darkenColor / hexToRgbTriple helpers added beside lightenColor

CSS (styles.css):
- introduce --copper-rgb tokens for both themes; --copper-glow now derives
  from rgba(var(--copper-rgb), 0.35) so it follows the picker
- replace 21 hardcoded rgba(224,128,56,...) / rgba(31,78,61,...) literals
  across hover bgs, focus halos, glows, vinyl-label gradients with
  rgba(var(--copper-rgb), ...)
- replace the light-theme vinyl-label gradient hexes with
  var(--copper) / var(--copper-lo)

Other player polish in this changeset:
- track-masthead: padding-right clamp(12px, 1.5vw, 24px) so VU meter,
  spectrum tail, end timecode and controls sit inset from the panel edge
  (zeroed on the single-column mobile breakpoint to keep symmetry)
- VU meter 140→120 px, volume slider 80→64 px to free up row width so
  the cluster stays inline with prev/play/next instead of wrapping
- light-theme VU meter override: cream gauge face, dark-ink scale ticks,
  hunter-emerald needle (replaces the hardcoded black gauge)
- fullscreen meta-cell labels: var(--ink-faint) → var(--copper) so STATE
  and SOURCE read as part of the same editorial system as the kicker
2026-04-25 18:19:19 +03:00
alexei.dolgolyov 51ec1503f4 perf(visualizer): cut spectrum + track-switch CPU significantly
Lint & Test / test (push) Successful in 10s
Frontend hot path (player.js, background.js):
- visualizer rAF: drop per-frame getComputedStyle('--accent') (cached on
  applyAccentColor), build canvas LinearGradient once per accent change
  instead of 32× per frame, batch all bars into a single beginPath/fill
- FPS-gate canvas redraw via frequencyDataVersion so 60-144 Hz monitors
  stop re-rendering identical frames produced at 30 Hz on the backend
- editorial spectrum bars: replace style.height (layout) with
  transform: scaleY (compositor-only); cache bar refs, pre-compute
  per-bar gain/range, dedup writes at 1/1000 quantization
- coalesce VU needle into the visualizer rAF; cache vuNeedle ref;
  dedup angle writes at 0.1°
- updateUI: status-payload fingerprint short-circuits the redundant
  status_update broadcasts that fire during a track change
- swapArtworkSrc: only force layout reflow when keyframe is in flight;
  drop the ?_=Date.now() cache-buster so identical artwork URLs reuse
  the decoded bitmap; mini/glow imgs only re-set src when changed
- drop the fullscreen MutationObserver — fs-bloom-art is mirrored
  directly from the artwork-swap path, eliminating the second blur paint
- updateProgress: skip text writes when the rounded second hasn't moved;
  POSITION_INTERPOLATION_MS 100 → 250
- background.js: lift resizeBackgroundCanvas out of the rAF body, cache
  step, accept new int-scaled wire format

CSS:
- spectrum bars use transform: scaleY(var(--bar-h-scale)) + transition
  on transform; will-change updated to transform
- album-art-glow and fs-bloom-art switched to small-source-blur trick
  (render at 20-25% size, scale 4-6×, lower blur radius) — visually
  equivalent, ~10-25× cheaper repaint on track change
- drop unused transition: filter on .vinyl-stage #album-art

Backend (audio_analyzer.py, websocket_manager.py):
- pre-allocate windowed and cumsum buffers; replace
  np.concatenate(([0.0], np.cumsum(...))) with cumsum[0]=0 +
  np.cumsum(out=cumsum[1:]); float32 hanning window
- RMS via np.dot(mono, mono) — no astype copy, no ** temp
- int16 wire format (scale=1000) — smaller JSON, no Python float boxing
- versioned data + threading.Event so _audio_broadcast_loop is event-
  driven (ev.wait + monotonic seq dedup) instead of polling on a timer
  with the always-false `data is _last_data` identity check

ruff clean, pytest 7 passed / 3 numpy-skipped, esbuild bundle 113.6 kB.
2026-04-25 18:05:57 +03:00
alexei.dolgolyov 08c3c80df4 ci: skip test workflow on release commits
Lint & Test / test (push) Successful in 46s
2026-04-25 15:36:18 +03:00
alexei.dolgolyov 62eeca1b9e chore: release v0.2.0
Release / create-release (push) Successful in 3s
Lint & Test / test (push) Successful in 39s
Release / build-linux (push) Successful in 1m3s
Release / build-windows (push) Successful in 1m57s
v0.2.0
2026-04-25 15:34:35 +03:00
alexei.dolgolyov 4c93bfb8c1 ui(player): soften vinyl-stage halo, transparent-bg album placeholder, crossfade artwork swaps
Lint & Test / test (push) Successful in 1m50s
- vinyl-stage background fades to transparent (matches fullscreen Listening Room) — no more rectangular dark card around the sleeve+disc composition
- album-art placeholder SVG drops the opaque #282828 backdrop in favour of a translucent disc glyph so the sleeve cardstock shows through before the first artwork load
- new swapArtworkSrc helper retriggers the .is-swapping keyframes so artwork changes crossfade instead of popping
2026-04-25 15:22:08 +03:00
alexei.dolgolyov 59840a1190 feat(player): fullscreen "Listening Room" mode
Toggleable theater-scale player view that takes over the viewport
and amplifies the existing Studio Reference aesthetic — same fonts,
same copper/ink palette, just dialed up for immersive listening.

Layout & typography:
- Two-column centerfold: massive vinyl stage left (clamp(., 72vh, 720px)),
  editorial column right with Fraunces italic title at clamp(48px, 6.4vw,
  112px), Geist Mono console-style metadata strip, oversized timecodes,
  full-width amplitude spectrum.
- Mobile / portrait flips to vertical theater (vinyl top, masthead+
  transport below) at <=900px or any portrait orientation.

Ambient bloom:
- Duplicate of #album-art rendered behind everything at blur(110px)
  saturate(1.6) opacity(0.42) — paints the room in the record's color.
  Slow 28s drift animation. Light-theme variant at lower opacity.
- MutationObserver keeps bloom art in sync as tracks change.
- Vignette + edge darkening + subtle paper-grain veil frame the stage.

Interaction:
- Header button (corner-arrows-out icon) toggles; pressing 'F' anywhere
  outside text inputs also toggles; ESC exits.
- Native Fullscreen API requested as best-effort sugar on top of the
  CSS overlay (works on TV / tablet); CSS overlay alone covers the
  CSS-only fallback case (iOS Safari, embedded webviews).
- fullscreenchange listener mirrors OS-level exit back into the overlay.
- Auto-hide chrome + cursor after 2.5s idle, restored on mousemove.
- Focus moves to play/pause on enter; restored to invoking element on
  exit.
- Hides mini-player, tab bar, header, folio marks, and other tabs while
  active.

Motion:
- 320ms fade-in for the stage, 600ms vinyl rise, 1.4s bloom-in,
  staggered 80ms ladder for kicker -> title -> byline -> album -> meta
  -> spectrum -> transport. prefers-reduced-motion disables all.

i18n:
- player.fullscreen / player.fullscreen.exit / player.fullscreen.exit_short
  added to en.json and ru.json.

Files: index.html (header button + fs-chrome strip + fs-bloom layer),
styles.css (~360-line fullscreen block at end, scoped to body.is-
fullscreen-player), player.js (toggle + init + idle/key/observer
plumbing, ~170 lines), app.js (import + window export + init call).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 14:47:53 +03:00
alexei.dolgolyov 2a474ea52c fix(player): redesign cleanup pass — sleeve, tonearm, AGC, dead code
Production-readiness pass before merging the Studio Reference redesign
to master.

Audio (backend):
- Reset AGC `_spectrum_ref` envelope on `start()` so a long silent gap
  between sessions doesn't make the first new transients clip at the
  ceiling. Annotated the trade-off (loud transient lifts reference for
  a few seconds afterwards — the price of real loudness).
- Add `tests/test_audio_analyzer.py` with 10 cases: bin-edge layout,
  AGC attack/release asymmetry, lifecycle reset. Skips numpy-dependent
  cases when numpy isn't installed; CI has it.

Vinyl mode dead code removed:
- The toggle button was dropped during the sleeve refactor but the JS
  state, 2 s `setInterval`, `beforeunload` handler, and `applyVinylMode`
  call (commented out in app.js) all stayed. Now properly excised from
  player.js + app.js + window.* exports.
- Stripped the matching `.album-art-container.vinyl*` CSS block and its
  `vinylSpin` keyframes (~95 LoC).

Sleeve + tonearm fixes:
- Removed the duplicate `.now-playing .vinyl-stage` / `.vinyl-label` /
  `.tonearm` block that was overriding the new `.vinyl-stage` rules by
  source order — the uncommitted tonearm geometry never took effect
  because the stale clone won the cascade.
- Tightened tonearm to 36% × 36% at right:-6%, top:26% so the SVG
  bounding box stays right of the sleeve (sleeve right edge ~68%).
  Needle now lands on the visible disc grooves at both rest and
  playing rotations and never overlaps the cover.
- Removed sleeve `transform: rotate(-2.5deg)` + the matching mobile
  `-1.8deg` override; sleeve now sits flat and squared-off.
- Removed the 1px inset hairline on the sleeve and the 1px outline +
  inset highlight on the album art — cleaner, no semitransparent
  border noise.
- Album art inset 5% to expose a cardstock margin around the print
  (using explicit width/height — `inset` shorthand triggered the CSS
  replaced-element rule that uses the image's intrinsic size and blew
  out the grid track).

Mobile + misc:
- Removed mobile tonearm overrides at 720px and 420px — they were
  calibrated for the pre-sleeve geometry and put the needle back over
  the cover on phones; desktop geometry is proportional and works.
- Added `<meta name="mobile-web-app-capable">` alongside the legacy
  Apple variant to silence the deprecation warning in Chromium.
- Replaced the "PRIMARY" badge on display cards with a copper star
  icon (translation key still drives title + aria-label).
- `.gitattributes` with `* text=auto eol=lf` so Windows checkouts stop
  nagging "LF will be replaced by CRLF".

Annotations:
- "REF · 24" record-label catalogue mark marked as intentional non-i18n
  decoration in index.html.

CI: ruff clean, pytest 7 passed + 3 numpy-skipped (all 10 run on CI).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 14:39:20 +03:00
alexei.dolgolyov f85ce77f14 ui(mobile): Pocket Edition layout + tablet tab range fix
- Pocket Edition (<=720px): bottom-fixed tab nav with frosted
  backdrop, copper hairline + glow on active, mono numerals +
  abbreviated labels (legacy rule was hiding labels under 600px).
- Single-column hero player tab: centered vinyl + tonearm,
  clamp(28..38px) title, full-width volume row replacing the desktop
  VU cluster (the analog meter is decorative on a phone).
- Mini-player floats above the bottom nav, condensed to art + title +
  play/pause with the hairline progress on the top edge.
- Library pagination stacks to full-width buttons; settings tables
  reflow to card-style rows so no columns drop off-screen.
- Tablet 721-980px: editorial top tabs but with compressed padding so
  all five labels fit without clipping.
- Side-by-side player threshold bumped 980 -> 1240 so the right
  column has genuine room for controls + VU cluster (was clipping
  off the edge at 981-1240px). flex-wrap on .controls as safety net.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 12:16:51 +03:00
alexei.dolgolyov b09569f390 fix(vu): drive needle from RMS-dB loudness instead of peak-of-bins
- Backend computes time-domain RMS, maps -60..-6 dB to 0..1, sends as
  `level` alongside the per-frame-normalized frequency bins.
- Frontend prefers `level` directly; drops the peak-of-bins fallback
  and the redundant volume-slider attenuation (loopback capture is
  already post-volume on Windows/macOS).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 12:16:41 +03:00
alexei.dolgolyov f2c82164e8 ui(vu): narrower 44deg swing, peak-based level, faster response; mini progress bar fix
- VU needle swings -22..+22deg instead of -45..+45 for a more realistic VU look
- Switch from RMS to peak frequency reading so the needle catches musical hits
- Faster attack (0.7) and release (0.25) so it swings rather than pinning
- Replace explicit grid lines with subtle repeating-conic-gradient ticks
- Scope mini progress bar styles to .mini-player; taller (3px), clickable
2026-04-25 11:41:32 +03:00
alexei.dolgolyov 588a303c44 ui: fix search icon overlap, Display cards, compact view, dark dropdowns
Library
- Search icon overlapped placeholder text — legacy CSS positioned
  the icon absolutely (left: 0.6rem) inside a position: relative
  wrapper. Override now resets position: static (with !important)
  on icon, clear button, and wrapper, lets the flexbox order them
  naturally with gap: 10px, and zeroes the input's legacy
  padding: 0.4rem 2rem 0.4rem 2rem.
- Compact view now visually distinct from grid view: tighter
  grid (minmax(120px, 1fr) vs 200px), 18px gap, smaller sans-
  font name (13px sans 500 weight) instead of serif, smaller
  meta (9px), smaller browser-icon. The legacy
  .browser-grid-compact class was being applied but my
  .now-playing-styled rules ignored it.

Display tab — full card styling
- Cards: 360px min width (was 280px), serif name (17px) with
  copper monitor icon, mono uppercase resolution + manufacturer,
  copper-bordered "PRIMARY" badge.
- Power button: 38px circle, jade when ON (with copper-glow
  shadow), faint ink when OFF, copper on hover. Was previously
  unstyled / invisible.
- Brightness control: hairline divider above, copper hairline
  slider with copper handle and copper-glow, mono tabular-num
  percentage in copper.

Native form widgets readable on dark theme
- color-scheme: dark on :root (light on light theme) so native
  controls (select dropdowns, scrollbars) inherit dark colors.
- select option { background-color, color } so the popup list
  paints dark text on dark background instead of system white.
2026-04-25 02:55:36 +03:00
alexei.dolgolyov 2049850180 ui: editorial styling for Library/Quick Access/Settings/Display + tab fix
Tab bar
- Was rendering with all 4 borders + bg + radius (legacy capsule).
  Override now nukes border/background/radius/padding with !important
  and leaves only a single hairline border-bottom. Tabs now read as
  an editorial nav strip, not a card.

Library (browser tab)
- Breadcrumb: italic serif with copper hover, mono separator
- Toolbar: hairline-grouped controls, no card chrome
- View-toggle: square hairline pills with copper active
- Search: underline-only input, copper underline on focus
- Items-per-page: mono uppercase label + hairline select
- Browser grid: editorial cards (serif title + mono meta), copper
  hover border, copper play-overlay
- List view: hairline table with mono headers + serif cells
- Pagination: hairline buttons, mono uppercase, copper hover

Quick Access
- Console-rail layout (1px gaps on rule background)
- Cards: serif italic label, copper hover with icon lift
- Empty state: italic serif on dashed paper card

Settings
- Each settings-section becomes a numbered editorial card with a
  CSS counter (5.01, 5.02, ...) shown as a mono prefix on the
  italic-serif summary. Open/closed chevron via :before/:after
- Tables: hairline borders, mono uppercase headers, serif name cells
- Add-card: editorial dashed border, copper-on-hover
- Audio device selector: mono labels, hairline select, status
  pills (active/available/unavailable) in jade/amber/rust
- Folder badges, action buttons, empty states all aligned to
  the editorial palette

Display
- Monitor grid: editorial paper cards
- Headings: serif, copper hover on actions
- Empty state: italic serif on dashed card

Cross-cutting
- Icon-select trigger + popup restyled to editorial
2026-04-25 02:50:51 +03:00
alexei.dolgolyov 9b84fdd0e5 fix(vu): drop conic-gradient mask, draw lines explicitly in 0-90 range
The mask + repeating-conic-gradient combo was rendering the visible
arc shifted to the right (mask appeared centred on 12-3 o'clock
instead of 10:30-1:30). Cause unclear — likely a browser quirk
around `from -45deg` interaction between the two conic-gradients.

Replaced with a single non-repeating conic-gradient using `from 315deg`
(the positive equivalent of -45deg). Lines are drawn explicitly at
every 9° from 0° to 90° in the gradient (-45° to +45° in standard
orientation) — 11 lines total, with the centre line (at 0°) slightly
brighter so it reads as the meter's "0 VU" mark. Outside the 90°
active wedge the gradient is transparent, so no mask is needed.

Result: the leftmost gridline now sits exactly where the needle rests
at -45°, like a real VU meter at silence.
2026-04-25 02:44:57 +03:00
alexei.dolgolyov 3de2b4496e fix(vu): clip grid arc to match needle swing range so rest = proper zero
The grid pattern was a 360° repeating conic gradient (visible across
the full upper 180° arc), while the needle only swings -45..+45.
That made the rest position at -45 sit *inside* the visible arc
rather than at the leftmost gridline — looked wrong.

Now:
- Grid lines start at the -45 angle (matches needle origin)
- Conic-gradient mask clips visibility to the 90° active wedge
- Leftmost gridline now coincides with the needle's rest rotation
  → looks like a real VU meter at zero (silence)
2026-04-25 02:41:29 +03:00
alexei.dolgolyov d7f488ac70 fix(ui): centred toolbar icons; full-width spectrum; needle at rest; drop dynamic bg
Toolbar icons (off-centre inside hit box)
- Legacy .header-btn had padding: 4px 6px and inline-flex with no
  justify-content. With box-sizing: content-box and width: 36px, the
  asymmetric horizontal padding shifted icons to the upper-left of
  the 36×36 hit box.
- Override now sets display: inline-flex; align-items: center;
  justify-content: center; padding: 0; box-sizing: border-box and
  forces SVG children to display: block 16x16 so icons sit dead-
  centre.

Spectrum width — root cause finally found and removed
- An old override block (lines 4821–4878) was still in the file:
  .spectrum { max-width: 360px } + .spectrum span { width: 3px;
  flex: 0 0 3px } + 30 nth-child rules. They had equal/higher
  specificity for some props than the .now-playing-scoped rules
  and were declared first, so width was capping the row.
- Deleted that whole block. .now-playing .spectrum is now the
  single source of truth. Combined with the explicit
  grid-template-columns: repeat(40, minmax(0, 1fr)) (set both in
  CSS literal and from JS via gridTemplateColumns), the row
  reliably fills the column.

VU needle resting position
- CSS default rotation changed from -22deg (pointing upper-left)
  to -45deg — the conventional VU meter rest at silence (-∞ dB).
  Matches stopVuWobble() which also settles the needle there.

Dynamic background — removed
- .bg-shader-canvas hidden via display: none.
- Toggle button (#bgToggle) hidden so the toolbar is cleaner.
- Canvas + JS module stay in DOM so the existing JS doesn't crash.
2026-04-25 02:38:03 +03:00
alexei.dolgolyov 968eb156bc fix(player): real audio level on VU; full-width spectrum; hide canvas under vinyl
VU needle reflects actual audio output
- Was just a synthetic wobble bounded by the volume slider value.
  Now reads RMS of the FFT bins (skipping bin 0 / DC) the visualizer
  feeds in, multiplies by current volume, and applies attack/release
  smoothing for analog-feeling ballistics.
- Falls back to the synthetic wobble when audio capture isn't
  running so the needle still looks alive on the static fallback.
- When playback stops, needle settles to the bottom of the swing
  (-45deg) instead of holding the volume position.

Spectrum width — actually fixed this time
- Root cause: CSS repeat() does NOT accept a CSS variable for its
  count argument, so my `repeat(var(--spectrum-bars), 1fr)` rule
  was invalid and silently dropped, leaving the legacy/auto sizing
  behavior. Set grid-template-columns directly from JS to
  `repeat(40, minmax(0, 1fr))`.
- CSS retains a `repeat(40, minmax(0, 1fr))` literal as a default
  so the row renders sane even before JS executes.

Spectrogram canvas under vinyl
- Hidden via display: none — the editorial .spectrum row already
  shows the audio spectrum; the canvas was redundant and ugly.
  Element stays in DOM so the visualizer JS keeps rendering (drives
  album-art bass-pulse + dynamic background bands).
2026-04-25 02:27:56 +03:00
alexei.dolgolyov a0f74dfc39 fix(visualizer): full-width spectrum + device pick auto-starts capture
Spectrum width
- grid-auto-flow: column with implicit columns wasn't reliably
  stretching to fill the parent. Switch to explicit
  grid-template-columns: repeat(var(--spectrum-bars), minmax(0, 1fr))
  with the bar count exposed as a CSS variable from JS so the
  column count and the actual bar count stay in sync.
- !important on display/grid-template-columns/width to defeat any
  legacy descendant rules.

Device selection
- Picking a device in the audio-device dropdown is an explicit
  signal that the user wants capture. Auto-enable the visualizer
  if it isn't already on, then call applyVisualizerMode so the
  WS subscription happens and the badge flips from 'Available' to
  'Active'. Was only doing this when visualizer was already on,
  which is why the user kept seeing 'Available, not capturing'.
2026-04-25 02:24:01 +03:00
alexei.dolgolyov 6066b4a2c5 fix(visualizer): auto-enable actually starts capture; persist audio device
Auto-enable was a no-op
- Writing 'visualizerEnabled'='true' to localStorage from app.js did
  not update the exported `let visualizerEnabled` in player.js. So
  applyVisualizerMode() saw the stale `false` and went into the
  DISABLE branch — leaving the device 'available, not capturing'.
- Add a setVisualizerEnabled() setter exported from player.js and
  call it before applyVisualizerMode() during boot.

Audio device persistence
- Save the selected device name to localStorage on change.
- On loadAudioDevices(), prefer status.current_device (server's
  current state) but fall back to the localStorage value if the
  server doesn't know one (e.g. after a server restart).
- If the saved device wasn't recognized by the server, push it back
  via POST /api/media/visualizer/device so capture lands on it
  immediately. Best-effort; no toast on failure.
2026-04-25 02:17:03 +03:00