Compare commits

...

85 Commits

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:28:04 +03:00
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
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
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
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
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
alexei.dolgolyov 153424eff8 ui(player): widen spectrum to fill column; swap volume control to left of VU cluster
- Spectrum: switch from flex to grid auto-flow so each bar gets an
  equal-width column slot regardless of bar count. Fewer (40) but
  fatter bars now genuinely span the full grid column.
- Spectrum height bumped to 70px, gap 4px.
- VU cluster: flex-direction: row-reverse so volume controls sit on
  the LEFT, readout in the middle, VU meter on the RIGHT.
- Volume hairline divider switched from border-left to border-right
  so it stays between the volume controls and the readout.
2026-04-25 02:13:22 +03:00
alexei.dolgolyov 336d596b66 fix(ui): full-width spectrum + log-mapped bars; deeper sepia + soft art fade
Spectrum
- Logarithmic frequency-to-bar mapping (squared time so bins
  stretch toward the highs). Per-bar high-end gain ramps from
  1.0x at the lowest bar to 3.0x at the highest, so the right
  half of the spectrum no longer reads as dead air.
- Floor bumped from 6% to 12% so silent bars stay visible.
- Skip bin 0 (DC + sub-rumble) which was overwhelming the lows.
- Use peak (not average) within each band — punchier visual.
- Container height 56→64, gradient now copper-lo → copper →
  copper-hi for more visible top tips. min-width: 0 / box-sizing
  border-box ensures the row truly claims the full grid column.
- Backend FFT path is unchanged: WS audio_data → setFrequencyData
  → renderVisualizerFrame → updateEditorialSpectrum. No
  client-side analyzer added.

Album art (vinyl label)
- Deeper sepia (0.35→0.6) and lower saturate (0.85→0.7) so
  vibrant covers blend into the copper grooves.
- Soft radial mask: outer ~22% of the disc fades toward the
  vinyl black so the album art dissolves into the surface
  rather than terminating at a hard clip edge.
- Hover state pulls the fade inward and eases sepia back so
  the user can still see the real cover at near-natural color.
- Glow tint matches the new sepia depth.
2026-04-25 02:07:20 +03:00
alexei.dolgolyov d937c1590c feat(ui): live VU + audio-driven spectrum, editorial banner, subtler dynamic bg
VU needle (animated)
- Synthetic wobble bounded by current volume runs only while
  state==='playing'. Two combined sines + jitter make it look
  like a real analog needle reacting to peaks.
- Settles back to the static volume-mapped position when paused.

Spectrum (real audio)
- Now driven by the same frequencyData the visualizer canvas
  uses. Each visual bar averages a chunk of frequency bins.
- Spans are now JS-injected (60 bars) instead of hardcoded so
  the bar count is no longer baked in.
- Spectrum spans full width of the masthead column, height
  bumped to 56px for presence.
- CSS animation pauses (sets via `body.audio-spectrum-live`)
  when JS is driving heights so the keyframes don't fight.
- Synthetic CSS animation remains as the fallback when audio
  capture isn't available.

Visualizer auto-enable
- On first install with loopback support, visualizer is
  enabled so the spectrum is alive out of the box.

Dynamic background
- Lower max opacity (1 → 0.45 dark, 0.35 light)
- sepia + saturate filter + hue-rotate keep it palette-aligned
  with the copper editorial tones instead of fighting them
- mix-blend-mode screen (dark) / multiply (light) blends into
  the page background instead of overlaying

Update + connection banners
- Fully restyled: glassy card with copper hairline accent,
  mono uppercase text, copper hairline-border CTA buttons,
  minimal close button. Matches the rest of the editorial
  palette instead of the old solid-green-bar look.
2026-04-25 02:03:15 +03:00
alexei.dolgolyov d157388a94 fix(ui): editorial toolbar + sepia album art
Toolbar
- Strip the legacy dark capsule from .header-toolbar (background,
  border, border-radius, padding) — buttons now sit as individual
  ghost icons matching the mockup, with the per-button transparent
  border + copper hover that was already in place.

Album art
- Apply a warm sepia/saturate/contrast filter to #album-art inside
  the vinyl label so vibrant covers (e.g. Rammstein red) don't
  fight the copper palette and read consistent with the vinyl
  groove rendering. Eases back to near-natural color on .now-playing
  hover so the user can still see the real cover.
- Match the album-art-glow tint with the same hue-rotate so the
  bloom around the spinning label stays palette-aligned.
2026-04-25 01:56:03 +03:00
alexei.dolgolyov e9e4165927 fix(ui): close more gaps with mockup (tabs, mini player, volume control)
Tabs
- Replace SVG icons with mockup-style numeric badges (01..05)
- Hide the legacy .tab-indicator (was rendering as a long copper
  bar above the strip); active tab gets its own copper underline
  via .tab-btn.active::after
- Numbers turn copper on the active tab

Player view
- Volume control restored: mute button + slim copper slider live
  inside the VU cluster, on the right of the readout, separated
  by a hairline. Slider is the existing #volume-slider so all JS
  hooks (bidirectional sync, drag, etc.) keep working.
- Track title font scaled down (clamp 34..64) and clamped to 3
  lines with ellipsis so long YouTube-style titles don't dominate
  the masthead. Adds word-break + overflow-wrap.
- #artist:empty and #album:empty are now display:none so blank
  rows don't leave a gap when the source provides no metadata.

Mini player
- Forced display: grid with 4 columns: track / controls / progress
  / volume. Was inheriting legacy display:flex which pushed elements
  into a single non-aligned row.
- Position locked: position: fixed, bottom: 0, left/right: 0 with
  !important. The strip is firmly anchored to the viewport bottom.
- Top edge progress (::before) painted in copper with glow.
- Responsive collapse: hide progress at <=880px, hide volume at
  <=540px, leaving track + controls on phones.

i18n
- tab.player default text aligned to "Now Spinning" (matches
  existing en/ru values added earlier).
2026-04-25 01:51:13 +03:00
alexei.dolgolyov 77b39e5684 fix(ui): snap player view directly from Studio Reference mockup
Wholesale replacement of the player view markup + a verbatim mockup
CSS block scoped to .now-playing. Previous approach kept restyling
legacy classes which left a layered, inconsistent result. This is a
clean snap: same DOM structure as the mockup, same CSS rules, mapped
onto existing JS-touched IDs.

Markup
- One <section class="now-playing"> with vinyl-stage on left and
  track-masthead on right
- Vinyl spins via data-playstate and contains album art as the
  circular center label (existing #album-art img preserved)
- SVG tonearm pivots in/out by data-playstate
- Track masthead: .kicker (copper italic mono), large italic-serif
  .track-title, italic .track-byline, mono .track-album
- Meta grid: 2 cells (State / Source) with mono labels + italic
  serif values
- 30 .spectrum bars between metadata and transport
- .transport: progress-row (timecode + .progress-track + timecode)
  and .controls (3 .btn-trans buttons + .vu-cluster)
- Volume slider, mute button, visualizer toggle moved to a
  .visually-hidden block — they remain functional for JS / a11y
  but no longer compete for visual real estate. Volume control
  happens via the always-visible mini player.

VU cluster (mockup-faithful)
- 140x60 VU meter with conic-gradient grid background, copper
  needle, "VU" label
- Stacked readout: "OUT <strong>SYS</strong>" / "VOL <strong>72%</strong>"
- Click anywhere on cluster toggles mute (calls toggleMute())
- When muted: needle turns rust, readout strong turns rust,
  OUT label switches to MUTE

JS hooks
- updatePlaybackState already sets :root[data-playstate] (drives
  spin + tonearm)
- Volume tick now updates #vu-vol and #vu-out
- updateMuteIcon updates #vu-out + .vu-cluster.muted class

Scoping
- All new CSS is .now-playing-prefixed so other tabs and dialogs
  are untouched
- Legacy .progress-bar:hover scaleY and ::after scale(0) are
  defeated with !important inside .now-playing
2026-04-25 01:43:11 +03:00
alexei.dolgolyov d9d4672ca3 fix(ui): drop redundant Elapsed/Length cells; restore timeline
- Meta-grid reduced from 4 cells to 2 (State / Source). Elapsed and
  Length were duplicates of the timecodes already flanking the
  timeline. Added .meta-grid-2 modifier with a 2-column layout.
- Timeline was distorted by legacy rules: .progress-bar:hover did
  scaleY(1.4) (inflating the 2px hairline on hover) and the
  progress-fill::after handle defaulted to scale(0). Both are now
  forced via !important to keep the hairline flat and the copper
  handle always visible.
- progress-row uses minmax(0, 1fr) for the bar cell so it shrinks
  cleanly inside the grid.
- Removed unused meta.elapsed / meta.length keys from en.json + ru.json.
- Dead JS lookups for #meta-elapsed / #meta-length stay (cheap if-checks,
  no-op when DOM elements gone).
2026-04-25 01:35:19 +03:00
alexei.dolgolyov 265b001b99 fix(ui): close gaps with Studio Reference mockup
Side-by-side comparison surfaced several layout regressions vs. the
mockup. This commit lands all of them at once.

Header
- Restore centered "Media Server / Studio Reference Edition" wordmark
  in italic Fraunces
- Move folio marks to fixed page corners (visible on every tab):
  TL = green pulse + "Connected · Local 8765"
  TR = "Vol. I — Studio Reference · v0.x.x"
- Replace boxed version-label badge with copper mono inline in folio.tr
- Reduce header-to-content gap (container padding-top 28→56 with the
  folio now anchored above)

Player view
- Spectrum bars: smaller height (32px), centered with max-width so
  they don't span the whole right column
- Spectrogram canvas: hidden by default (opacity 0); reveals only when
  visualizer toggle is active. No more leaking into bottom-left.
- VU cluster volume controls: strip legacy box (background, padding,
  border-radius); compact stacked layout with thin slider, small mute
  button, mono "VOL · XX%" readout
- Disable legacy applyVinylMode() — the .vinyl class added a SECOND
  rotation animation on top of the structural .vinyl-stage spin,
  causing visible compounding. Vinyl is now purely structural.

Toggles
- Remove vinyl mode toggle button (vinyl is always on)
- Keep audio visualizer (spectrum vis) toggle — still shown by JS
  when supported

Mini player
- Force always-visible on non-player tabs regardless of scroll, by
  short-circuiting setMiniPlayerVisible when activeTab !== 'player'

i18n
- New keys: header.connected, header.volume, header.edition,
  header.edition_sub
- Removed unused: player.folio_left, player.folio_right
- en.json + ru.json updated
2026-04-25 01:32:34 +03:00
alexei.dolgolyov 14e9f2294e feat(ui): rebuild player view to match Studio Reference mockup
Restructures the player tab DOM to actually look like the editorial
mockup, not just inherit new fonts. The previous commit only swapped
tokens & typography on the legacy Spotify-clone layout.

DOM additions (all preserve existing JS-touched IDs):
- Vinyl stage: rotating vinyl wrapping the existing #album-art as a
  circular center label; spins only when state=playing via CSS hook
- SVG tonearm: pivots in/out based on data-playstate
- Kicker line: copper italic mono header above the track title
- Editorial 4-cell metadata grid: State / Source / Elapsed / Length
- Decorative spectrum bars (30, CSS-only animation, paused when idle)
- VU meter cluster: needle visual driven by volume %, alongside the
  preserved volume slider for a11y
- Folio marks: top-left and top-right of the player container

JS hooks (small, additive):
- updatePlaybackState now sets :root[data-playstate] for CSS
- progress tick mirrors timecode into meta-grid cells
- volume update rotates the VU needle
- folio-version mirrors the version label

i18n:
- new keys: player.kicker, player.modes, player.folio_*, meta.*
- added to both en.json and ru.json

Restored: media_server/static/redesign-mockup.html (Studio Reference
visual reference; deleting it in the prior commit was a mistake).
2026-04-25 01:24:11 +03:00
alexei.dolgolyov 8110c152b0 feat(ui): Studio Reference redesign — editorial hi-fi aesthetic
Replaces the Spotify-clone dark theme with a warm editorial design
language inspired by hi-fi audio mastering and magazine layouts.

- Self-host Fraunces, Geist, and Geist Mono as variable WOFF2 files
  (Latin + Latin-ext + Cyrillic subsets, OFL-licensed)
- New design tokens: warm charcoal + copper accent (dark) /
  cream paper + hunter emerald (light)
- Editorial typography: Fraunces serif for display + masthead,
  Geist for UI, Geist Mono for technical readouts (timecodes, bitrates)
- Player view restyled as magazine spread with framed album art
- Mini player as glassy console strip with copper hairline glow
- Tabs as italic editorial nav with copper underline
- Browser items as gallery cards with editorial typography
- Settings as numbered sections with refined tables
- Quick Access as console rail
- Dialogs and auth modal as paper cards with mono kickers
- Subtle film-grain overlay for analog warmth
- Localized tab labels: Player → Now Spinning, Browser → Library
2026-04-25 01:07:45 +03:00
alexei.dolgolyov 21adeb1070 chore: release v0.1.8
Lint & Test / test (push) Successful in 15s
Release / create-release (push) Successful in 8s
Release / build-linux (push) Successful in 40s
Release / build-windows (push) Successful in 1m7s
2026-04-18 19:49:49 +03:00
alexei.dolgolyov 68614c982d fix(windows): keep required numpy submodules in build cleanup
lib, linalg, ma, polynomial, fft, ctypeslib, matrixlib are imported
unconditionally by numpy/__init__.py and must not be trimmed.
2026-04-18 19:49:09 +03:00
alexei.dolgolyov a2a258e898 chore: release v0.1.8
Lint & Test / test (push) Successful in 10s
Release / create-release (push) Successful in 3s
Release / build-linux (push) Successful in 48s
Release / build-windows (push) Successful in 1m19s
2026-04-18 19:30:02 +03:00
alexei.dolgolyov 456eb3a881 fix(windows): fix numpy DLL loading in embedded Python distribution
- Generate numpy/_distributor_init_local.py during build so libopenblas
  can be located when running from the Windows installer
- Add os.add_dll_directory() call at runtime as a fallback for embedded Python
2026-04-18 19:29:39 +03:00
alexei.dolgolyov c586b1b518 chore: release v0.1.8
Lint & Test / test (push) Successful in 10s
Release / create-release (push) Successful in 3s
Release / build-linux (push) Successful in 47s
Release / build-windows (push) Successful in 1m10s
2026-04-18 17:51:46 +03:00
alexei.dolgolyov ee5184920d fix(visualizer): sync state and re-subscribe from audio device load
- Broaden audio import errors from ImportError to Exception, log at warning
- Move visualizer WS re-subscription into loadAudioDevices() so it runs
  after availability is confirmed from the API
- Show/hide the visualizer toggle button based on fetched availability
2026-04-18 17:48:49 +03:00
alexei.dolgolyov af556e0bff chore: release v0.1.7
Lint & Test / test (push) Successful in 14s
Release / create-release (push) Successful in 3s
Release / build-windows (push) Successful in 47s
Release / build-linux (push) Successful in 29s
2026-04-17 23:40:45 +03:00
alexei.dolgolyov 26b4672a99 chore: release v0.1.6
Release / create-release (push) Successful in 3s
Lint & Test / test (push) Successful in 10s
Release / build-linux (push) Successful in 31s
Release / build-windows (push) Successful in 52s
2026-04-11 03:36:55 +03:00
alexei.dolgolyov 2e3bebfeb8 chore: release v0.1.5
Release / create-release (push) Successful in 10s
Lint & Test / test (push) Successful in 15s
Release / build-linux (push) Successful in 48s
Release / build-windows (push) Successful in 1m10s
2026-04-11 02:09:29 +03:00
alexei.dolgolyov 34eb7c7b19 fix(ws): make WebSocket token parameter optional
Required token query param caused connection failures for clients
that authenticate via other means.
2026-04-11 02:04:36 +03:00
alexei.dolgolyov 972ee54b91 chore: release v0.1.5
Release / create-release (push) Successful in 3s
Lint & Test / test (push) Successful in 10s
Release / build-linux (push) Successful in 53s
Release / build-windows (push) Successful in 1m17s
2026-04-11 01:43:43 +03:00
alexei.dolgolyov d09a0b90e4 fix(ws): fetch status eagerly on new WebSocket connection
Instead of waiting for the next poll cycle, new clients now get the
current playback status immediately on connect by calling get_status_func
if no cached status is available yet.
2026-04-11 01:40:40 +03:00
alexei.dolgolyov c3cb7a4da9 fix(dist): stop stripping .py sources; wipe payload on NSIS upgrade
Release / create-release (push) Successful in 5s
Lint & Test / test (push) Successful in 11s
Release / build-linux (push) Successful in 33s
Release / build-windows (push) Successful in 57s
Two root causes for the 'imaging extension was built for another version
of Pillow' error users hit after install:

1) cleanup_site_packages ran 'find ... -name "*.py" ! -name "__init__.py"
   -delete' with a comment claiming 'keep .pyc only' — but no compileall
   step exists. Result: the dist shipped __init__.py + .pyd only, missing
   every submodule (Image.py, ImageDraw.py, _version.py, ...). Fresh
   installs were broken; in-place upgrades produced a half-old/half-new
   site-packages. Removed the deletion entirely.

2) NSIS installer extracted over the previous install without cleaning
   python/, app/, scripts/. Upgrades left stale files (old PIL/_version.py
   next to new PIL/_imaging.pyd) which raised the Pillow ABI mismatch.
   Wipe those subtrees before File /r, preserving config.yaml at the
   install root.
2026-04-07 22:57:26 +03:00
alexei.dolgolyov e3889fef29 chore: release v0.1.4
Lint & Test / test (push) Successful in 10s
Release / create-release (push) Successful in 3s
Release / build-windows (push) Successful in 56s
Release / build-linux (push) Successful in 31s
2026-04-07 22:43:05 +03:00
alexei.dolgolyov 84500401e7 fix(ci): move pystray to VIS_DEPS so its Pillow resolves with core
Release / create-release (push) Successful in 3s
Lint & Test / test (push) Successful in 10s
Release / build-linux (push) Successful in 32s
Release / build-windows (push) Successful in 1m7s
pystray in WIN_DEPS (per-dep loop) downloaded its own Pillow version,
which overwrote the one resolved alongside CORE_DEPS during unzip.
Result at runtime: '_imaging extension was built for another version
of Pillow'.

Move pystray into VIS_DEPS so it's resolved in the single cross-deps
pip-download call and shares one consistent Pillow version.
2026-04-07 22:35:24 +03:00
alexei.dolgolyov 28293c6340 fix(ci): replace uvicorn[standard] with explicit extras for cross-build
Release / create-release (push) Successful in 3s
Lint & Test / test (push) Successful in 10s
Release / build-linux (push) Successful in 42s
Release / build-windows (push) Successful in 1m9s
uvicorn[standard] pulls uvloop via a 'sys_platform != win32' marker.
pip evaluates env markers against the HOST (Linux in CI), so uvloop
is requested even in a --platform win_amd64 resolve. No uvloop wheel
exists for Windows, so pip backtracks across every uvicorn[standard]
version and fails with ResolutionImpossible.

Use plain uvicorn plus the Windows-compatible extras we actually need
(httptools, websockets, python-dotenv).
2026-04-07 22:29:29 +03:00
alexei.dolgolyov 39b3aed5f3 fix(ci): hybrid pip download - single call for cross-platform deps
Release / create-release (push) Successful in 3s
Lint & Test / test (push) Successful in 10s
Release / build-windows (push) Failing after 31s
Release / build-linux (push) Successful in 47s
The per-dep loop regressed pydantic/pydantic-core compatibility:
each dep resolves transitive versions independently, so 'pydantic'
brings core 2.41.5 while 'pydantic-settings' brings core 2.45.0,
and the later wheel overwrites the earlier during site-packages
unzip, producing:
  SystemError: pydantic-core 2.45.0 is incompatible with
  pydantic, which requires 2.41.5

Fix: single pip-download call for CORE_DEPS + VIS_DEPS so pip
resolves compatible transitive versions. Keep the per-dep loop
with --pre only for WIN_DEPS, where each dep needs its own
platform/non-platform fallback and winsdk requires --pre for
its beta wheels.
2026-04-07 22:24:57 +03:00
alexei.dolgolyov ba90dffa18 fix(ci): revert to per-dep pip download loop with --pre
Release / create-release (push) Successful in 4s
Lint & Test / test (push) Successful in 10s
Release / build-linux (push) Successful in 40s
Release / build-windows (push) Successful in 1m17s
Single pip-download call fails because the second fallback branch
(without --platform) tries to resolve Windows-only deps like winsdk
on Linux, where no wheels exist. The original per-dep loop isolates
each failure so the platform-specific branch handles each dep
independently. Add --pre throughout for winsdk (1.0.0bNN beta).
2026-04-07 19:39:38 +03:00
alexei.dolgolyov 69df9b6b95 fix(ci): normalize non-PEP440 versions before stamping pyproject.toml
Lint & Test / test (push) Successful in 16s
If a tag or CI ref is not PEP 440 compliant (e.g. 'dev', 'nightly',
'snapshot-2024'), the previous detect_version stamped it raw into
pyproject.toml, which then broke 'pip install' with:
  configuration error: project.version must be pep440

Add a regex check after stripping the leading 'v'. If the result
is not PEP 440, substitute '0.0.0.dev0' and warn.

Pattern from ClaudeCodeFacts/gitea-python-ci-cd.md §3.
2026-04-07 19:38:15 +03:00
alexei.dolgolyov 760c3df90c fix(ci): pass --pre to pip download for winsdk beta wheels
Release / create-release (push) Successful in 24s
Lint & Test / test (push) Successful in 30s
Release / build-linux (push) Successful in 38s
Release / build-windows (push) Failing after 43s
The single pip-download call regressed winsdk fetching because pip
won't pick up pre-releases (1.0.0bNN) without --pre. The old per-dep
loop hid this via its fallback branch. Add --pre to both branches.
2026-04-07 19:36:49 +03:00
alexei.dolgolyov 60f287bb40 ci: revert action caching, gitea cache backend not configured
Lint & Test / test (push) Has been cancelled
Release / create-release (push) Successful in 36s
Release / build-windows (push) Failing after 1m59s
Release / build-linux (push) Successful in 2m48s
setup-node and actions/cache@v4 hang trying to talk to a missing
cache server, adding 1-3min per step. Drop the cache: directives
and explicit cache blocks. Keep the single pip-download call in
build-dist-windows.sh which is independent of any cache backend.
2026-04-07 19:30:39 +03:00
alexei.dolgolyov f52af51a20 ci: cache pip wheels, npm deps, and embedded Python in release workflow
Lint & Test / test (push) Successful in 18s
Release / create-release (push) Successful in 13s
Release / build-windows (push) Has been cancelled
Release / build-linux (push) Has been cancelled
- Add pip and npm caching to build-windows and build-linux jobs
- Cache embedded Python zip and Windows wheels across runs
- Collapse per-dep pip download loop into a single resolver call

First run after this lands populates the caches; subsequent
release builds should drop from ~11min to ~3-5min.
2026-04-07 19:19:46 +03:00
alexei.dolgolyov f2d569a1b0 chore: release v0.1.3
Release / create-release (push) Successful in 4s
Release / build-windows (push) Has been cancelled
Release / build-linux (push) Has been cancelled
Lint & Test / test (push) Has been cancelled
2026-04-07 19:04:03 +03:00
alexei.dolgolyov db777fa64b fix: prevent dialog showModal from auto-focusing first input
Lint & Test / test (push) Successful in 1m18s
Patches HTMLDialogElement.prototype.showModal globally to move focus
onto the dialog element itself instead of the first focusable
descendant. On touch devices the previous behavior popped up the
on-screen keyboard whenever a modal opened, which was confusing.
2026-04-07 19:01:42 +03:00
alexei.dolgolyov 2961f8eaec chore: release v0.1.2
Release / create-release (push) Successful in 4s
Lint & Test / test (push) Successful in 11s
Release / build-linux (push) Successful in 29s
Release / build-windows (push) Successful in 1m11s
2026-03-29 20:00:38 +03:00
alexei.dolgolyov c50a8f472c fix: make folder status visible with dot + text label
Lint & Test / test (push) Successful in 10s
Status dot was 8x8px with no text, nearly invisible in the table.
Now renders as a colored dot with an adjacent text label
(Available / Unavailable).
2026-03-29 15:07:46 +03:00
alexei.dolgolyov cad6e8a1fe feat: redesign media browser UI
Lint & Test / test (push) Successful in 9s
- Root folder cards with hero-style layout and SVG icons
- Full-width thumbnails with aspect-ratio grid items
- List view column headers (Name, Bitrate, Duration, Size)
- Modernized breadcrumb with pill segments and overflow handling
- Proper skeleton shimmer replacing emoji hourglass on thumbnails
- Pagination shows "Showing X-Y of Z" item count
- Refined hover effects, animations, and visual hierarchy
- Download button revealed on row hover in list view
- Type badges hidden by default, shown on hover
- Localized new keys in en.json and ru.json
2026-03-29 14:59:43 +03:00
alexei.dolgolyov c9ee41ad35 feat: add media folder management from WebUI
Lint & Test / test (push) Successful in 10s
- Add media_folders_management config flag (enabled by default)
- Guard folder CRUD endpoints with 403 when management disabled
- Wire up frontend folder add/edit/delete in Settings tab
- Add per-folder availability check (for network shares)
- Show unavailable badge on offline folders in browser view
- Expose management flag via /api/health endpoint
- Add EN/RU locale keys for folder management UI
2026-03-29 14:44:03 +03:00
alexei.dolgolyov 0256be816e chore: update release notes for v0.1.1
Release / create-release (push) Successful in 3s
Lint & Test / test (push) Successful in 9s
Release / build-linux (push) Successful in 32s
Release / build-windows (push) Successful in 1m10s
2026-03-28 18:53:13 +03:00
alexei.dolgolyov 5219263388 fix: port-in-use check and remove packaging dependency 2026-03-28 18:52:46 +03:00
alexei.dolgolyov 98163ea5a9 chore: update release notes and version for v0.1.1
Release / create-release (push) Successful in 5s
Lint & Test / test (push) Successful in 11s
Release / build-linux (push) Successful in 33s
Release / build-windows (push) Successful in 1m11s
2026-03-28 18:37:56 +03:00
alexei.dolgolyov 5e5e5036c0 fix: use custom icon for Windows shortcuts instead of python.exe
Lint & Test / test (push) Successful in 10s
2026-03-28 18:36:53 +03:00
alexei.dolgolyov 4f9e99e10b ci: add manual build workflow for testing artifacts
Lint & Test / test (push) Failing after 11m21s
workflow_dispatch-triggered build.yml that produces Windows
installer/portable and Linux tarball as CI artifacts without
creating a release. Trigger from Gitea UI → Actions → Run.
2026-03-27 23:44:21 +03:00
alexei.dolgolyov 81d5b0a402 ci: sparse-checkout RELEASE_NOTES.md in create-release job
Release / create-release (push) Successful in 3s
Lint & Test / test (push) Successful in 10s
Release / build-linux (push) Successful in 30s
Release / build-windows (push) Successful in 1m8s
2026-03-25 23:20:00 +03:00
alexei.dolgolyov d67e61ae39 ci: embed RELEASE_NOTES.md in Gitea release body
Lint & Test / test (push) Successful in 11s
Release / create-release (push) Successful in 2s
Release / build-linux (push) Successful in 36s
Release / build-windows (push) Successful in 1m21s
2026-03-25 23:17:01 +03:00
alexei.dolgolyov e795d224a8 chore: update release notes and version for v0.1.0
Lint & Test / test (push) Successful in 19s
Release / create-release (push) Successful in 8s
Release / build-linux (push) Successful in 36s
Release / build-windows (push) Has been cancelled
2026-03-25 23:10:42 +03:00
alexei.dolgolyov d0830cbbe5 ci: use warning annotation for existing release fallback
Lint & Test / test (push) Successful in 10s
2026-03-25 23:02:46 +03:00
alexei.dolgolyov 4ef11c8f00 chore: CI/build improvements and version detection
Lint & Test / test (push) Successful in 10s
- Rename GITEA_TOKEN to DEPLOY_TOKEN in release workflow
- Extract shared version detection into build-common.sh
- Use importlib.metadata for runtime version instead of hardcoded string
- Use PEP 440 parsing (packaging lib) for update version comparison
- Add packaging>=23.0 to dependencies
- Fix update banner close button alignment (CSS)
- Update CLAUDE.md with versioning docs and frontend rebuild notes
2026-03-25 15:43:27 +03:00
alexei.dolgolyov fb56e6cdc0 feat: persist audio capture device selection to config.yaml
Release / create-release (push) Successful in 1s
Lint & Test / test (push) Successful in 9s
Release / build-linux (push) Successful in 26s
Release / build-windows (push) Successful in 1m3s
Device choice now survives server restarts. Falls back to default
if the saved device is no longer available.
2026-03-25 11:50:01 +03:00
alexei.dolgolyov ff6712620e chore: bump version to 1.0.1
Release / create-release (push) Successful in 2s
Lint & Test / test (push) Successful in 38s
Release / build-linux (push) Successful in 2m10s
Release / build-windows (push) Successful in 2m48s
2026-03-25 11:37:50 +03:00
alexei.dolgolyov 795a15cb8b feat: add update-available notification system
Lint & Test / test (push) Successful in 10s
- Abstract ReleaseProvider protocol for platform-agnostic version checking
- GiteaReleaseProvider implementation using stdlib urllib
- UpdateChecker service with periodic background checks and WS broadcast
- Persistent dismissible banner in Web UI when a new version is detected
- Health endpoint now returns cached update info
- Configurable via update_check_enabled and update_check_interval settings
- i18n support (EN/RU)
2026-03-25 11:37:09 +03:00
66 changed files with 13196 additions and 1692 deletions
+16
View File
@@ -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
+72
View File
@@ -0,0 +1,72 @@
name: Build Artifacts
on:
workflow_dispatch:
inputs:
version:
description: 'Version label (e.g. dev, 0.3.0-test)'
required: false
default: 'dev'
jobs:
build-windows:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Build frontend
run: npm ci && npm run build
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install build tools
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends nsis zip
- name: Build Windows distribution
run: |
chmod +x build-dist-windows.sh
./build-dist-windows.sh "v${{ inputs.version }}"
- name: Build NSIS installer
run: makensis -DVERSION="${{ inputs.version }}" installer.nsi
- uses: actions/upload-artifact@v3
with:
name: MediaServer-${{ inputs.version }}-win-x64
path: |
build/MediaServer-*.zip
build/MediaServer-*-setup.exe
retention-days: 90
build-linux:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Build frontend
run: npm ci && npm run build
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Build Linux distribution
run: |
chmod +x build-dist-linux.sh
./build-dist-linux.sh "v${{ inputs.version }}"
- uses: actions/upload-artifact@v3
with:
name: MediaServer-${{ inputs.version }}-linux-x64
path: build/MediaServer-*-linux-x64.tar.gz
retention-days: 90
+90 -19
View File
@@ -13,10 +13,16 @@ jobs:
release_id: ${{ steps.create.outputs.release_id }}
version: ${{ steps.create.outputs.version }}
steps:
- name: Fetch RELEASE_NOTES.md only
uses: actions/checkout@v4
with:
sparse-checkout: RELEASE_NOTES.md
sparse-checkout-cone-mode: false
- name: Create Gitea release
id: create
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: |
TAG="${{ gitea.ref_name }}"
VERSION="${TAG#v}"
@@ -27,21 +33,40 @@ jobs:
IS_PRE="true"
fi
if [ -f RELEASE_NOTES.md ]; then
export RELEASE_NOTES=$(cat RELEASE_NOTES.md)
echo "Found RELEASE_NOTES.md"
else
export RELEASE_NOTES=""
echo "No RELEASE_NOTES.md found"
fi
BODY_JSON=$(python3 -c "
import json, textwrap
import json, os, textwrap
tag = '$TAG'
body = f'''## Downloads
release_notes = os.environ.get('RELEASE_NOTES', '')
sections = []
if release_notes.strip():
sections.append(release_notes.strip())
sections.append(textwrap.dedent(f'''
## Downloads
| Platform | File |
|----------|------|
| Windows (installer) | \`MediaServer-{tag}-setup.exe\` |
| Windows (portable) | \`MediaServer-{tag}-win-x64.zip\` |
| Linux | \`MediaServer-{tag}-linux-x64.tar.gz\` |
'''
print(json.dumps(textwrap.dedent(body).strip()))
''').strip())
print(json.dumps('\n\n'.join(sections)))
")
RELEASE=$(curl -s -X POST "$BASE_URL/releases" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Authorization: token $DEPLOY_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"tag_name\": \"$TAG\",
@@ -62,9 +87,9 @@ jobs:
print(json.dumps(data, indent=2), file=sys.stderr)
sys.exit(1)
" 2>&1) || {
echo "Create failed, fetching existing release for tag $TAG..."
echo "::warning::Release already exists for tag $TAG — reusing existing release"
RELEASE=$(curl -s "$BASE_URL/releases/tags/$TAG" \
-H "Authorization: token $GITEA_TOKEN")
-H "Authorization: token $DEPLOY_TOKEN")
RELEASE_ID=$(echo "$RELEASE" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
}
echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT"
@@ -103,19 +128,45 @@ jobs:
- name: Upload assets to release
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: |
RELEASE_ID="${{ needs.create-release.outputs.release_id }}"
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
upload_asset() {
local file="$1"
local name
name=$(basename "$file")
# Delete existing asset with the same name (idempotent re-runs)
EXISTING=$(curl -s "$BASE_URL/releases/$RELEASE_ID/assets" \
-H "Authorization: token $DEPLOY_TOKEN")
ASSET_ID=$(echo "$EXISTING" | python3 -c "
import sys, json
assets = json.load(sys.stdin)
for a in assets:
if a['name'] == '$name':
print(a['id'])
break
" 2>/dev/null || true)
if [ -n "$ASSET_ID" ]; then
echo "Replacing existing asset: $name (id=$ASSET_ID)"
curl -s -X DELETE "$BASE_URL/releases/$RELEASE_ID/assets/$ASSET_ID" \
-H "Authorization: token $DEPLOY_TOKEN"
fi
echo "Uploading $name..."
curl -s -X POST \
"$BASE_URL/releases/$RELEASE_ID/assets?name=$name" \
-H "Authorization: token $DEPLOY_TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$file"
}
for FILE in build/MediaServer-*.zip build/MediaServer-*-setup.exe; do
[ -f "$FILE" ] || continue
echo "Uploading $(basename "$FILE")..."
curl -s -X POST \
"$BASE_URL/releases/$RELEASE_ID/assets?name=$(basename "$FILE")" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$FILE"
upload_asset "$FILE"
done
# --- Build Linux tarball ---
@@ -143,15 +194,35 @@ jobs:
- name: Upload assets to release
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: |
RELEASE_ID="${{ needs.create-release.outputs.release_id }}"
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
FILE=$(ls build/MediaServer-*-linux-x64.tar.gz | head -1)
echo "Uploading $(basename "$FILE")..."
NAME=$(basename "$FILE")
# Delete existing asset with the same name (idempotent re-runs)
EXISTING=$(curl -s "$BASE_URL/releases/$RELEASE_ID/assets" \
-H "Authorization: token $DEPLOY_TOKEN")
ASSET_ID=$(echo "$EXISTING" | python3 -c "
import sys, json
assets = json.load(sys.stdin)
for a in assets:
if a['name'] == '$NAME':
print(a['id'])
break
" 2>/dev/null || true)
if [ -n "$ASSET_ID" ]; then
echo "Replacing existing asset: $NAME (id=$ASSET_ID)"
curl -s -X DELETE "$BASE_URL/releases/$RELEASE_ID/assets/$ASSET_ID" \
-H "Authorization: token $DEPLOY_TOKEN"
fi
echo "Uploading $NAME..."
curl -s -X POST \
"$BASE_URL/releases/$RELEASE_ID/assets?name=$(basename "$FILE")" \
-H "Authorization: token $GITEA_TOKEN" \
"$BASE_URL/releases/$RELEASE_ID/assets?name=$NAME" \
-H "Authorization: token $DEPLOY_TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$FILE"
+1
View File
@@ -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
+2
View File
@@ -53,3 +53,5 @@ Thumbs.db
# Node.js / esbuild
node_modules/
media_server/static/dist/
# Added by code-review-graph
.code-review-graph/
+12
View File
@@ -0,0 +1,12 @@
{
"mcpServers": {
"code-review-graph": {
"command": "uvx",
"args": [
"code-review-graph",
"serve"
],
"type": "stdio"
}
}
}
+59 -5
View File
@@ -41,10 +41,20 @@ Unregister-ScheduledTask -TaskName "MediaServer" -Confirm:$false
**When restart is NOT needed:**
- Static file changes (`*.html`, `*.css`, `*.js`, `*.json`) - browser refresh is enough
- Static file changes (`*.html`, `*.css`, `*.json`) - browser refresh is enough
- README or documentation updates
- Changes to install/service scripts (only affects new installations)
### Frontend Rebuild After JS Changes
**CRITICAL:** The frontend is bundled via esbuild into `static/dist/app.bundle.js`. After modifying ANY JavaScript file in `media_server/static/js/`, you **MUST** run:
```bash
npm run build
```
Raw JS file edits have **NO effect** until the bundle is rebuilt. After rebuilding, a browser hard-refresh (Ctrl+Shift+R) is sufficient — no server restart needed.
**How to restart during development:**
1. Find the running server process:
@@ -124,12 +134,17 @@ To add support for a new language:
## Versioning
Version is tracked in two files that must be kept in sync:
**`pyproject.toml`** is the single source of truth for the version string.
- `pyproject.toml` - `[project].version`
- `media_server/__init__.py` - `__version__`
At runtime, `media_server/__init__.py` reads the version via `importlib.metadata.version()` — no manual syncing needed.
When releasing a new version, update both files with the same version string.
Version flow:
1. `git tag v0.3.0` → CI reads the tag
2. Build scripts stamp `pyproject.toml` with the clean version via `sed`
3. `pip install` bakes the version into package metadata
4. `importlib.metadata.version("media-server")` reads it at runtime
When bumping the version for a new release, only `pyproject.toml` needs to be updated.
**Important:** After making any changes, always ask the user if the version needs to be incremented.
@@ -181,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.
+65
View File
@@ -0,0 +1,65 @@
## 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
- **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))
---
<details>
<summary>All Commits</summary>
| Hash | Message | Author |
|------|---------|--------|
| [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>
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env bash
# build-common.sh — shared functions for platform build scripts
# Source this file, do not execute directly.
# --- Version detection ---
# Fallback chain: CLI arg → git tag → CI env var → pyproject.toml
detect_version() {
local arg="${1:-}"
VERSION="${arg}"
if [ -z "$VERSION" ]; then
VERSION=$(git describe --tags --exact-match 2>/dev/null || true)
fi
if [ -z "$VERSION" ]; then
VERSION="${GITEA_REF_NAME:-${GITHUB_REF_NAME:-}}"
fi
if [ -z "$VERSION" ]; then
VERSION=$(grep -oP '^version\s*=\s*"\K[^"]+' \
pyproject.toml 2>/dev/null || echo "0.0.0")
fi
VERSION_CLEAN="${VERSION#v}"
# Normalize non-PEP440 labels (e.g. "dev", "nightly", "snapshot") to a
# valid PEP440 dev release. Without this, pip/setuptools rejects
# pyproject.toml with: `project.version` must be pep440.
#
# Valid forms: 1.2.3, 1.2.3a1, 1.2.3rc2, 1.2.3.dev0, 1.2.3.post1, +local
# Invalid forms: dev, vdev, nightly, snapshot-2024
if ! [[ "$VERSION_CLEAN" =~ ^[0-9]+(\.[0-9]+)*((a|b|rc|\.dev|\.post)[0-9]+)*(\+[a-zA-Z0-9.]+)?$ ]]; then
echo " Warning: '$VERSION_CLEAN' is not PEP440-compliant, using 0.0.0.dev0"
VERSION_CLEAN="0.0.0.dev0"
fi
# Stamp version into pyproject.toml (single source of truth)
sed -i "s/^version = .*/version = \"${VERSION_CLEAN}\"/" pyproject.toml
}
# --- Clean dist/build directories ---
clean_dist() {
rm -rf dist build
mkdir -p "$@"
}
# --- Verify frontend bundle exists ---
verify_frontend() {
if [ ! -f "media_server/static/dist/app.bundle.js" ]; then
echo "ERROR: Frontend bundle not found. Run 'npm ci && npm run build' first."
exit 1
fi
}
# --- Copy application files into dist ---
# Args: $1 = DIST_DIR
copy_app_files() {
local dist_dir="$1"
echo "Copying application files..."
mkdir -p "${dist_dir}/app"
cp -r media_server "${dist_dir}/app/"
# Remove source JS (bundle is in dist/)
rm -rf "${dist_dir}/app/media_server/static/js"
# Remove source maps from release
rm -f "${dist_dir}/app/media_server/static/dist/"*.map
# Copy config example
cp config.example.yaml "${dist_dir}/"
# Write version file
echo "$VERSION_CLEAN" > "${dist_dir}/VERSION"
}
# --- Clean up site-packages for smaller distribution ---
# Args: $1 = site-packages path, $2 = ext suffix (pyd|so), $3 = lib suffix (dll|so)
# Windows: cleanup_site_packages "$SP" "pyd" "dll"
# Linux: cleanup_site_packages "$SP" "so" "so"
cleanup_site_packages() {
local sp_dir="$1"
local ext_suffix="${2:-so}"
local lib_suffix="${3:-so}"
echo "Optimizing size..."
# Generic cleanup
find "$sp_dir" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
find "$sp_dir" -type d -name tests -exec rm -rf {} + 2>/dev/null || true
find "$sp_dir" -type d -name "*.dist-info" -exec rm -rf {} + 2>/dev/null || true
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.
# 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
# Trim OpenCV if present
rm -f "$sp_dir"/cv2/opencv_videoio_ffmpeg*."$lib_suffix" 2>/dev/null || true
rm -rf "$sp_dir"/cv2/{data,gapi,misc,utils,typing_stubs,typing} 2>/dev/null || true
# Trim Pillow unused plugins if present
rm -rf "$sp_dir"/PIL/{FpxImagePlugin,MicImagePlugin,McIdasImagePlugin}* 2>/dev/null || true
# Trim zeroconf service DB if present
rm -rf "$sp_dir"/zeroconf/_services 2>/dev/null || true
# Strip debug symbols from native extensions
find "$sp_dir" -name "*.$ext_suffix" -exec strip --strip-debug {} \; 2>/dev/null || true
# NOTE: do NOT strip .py source files. A previous version of this function
# ran `find ... -name "*.py" ! -name "__init__.py" -delete` with a comment
# claiming "keep .pyc only" — but no compileall step exists, so the dist
# shipped with __init__.py + .pyd only, missing every submodule (Image.py,
# ImageDraw.py, _version.py, ...). Fresh installs would fail with
# ModuleNotFoundError; in-place upgrades over an older install produced a
# half-old/half-new site-packages where PIL/__init__.py was new but
# PIL/_version.py was stale, yielding the runtime "_imaging extension was
# built for another version of Pillow" import error.
}
+9 -39
View File
@@ -4,44 +4,24 @@ set -euo pipefail
# Build Linux distribution (self-contained venv + tarball)
# Usage: ./build-dist-linux.sh [VERSION]
# --- Version detection ---
VERSION="${1:-}"
source "$(dirname "$0")/build-common.sh"
if [ -z "$VERSION" ]; then
VERSION=$(git describe --tags --exact-match 2>/dev/null || true)
fi
if [ -z "$VERSION" ]; then
VERSION="${GITEA_REF_NAME:-${GITHUB_REF_NAME:-}}"
fi
if [ -z "$VERSION" ]; then
VERSION=$(grep -oP '__version__\s*=\s*"\K[^"]+' \
media_server/__init__.py 2>/dev/null || echo "0.0.0")
fi
VERSION_CLEAN="${VERSION#v}"
detect_version "${1:-}"
echo "Building Media Server v${VERSION_CLEAN} for Linux"
# --- Configuration ---
DIST_DIR="dist/media-server"
BUILD_OUTPUT="build/MediaServer-v${VERSION_CLEAN}-linux-x64"
rm -rf dist build
mkdir -p "${DIST_DIR}" build
# --- Verify frontend bundle ---
if [ ! -f "media_server/static/dist/app.bundle.js" ]; then
echo "ERROR: Frontend bundle not found. Run 'npm ci && npm run build' first."
exit 1
fi
clean_dist "${DIST_DIR}" build
verify_frontend
# --- Create self-contained virtualenv ---
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*
@@ -49,21 +29,11 @@ rm -rf "${DIST_DIR}"/venv/lib/python*/site-packages/media_server*.dist-info
deactivate
# --- Copy application ---
echo "Copying application files..."
mkdir -p "${DIST_DIR}/app"
cp -r media_server "${DIST_DIR}/app/"
# Trim venv site-packages
LINUX_SP=$(echo "${DIST_DIR}"/venv/lib/python*/site-packages)
cleanup_site_packages "$LINUX_SP" "so" "so"
# Remove source JS (bundle is in dist/)
rm -rf "${DIST_DIR}/app/media_server/static/js"
# Remove source maps from release
rm -f "${DIST_DIR}/app/media_server/static/dist/"*.map
# Copy config example
cp config.example.yaml "${DIST_DIR}/"
# --- Write version ---
echo "$VERSION_CLEAN" > "${DIST_DIR}/VERSION"
copy_app_files "$DIST_DIR"
# --- Create launcher ---
cat > "${DIST_DIR}/media-server.sh" << 'LAUNCHER'
+64 -58
View File
@@ -4,23 +4,9 @@ set -euo pipefail
# Cross-build Windows distribution on Linux
# Usage: ./build-dist-windows.sh [VERSION]
# --- Version detection ---
VERSION="${1:-}"
source "$(dirname "$0")/build-common.sh"
if [ -z "$VERSION" ]; then
VERSION=$(git describe --tags --exact-match 2>/dev/null || true)
fi
if [ -z "$VERSION" ]; then
VERSION="${GITEA_REF_NAME:-${GITHUB_REF_NAME:-}}"
fi
if [ -z "$VERSION" ]; then
VERSION=$(grep -oP '__version__\s*=\s*"\K[^"]+' \
media_server/__init__.py 2>/dev/null || echo "0.0.0")
fi
VERSION_CLEAN="${VERSION#v}"
detect_version "${1:-}"
echo "Building Media Server v${VERSION_CLEAN} for Windows"
# --- Configuration ---
@@ -31,13 +17,17 @@ WHEEL_DIR="build/win-wheels"
SITE_PACKAGES="${DIST_DIR}/python/Lib/site-packages"
BUILD_OUTPUT="build/MediaServer-v${VERSION_CLEAN}-win-x64"
rm -rf dist build
mkdir -p "${DIST_DIR}" "${WHEEL_DIR}" "${SITE_PACKAGES}"
clean_dist "${DIST_DIR}" "${WHEEL_DIR}" "${SITE_PACKAGES}"
# --- Download embedded Python ---
echo "Downloading embedded Python ${PYTHON_VERSION}..."
curl -sL "https://www.python.org/ftp/python/${PYTHON_VERSION}/python-${PYTHON_VERSION}-embed-amd64.zip" \
-o build/python-embed.zip
# --- Download embedded Python (cache-friendly) ---
mkdir -p build
if [ ! -f build/python-embed.zip ]; then
echo "Downloading embedded Python ${PYTHON_VERSION}..."
curl -sL "https://www.python.org/ftp/python/${PYTHON_VERSION}/python-${PYTHON_VERSION}-embed-amd64.zip" \
-o build/python-embed.zip
else
echo "Using cached embedded Python ${PYTHON_VERSION}"
fi
unzip -qo build/python-embed.zip -d "${DIST_DIR}/python"
# Patch ._pth to enable site-packages and app source
@@ -50,9 +40,18 @@ echo '..\app' >> "$PTH_FILE"
echo "Downloading Windows wheels..."
# Core dependencies
# NOTE: uvicorn[standard] pulls in uvloop via `sys_platform != 'win32'` marker.
# pip evaluates env markers against the HOST (Linux in CI), so uvloop is
# requested, but `--platform win_amd64 --only-binary :all:` cannot find a
# Windows wheel for uvloop (none exist). Result: pip backtracks across every
# uvicorn[standard] version and ResolutionImpossible. Fix: use plain uvicorn
# and list only the Windows-compatible standard extras we actually need.
CORE_DEPS=(
"fastapi>=0.109.0"
"uvicorn[standard]>=0.27.0"
"uvicorn>=0.27.0"
"httptools>=0.5.0"
"websockets>=10.4"
"python-dotenv>=0.13"
"pydantic>=2.0"
"pydantic-settings>=2.0"
"pyyaml>=6.0"
@@ -61,30 +60,50 @@ 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"
"pystray>=0.19.0"
)
# Visualizer dependencies
VIS_DEPS=(
"soundcard>=0.4.0"
"numpy>=1.24.0,<2.0"
# pystray lives here (not WIN_DEPS) so its transitive Pillow resolves in the
# same pass as CORE_DEPS. Keeping it in the per-dep WIN_DEPS loop downloaded
# a second Pillow version that clobbered the core one on unzip, producing
# "_imaging extension was built for another version of Pillow" at runtime.
"pystray>=0.19.0"
)
ALL_DEPS=("${CORE_DEPS[@]}" "${WIN_DEPS[@]}" "${VIS_DEPS[@]}")
# Resolve core + visualizer deps in a SINGLE call so pip picks compatible
# transitive versions (notably pydantic/pydantic-core must match).
# Per-dep loops resolve each dep independently and can leave mismatched
# transitive versions that overwrite each other in the site-packages unzip.
CROSS_DEPS=("${CORE_DEPS[@]}" "${VIS_DEPS[@]}")
pip download --quiet --no-cache-dir --dest "$WHEEL_DIR" \
--platform win_amd64 --python-version "${PYTHON_SHORT}" \
--implementation cp --only-binary :all: \
"${CROSS_DEPS[@]}"
for dep in "${ALL_DEPS[@]}"; do
pip download --quiet --no-cache-dir --dest "$WHEEL_DIR" \
# Windows-only deps in a loop with --pre: winsdk only ships beta wheels
# (1.0.0bNN) and each dep needs its own platform/non-platform fallback.
for dep in "${WIN_DEPS[@]}"; do
pip download --pre --quiet --no-cache-dir --dest "$WHEEL_DIR" \
--platform win_amd64 --python-version "${PYTHON_SHORT}" \
--implementation cp --only-binary :all: \
"$dep" 2>/dev/null || \
pip download --quiet --no-cache-dir --dest "$WHEEL_DIR" \
pip download --pre --quiet --no-cache-dir --dest "$WHEEL_DIR" \
--only-binary :all: \
"$dep"
done
@@ -100,43 +119,30 @@ for whl in "$WHEEL_DIR"/*.whl; do
unzip -qo "$whl" -d "$SITE_PACKAGES"
done
# --- Size optimization ---
echo "Optimizing size..."
find "$SITE_PACKAGES" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
find "$SITE_PACKAGES" -type d -name tests -exec rm -rf {} + 2>/dev/null || true
find "$SITE_PACKAGES" -type d -name "*.dist-info" -exec rm -rf {} + 2>/dev/null || true
find "$SITE_PACKAGES" -name "*.pyi" -delete 2>/dev/null || true
rm -rf "$SITE_PACKAGES"/{pip,setuptools,pkg_resources}* 2>/dev/null || true
# Trim numpy if present
rm -rf "$SITE_PACKAGES"/numpy/{tests,f2py,typing} 2>/dev/null || true
# --- Verify frontend bundle ---
if [ ! -f "media_server/static/dist/app.bundle.js" ]; then
echo "ERROR: Frontend bundle not found. Run 'npm ci && npm run build' first."
exit 1
# 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
# --- Copy application ---
echo "Copying application files..."
mkdir -p "${DIST_DIR}/app"
cp -r media_server "${DIST_DIR}/app/"
# Remove source JS (bundle is in dist/)
rm -rf "${DIST_DIR}/app/media_server/static/js"
# Remove source maps from release
rm -f "${DIST_DIR}/app/media_server/static/dist/"*.map
# Copy config example
cp config.example.yaml "${DIST_DIR}/"
cleanup_site_packages "$SITE_PACKAGES" "pyd" "dll"
verify_frontend
copy_app_files "$DIST_DIR"
# Copy scripts needed for auto-start
mkdir -p "${DIST_DIR}/scripts"
cp scripts/start-hidden.vbs "${DIST_DIR}/scripts/"
# --- Write version ---
echo "$VERSION_CLEAN" > "${DIST_DIR}/VERSION"
# --- Create launcher ---
cat > "${DIST_DIR}/media-server.bat" << 'LAUNCHER'
@echo off
+5
View File
@@ -56,6 +56,11 @@ scripts:
timeout: 10
shell: true
# Media folder management from Web UI (default: true)
# When enabled, media folders can be added, edited, and deleted from the Settings tab.
# Set to false to disable folder management from the UI.
# media_folders_management: false
# Callback scripts (executed after media actions)
# All callbacks are optional - if not defined, the action runs without callback
callbacks:
+17 -4
View File
@@ -70,6 +70,19 @@ Section "!Core (required)" SecCore
SetOutPath "$INSTDIR"
; Wipe previous payload before extracting so stale files from an older
; version cannot survive an upgrade. Without this, in-place upgrades
; produce a half-old/half-new site-packages — e.g. an old PIL/_version.py
; alongside a new PIL/_imaging.pyd, which raises "_imaging extension was
; built for another version of Pillow" at runtime. config.yaml lives at
; $INSTDIR root and is preserved.
RMDir /r "$INSTDIR\python"
RMDir /r "$INSTDIR\app"
RMDir /r "$INSTDIR\scripts"
Delete "$INSTDIR\${EXENAME}"
Delete "$INSTDIR\VERSION"
Delete "$INSTDIR\config.example.yaml"
; Copy entire distribution
File /r "dist\media-server\*.*"
@@ -84,10 +97,10 @@ Section "!Core (required)" SecCore
CreateDirectory "$SMPROGRAMS\${APPNAME}"
CreateShortcut "$SMPROGRAMS\${APPNAME}\${APPNAME}.lnk" \
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
"$INSTDIR\python\python.exe" 0
"$INSTDIR\app\media_server\static\icons\icon.ico" 0
CreateShortcut "$SMPROGRAMS\${APPNAME}\${APPNAME} (Console).lnk" \
"$INSTDIR\${EXENAME}" "" \
"$INSTDIR\python\python.exe" 0
"$INSTDIR\app\media_server\static\icons\icon.ico" 0
CreateShortcut "$SMPROGRAMS\${APPNAME}\Uninstall.lnk" \
"$INSTDIR\uninstall.exe"
@@ -117,14 +130,14 @@ SectionEnd
Section "Desktop shortcut" SecDesktop
CreateShortcut "$DESKTOP\${APPNAME}.lnk" \
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
"$INSTDIR\python\python.exe" 0
"$INSTDIR\app\media_server\static\icons\icon.ico" 0
SectionEnd
Section "Start with Windows" SecAutostart
; Create Startup folder shortcut (runs hidden via VBS)
CreateShortcut "$SMSTARTUP\${APPNAME}.lnk" \
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
"$INSTDIR\python\python.exe" 0
"$INSTDIR\app\media_server\static\icons\icon.ico" 0
SectionEnd
; --- Section descriptions ---
+35 -1
View File
@@ -1,3 +1,37 @@
"""Media Server - REST API for controlling system media playback."""
__version__ = "1.0.0"
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. 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
# 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()
return "0.0.0-dev"
__version__ = _detect_version()
+84 -22
View File
@@ -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(
@@ -124,6 +155,10 @@ class Settings(BaseSettings):
default_factory=dict,
description="Media folders available for browsing in the media browser",
)
media_folders_management: bool = Field(
default=True,
description="Allow adding, editing, and deleting media folders from the Web UI",
)
# Thumbnail settings
thumbnail_size: str = Field(
@@ -159,6 +194,17 @@ class Settings(BaseSettings):
description="Loopback audio device name for visualizer (None = auto-detect)",
)
# Update checker
update_check_enabled: bool = Field(
default=True,
description="Check for new versions on startup and periodically",
)
update_check_interval: int = Field(
default=21600,
description="Update check interval in seconds (default: 6 hours)",
ge=600,
)
@classmethod
def load_from_yaml(cls, path: Optional[Path] = None) -> "Settings":
"""Load settings from a YAML configuration file."""
@@ -203,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!",
@@ -225,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()
+154 -381
View File
@@ -1,52 +1,50 @@
"""Thread-safe configuration file manager for runtime script updates."""
"""Thread-safe configuration file manager for runtime updates."""
import logging
import 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,394 +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")
data = self._load()
if value is None:
data.pop(key, None)
else:
data[key] = value
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()
+125 -9
View File
@@ -2,6 +2,7 @@
import argparse
import logging
import socket
import sys
from contextlib import asynccontextmanager
from pathlib import Path
@@ -62,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")
@@ -74,6 +75,36 @@ async def lifespan(app: FastAPI):
await ws_manager.start_status_monitor(controller.get_status)
logger.info("WebSocket status monitor started")
# Start update checker
update_checker = None
if settings.update_check_enabled:
from .services.gitea_release_provider import GiteaReleaseProvider
from .services.update_checker import UpdateChecker
provider = GiteaReleaseProvider()
update_checker = UpdateChecker(provider, __version__)
await update_checker.start(settings.update_check_interval)
# 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:
@@ -92,6 +123,17 @@ async def lifespan(app: FastAPI):
yield
# Stop update checker
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:
@@ -100,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":
@@ -121,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):
@@ -230,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:
@@ -243,6 +320,45 @@ 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:
sock.bind((args.host if args.host != "0.0.0.0" else "127.0.0.1", args.port))
except OSError:
print(
f"ERROR: Port {args.port} is already in use. "
f"Another instance of Media Server may be running.\n"
f"Stop the other process or use --port to pick a different port.",
file=sys.stderr,
)
sys.exit(1)
from .tray import PYSTRAY_AVAILABLE, TrayManager
use_tray = PYSTRAY_AVAILABLE and not args.no_tray
+119 -82
View File
@@ -23,22 +23,49 @@ 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."""
if not settings.media_folders_management:
raise HTTPException(
status_code=403,
detail="Media folder management is disabled. Set media_folders_management: true in config.yaml to enable.",
)
async def _broadcast_after_open(controller, label: str, max_wait: float = 2.0) -> None:
"""Poll until media session registers, then broadcast status update.
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}")
@@ -65,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):
@@ -83,17 +115,22 @@ async def list_folders(_: str = Depends(verify_token)):
"""List all configured media folders.
Returns:
Dictionary of folder configurations.
Dictionary with folder configurations and management flag.
"""
folders = {}
for folder_id, config in settings.media_folders.items():
folder_path = Path(config.path)
folders[folder_id] = {
"id": folder_id,
"label": config.label,
"path": config.path,
"enabled": config.enabled,
"available": folder_path.is_dir(),
}
return folders
return {
"folders": folders,
"management_enabled": settings.media_folders_management,
}
@router.post("/folders/create")
@@ -112,9 +149,12 @@ async def create_folder(
Raises:
HTTPException: If folder already exists or validation fails.
"""
_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",
@@ -169,6 +209,7 @@ async def update_folder(
Raises:
HTTPException: If folder doesn't exist or validation fails.
"""
_require_folder_management()
try:
# Validate path exists
path = Path(request.path)
@@ -217,6 +258,7 @@ async def delete_folder(
Raises:
HTTPException: If folder doesn't exist.
"""
_require_folder_management()
try:
config_manager.delete_media_folder(folder_id)
@@ -260,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
@@ -290,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:
@@ -335,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:
@@ -403,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:
@@ -472,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()
@@ -500,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,
+27 -4
View File
@@ -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
+80 -6
View File
@@ -1,5 +1,6 @@
"""Display brightness and power control API endpoints."""
"""Display brightness, power, contrast, input-source, color-preset and picture-mode API."""
import asyncio
import logging
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}
+14 -4
View File
@@ -3,23 +3,33 @@
import platform
from typing import Any
from fastapi import APIRouter
from fastapi import APIRouter, Request
from .. import __version__
from ..auth import auth_enabled
from ..config import settings
router = APIRouter(prefix="/api", tags=["health"])
@router.get("/health")
async def health_check() -> dict[str, Any]:
async def health_check(request: Request) -> dict[str, Any]:
"""Health check endpoint - no authentication required.
Returns:
Health status and server information
"""
return {
result: dict[str, Any] = {
"status": "healthy",
"platform": platform.system(),
"version": "1.0.0",
"version": __version__,
"auth_required": auth_enabled(),
"media_folders_management": settings.media_folders_management,
}
# Include cached update info if available
checker = getattr(request.app.state, "update_checker", None)
if checker is not None and checker.cached_update is not None:
result["update_available"] = checker.cached_update
return result
+49 -13
View File
@@ -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:
+9 -3
View File
@@ -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)
@@ -307,6 +307,12 @@ async def set_visualizer_device(
# set_device() handles stop/start internally if capture was running
success = analyzer.set_device(device_name)
# Persist selection to config.yaml so it survives server restarts
if success:
from ..config_manager import config_manager
config_manager.set_setting("visualizer_device", device_name)
return {
"success": success,
"current_device": analyzer.current_device,
@@ -317,7 +323,7 @@ async def set_visualizer_device(
@router.websocket("/ws")
async def websocket_endpoint(
websocket: WebSocket,
token: str = Query(..., description="API authentication token"),
token: str | None = Query(None, description="API authentication token"),
) -> None:
"""WebSocket endpoint for real-time media status updates.
+32 -5
View File
@@ -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
+126 -19
View File
@@ -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
+20 -6
View File
@@ -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
+403 -22
View File
@@ -1,4 +1,4 @@
"""Display brightness and power control service."""
"""Display brightness, power, contrast, input-source and color-preset control."""
import ctypes
import ctypes.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
@@ -0,0 +1,77 @@
"""Gitea release provider implementation."""
import json
import logging
import urllib.error
import urllib.request
from typing import Optional
from .release_provider import ReleaseInfo, ReleaseProvider
logger = logging.getLogger(__name__)
# Default repository coordinates
_DEFAULT_BASE_URL = "https://git.dolgolyov-family.by"
_DEFAULT_OWNER = "alexei.dolgolyov"
_DEFAULT_REPO = "media-player-server"
class GiteaReleaseProvider(ReleaseProvider):
"""Fetches the latest release from a Gitea repository."""
def __init__(
self,
base_url: str = _DEFAULT_BASE_URL,
owner: str = _DEFAULT_OWNER,
repo: str = _DEFAULT_REPO,
timeout: float = 10.0,
) -> None:
self._api_url = f"{base_url}/api/v1/repos/{owner}/{repo}/releases"
self._release_page_url = f"{base_url}/{owner}/{repo}/releases/tag"
self._timeout = timeout
async def get_latest_release(self) -> Optional[ReleaseInfo]:
"""Fetch the latest stable release from Gitea API.
Returns:
ReleaseInfo for the latest non-prerelease, or None on failure.
"""
import asyncio
try:
data = await asyncio.to_thread(self._fetch_releases)
except Exception as e:
logger.warning("Failed to check for updates: %s", e)
return None
if not data:
return None
# Find the first non-prerelease, non-draft release
for release in data:
if release.get("draft") or release.get("prerelease"):
continue
tag = release.get("tag_name", "")
version = tag.lstrip("v")
if not version:
continue
return ReleaseInfo(
version=version,
url=f"{self._release_page_url}/{tag}",
prerelease=False,
)
logger.debug("No stable releases found")
return None
def _fetch_releases(self) -> list[dict]:
"""Synchronous HTTP fetch of releases (run in thread)."""
url = f"{self._api_url}?limit=5"
req = urllib.request.Request(url, headers={"Accept": "application/json"})
try:
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
return json.loads(resp.read().decode())
except (urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError, OSError) as e:
raise RuntimeError(f"Gitea API request failed: {e}") from e
+24 -68
View File
@@ -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).
+29
View File
@@ -0,0 +1,29 @@
"""Abstract release provider interface for version checking."""
from dataclasses import dataclass
from typing import Protocol
@dataclass(frozen=True)
class ReleaseInfo:
"""Version-provider-agnostic release metadata."""
version: str # e.g. "1.1.0" (no "v" prefix)
url: str # release page URL
prerelease: bool
class ReleaseProvider(Protocol):
"""Abstract interface for fetching the latest release.
Implement this protocol to support different hosting platforms
(Gitea, GitHub, GitLab, etc.).
"""
async def get_latest_release(self) -> ReleaseInfo | None:
"""Fetch the latest stable release.
Returns:
ReleaseInfo if a release was found, None on failure.
"""
...
+1 -1
View File
@@ -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,
+169
View File
@@ -0,0 +1,169 @@
"""Provider-agnostic update checker service."""
import asyncio
import logging
import re
from functools import total_ordering
from typing import Any, Optional
from .release_provider import ReleaseProvider
from .websocket_manager import ws_manager
logger = logging.getLogger(__name__)
_PRE_PATTERN = re.compile(
r"^(\d+\.\d+\.\d+)[-.]?(alpha|beta|rc)[.-]?(\d+)$", re.IGNORECASE
)
_PRE_ORDER = {"alpha": 0, "beta": 1, "rc": 2}
@total_ordering
class _Version:
"""Lightweight PEP 440-ish version for comparison without packaging dep.
Supports: X.Y.Z and X.Y.Z-{alpha,beta,rc}.N
Pre-releases sort before the corresponding stable release.
"""
__slots__ = ("_release", "_pre")
def __init__(self, release: tuple[int, ...], pre: Optional[tuple[int, int]]) -> None:
self._release = release
self._pre = pre
def __eq__(self, other: object) -> bool:
if not isinstance(other, _Version):
return NotImplemented
return self._release == other._release and self._pre == other._pre
def __lt__(self, other: object) -> bool:
if not isinstance(other, _Version):
return NotImplemented
if self._release != other._release:
return self._release < other._release
# No pre-release (stable) is greater than any pre-release
if self._pre is None and other._pre is None:
return False
if self._pre is not None and other._pre is None:
return True
if self._pre is None and other._pre is not None:
return False
return self._pre < other._pre # type: ignore[operator]
def __repr__(self) -> str:
v = ".".join(str(p) for p in self._release)
if self._pre is not None:
labels = {0: "alpha", 1: "beta", 2: "rc"}
v += f"-{labels[self._pre[0]]}.{self._pre[1]}"
return f"_Version('{v}')"
def _parse_version(raw: str) -> _Version:
"""Parse a version tag for comparison.
Examples:
v0.3.0-alpha.1 → (0,3,0) pre=(0,1) (sorts below 0.3.0)
v0.3.0-rc.3 → (0,3,0) pre=(2,3)
v1.0.0 → (1,0,0) pre=None
"""
cleaned = raw.lstrip("v").strip()
m = _PRE_PATTERN.match(cleaned)
if m:
base = tuple(int(x) for x in m.group(1).split("."))
pre_label = m.group(2).lower()
pre_num = int(m.group(3))
return _Version(base, (_PRE_ORDER[pre_label], pre_num))
release = tuple(int(x) for x in cleaned.split("."))
return _Version(release, None)
class UpdateChecker:
"""Periodically checks for new releases using a ReleaseProvider."""
def __init__(self, provider: ReleaseProvider, current_version: str) -> None:
self._provider = provider
self._current_version = current_version
self._current_parsed = _parse_version(current_version)
self._task: Optional[asyncio.Task] = None
self._cached_update: Optional[dict[str, Any]] = None
@property
def cached_update(self) -> Optional[dict[str, Any]]:
"""Return the cached update info, or None if up-to-date."""
return self._cached_update
async def check_for_update(self) -> Optional[dict[str, Any]]:
"""Check for a newer release.
Returns:
Dict with current/latest/url if an update exists, None otherwise.
"""
release = await self._provider.get_latest_release()
if release is None:
return None
latest_parsed = _parse_version(release.version)
if latest_parsed <= self._current_parsed:
return None
return {
"current": self._current_version,
"latest": release.version,
"url": release.url,
}
async def start(self, interval: int) -> None:
"""Start periodic update checking.
Checks immediately on start, then every `interval` seconds.
"""
if self._task is not None:
return
self._task = asyncio.create_task(self._check_loop(interval))
logger.info("Update checker started (interval: %ds)", interval)
async def stop(self) -> None:
"""Stop periodic update checking."""
if self._task is not None:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
logger.info("Update checker stopped")
async def _check_loop(self, interval: int) -> None:
"""Background loop that checks for updates periodically."""
# Initial check with a small delay to let the server finish starting
await asyncio.sleep(5)
while True:
try:
update = await self.check_for_update()
if update and update != self._cached_update:
self._cached_update = update
logger.info(
"New version available: %s%s (%s)",
update["current"],
update["latest"],
update["url"],
)
await ws_manager.broadcast(
{"type": "update_available", "data": update}
)
elif update is None and self._cached_update is not None:
# Version was updated (or release removed) — clear cache
self._cached_update = None
except asyncio.CancelledError:
break
except Exception as e:
logger.warning("Update check failed: %s", e)
try:
await asyncio.sleep(interval)
except asyncio.CancelledError:
break
+60 -19
View File
@@ -19,6 +19,7 @@ class ConnectionManager:
self._active_connections: set[WebSocket] = set()
self._lock = asyncio.Lock()
self._last_status: dict[str, Any] | None = None
self._get_status_func: Callable[[], Coroutine[Any, Any, Any]] | None = None
self._broadcast_task: asyncio.Task | None = None
self._poll_interval: float = 0.5 # Internal poll interval for change detection
self._position_broadcast_interval: float = 5.0 # Send position updates every 5s during playback
@@ -39,9 +40,17 @@ class ConnectionManager:
)
# Send current status immediately upon connection
if self._last_status:
status = self._last_status
if not status and self._get_status_func:
try:
await websocket.send_json({"type": "status", "data": self._last_status})
result = await self._get_status_func()
status = result.model_dump()
self._last_status = status
except Exception as e:
logger.debug("Failed to fetch initial status: %s", e)
if status:
try:
await websocket.send_json({"type": "status", "data": status})
except Exception as e:
logger.debug("Failed to send initial status: %s", e)
@@ -61,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)
@@ -120,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)")
@@ -130,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)")
@@ -152,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=(',', ':'))
@@ -189,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]
@@ -251,6 +291,7 @@ class ConnectionManager:
if self._running:
return
self._get_status_func = get_status_func
self._running = True
self._broadcast_task = asyncio.create_task(
self._status_monitor_loop(get_status_func)
+67 -79
View File
@@ -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
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.
+261 -122
View File
@@ -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,120 +115,198 @@
<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>
</header>
<!-- Update Banner -->
<div class="update-banner hidden" id="updateBanner">
<span id="updateBannerText"></span>
<a id="updateBannerLink" href="#" target="_blank" rel="noopener noreferrer" data-i18n="update.view_release">View Release</a>
<button class="update-banner-close" id="updateBannerClose">&times;</button>
</div>
<!-- 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 -->
@@ -228,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>
@@ -276,13 +366,14 @@
<!-- 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>
@@ -307,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>
@@ -316,6 +407,39 @@
</div>
</details>
<details class="settings-section" open id="mediaFoldersSection" style="display: none;">
<summary data-i18n="settings.section.media_folders">Media Folders</summary>
<div class="settings-section-content">
<p class="settings-section-description" data-i18n="browser.folders_description">
Media folders available for browsing. Folders on network shares show availability status.
</p>
<table class="scripts-table">
<thead>
<tr>
<th data-i18n="browser.folders_table.id">ID</th>
<th data-i18n="browser.folders_table.label">Label</th>
<th data-i18n="browser.folders_table.path">Path</th>
<th data-i18n="browser.folders_table.status">Status</th>
<th data-i18n="browser.folders_table.actions">Actions</th>
</tr>
</thead>
<tbody id="foldersTableBody">
<tr>
<td colspan="5" class="empty-state">
<div class="empty-state-illustration">
<svg viewBox="0 0 64 64"><path d="M8 16h20l4-6h20a4 4 0 014 4v36a4 4 0 01-4 4H8a4 4 0 01-4-4V20a4 4 0 014-4z"/><path d="M4 24h56" stroke-dasharray="4 3" opacity="0.4"/></svg>
<p data-i18n="browser.folders_empty">No media folders configured. Click "+" to add one.</p>
</div>
</td>
</tr>
</tbody>
</table>
<div class="add-card" data-onclick="showAddFolderDialog()">
<span class="add-card-icon">+</span>
</div>
</div>
</details>
<details class="settings-section" open>
<summary data-i18n="settings.section.scripts">Scripts</summary>
<div class="settings-section-content">
@@ -340,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>
@@ -372,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>
@@ -398,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>
@@ -427,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">
@@ -469,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>
@@ -484,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>
@@ -502,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">
@@ -540,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>
@@ -551,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">
@@ -586,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>
@@ -613,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>
@@ -622,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">
@@ -649,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>
@@ -667,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>
+156 -9
View File
@@ -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
@@ -57,10 +58,13 @@ import {
onBrowserSearch, clearBrowserSearch, onItemsPerPageChanged,
downloadFile, closeFolderDialog, saveFolder,
showManageFoldersDialog,
showAddFolderDialog, showEditFolderDialog, deleteFolderConfirm,
} from './browser.js';
import {
loadDisplayMonitors, onDisplayBrightnessInput, onDisplayBrightnessChange,
onDisplayContrastInput, onDisplayContrastChange,
onDisplayInputSourceChange, onDisplayColorPresetChange, onDisplayPictureModeChange,
toggleDisplayPower, loadHeaderLinks, loadLinksTable,
showAddLinkDialog, showEditLinkDialog, closeLinkDialog, saveLink, deleteLinkConfirm,
linkFormDirty, setLinkFormDirty,
@@ -95,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
@@ -117,39 +123,152 @@ Object.assign(window, {
onBrowserSearch, clearBrowserSearch, onItemsPerPageChanged,
downloadFile, closeFolderDialog, saveFolder,
showManageFoldersDialog,
showAddFolderDialog, showEditFolderDialog, deleteFolderConfirm,
// Links
showAddLinkDialog, showEditLinkDialog, closeLinkDialog,
saveLink, deleteLinkConfirm,
// Display
loadDisplayMonitors, onDisplayBrightnessInput, onDisplayBrightnessChange,
onDisplayContrastInput, onDisplayContrastChange,
onDisplayInputSourceChange, onDisplayColorPresetChange, onDisplayPictureModeChange,
toggleDisplayPower,
// Audio device
onAudioDeviceChanged,
// About
showAboutDialog, closeAboutDialog,
});
// ============================================================
// Initialization (DOMContentLoaded)
// ============================================================
// Prevent <dialog>.showModal() from auto-focusing the first input field.
// On touch devices this pops up the on-screen keyboard, which is confusing
// when the user just opened a dialog. Force focus onto the dialog itself.
const _origShowModal = HTMLDialogElement.prototype.showModal;
HTMLDialogElement.prototype.showModal = function (...args) {
if (!this.hasAttribute('tabindex')) {
this.setAttribute('tabindex', '-1');
}
const result = _origShowModal.apply(this, args);
const active = document.activeElement;
if (active && active !== this && this.contains(active)) {
active.blur();
this.focus({ preventScroll: true });
}
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();
}
});
@@ -323,6 +442,24 @@ window.addEventListener('DOMContentLoaded', async () => {
else if (action === 'delete') deleteCallbackConfirm(name);
});
// Folder dialog backdrop click to close
const folderDialog = document.getElementById('folderDialog');
folderDialog.addEventListener('click', (e) => {
if (e.target === folderDialog) {
closeFolderDialog();
}
});
// Delegated click handlers for folder table actions
document.getElementById('foldersTableBody').addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
const folderId = btn.dataset.folderId;
if (action === 'edit') showEditFolderDialog(folderId);
else if (action === 'delete') deleteFolderConfirm(folderId);
});
// Link dialog backdrop click to close
const linkDialog = document.getElementById('linkDialog');
linkDialog.addEventListener('click', (e) => {
@@ -331,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]');
@@ -352,7 +499,7 @@ window.addEventListener('DOMContentLoaded', async () => {
// Initialize browser toolbar and load folders
initBrowserToolbar();
if (token) {
if (!authReq || token) {
loadMediaFolders();
}
+34 -8
View File
@@ -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);
+263 -44
View File
@@ -3,7 +3,7 @@
// ============================================================
import {
t, showToast, escapeHtml, closeDialog,
t, showToast, showConfirm, escapeHtml, closeDialog,
SEARCH_DEBOUNCE_MS, EMPTY_SVG_FILE, EMPTY_SVG_FOLDER, emptyStateHtml,
getAuthHeaders, hasCredentials,
} from './core.js';
@@ -15,6 +15,7 @@ let currentOffset = 0;
let itemsPerPage = parseInt(localStorage.getItem('mediaBrowser.itemsPerPage')) || 100;
let totalItems = 0;
let mediaFolders = {};
let managementEnabled = false;
let viewMode = localStorage.getItem('mediaBrowser.viewMode') || 'grid';
let cachedItems = null;
let browserSearchTerm = '';
@@ -33,7 +34,20 @@ export async function loadMediaFolders() {
if (!response.ok) throw new Error('Failed to load folders');
mediaFolders = await response.json();
const data = await response.json();
mediaFolders = data.folders || {};
managementEnabled = data.management_enabled || false;
// Show/hide the media folders settings section
const section = document.getElementById('mediaFoldersSection');
if (section) {
section.style.display = managementEnabled ? '' : 'none';
}
// Render folders table in settings if management is enabled
if (managementEnabled) {
loadFoldersTable();
}
// Load last browsed path or show root folder list
loadLastBrowserPath();
@@ -52,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
@@ -69,41 +85,48 @@ function showRootFolders() {
revokeBlobUrls(container);
if (viewMode === 'list') {
container.className = 'browser-list';
} else if (viewMode === 'compact') {
container.className = 'browser-grid browser-grid-compact';
} else {
container.className = 'browser-grid';
container.className = 'browser-grid browser-root-grid';
}
container.innerHTML = '';
const folderSvg = '<svg viewBox="0 0 24 24" width="24" height="24"><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>';
Object.entries(mediaFolders).forEach(([id, folder]) => {
if (!folder.enabled) return;
const unavailable = folder.available === false;
const unavailableClass = unavailable ? ' unavailable' : '';
if (viewMode === 'list') {
const row = document.createElement('div');
row.className = 'browser-list-item';
row.onclick = () => {
currentFolderId = id;
browsePath(id, '');
};
row.className = 'browser-list-item' + unavailableClass;
if (!unavailable) {
row.onclick = () => {
currentFolderId = id;
browsePath(id, '');
};
}
row.innerHTML = `
<div class="browser-list-icon">\u{1F4C1}</div>
<div class="browser-list-name">${folder.label}</div>
<div class="browser-list-icon" style="color: var(--accent)">${folderSvg}</div>
<div class="browser-list-name">${escapeHtml(folder.label)}${unavailable ? ' <span class="folder-unavailable-badge">' + t('browser.unavailable') + '</span>' : ''}</div>
`;
container.appendChild(row);
} else {
const card = document.createElement('div');
card.className = 'browser-item';
card.onclick = () => {
currentFolderId = id;
browsePath(id, '');
};
card.className = 'browser-item browser-root-folder' + unavailableClass;
if (!unavailable) {
card.onclick = () => {
currentFolderId = id;
browsePath(id, '');
};
}
card.innerHTML = `
<div class="browser-thumb-wrapper">
<div class="browser-icon">\u{1F4C1}</div>
<div class="browser-icon" style="color: var(--accent)">${folderSvg}</div>
</div>
<div class="browser-item-info">
<div class="browser-item-name">${folder.label}</div>
<div class="browser-item-name">${escapeHtml(folder.label)}</div>
${unavailable ? '<div class="browser-item-meta folder-unavailable-badge">' + t('browser.unavailable') + '</div>' : ''}
</div>
`;
container.appendChild(card);
@@ -112,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;
@@ -174,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);
@@ -248,6 +276,19 @@ function renderBrowserList(items, container) {
return;
}
// Column header row
const header = document.createElement('div');
header.className = 'browser-list-header';
header.innerHTML = `
<span></span>
<span>${t('browser.list_header.name')}</span>
<span>${t('browser.list_header.bitrate')}</span>
<span>${t('browser.list_header.duration')}</span>
<span>${t('browser.list_header.size')}</span>
<span></span>
`;
container.appendChild(header);
items.forEach((item, idx) => {
const row = document.createElement('div');
row.className = 'browser-list-item';
@@ -478,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');
};
@@ -495,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) {
@@ -514,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()) {
@@ -530,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) {
@@ -545,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');
}
}
@@ -567,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');
@@ -662,6 +726,7 @@ function renderPagination() {
const nextBtn = document.getElementById('nextPage');
const pageInput = document.getElementById('pageInput');
const pageTotal = document.getElementById('pageTotal');
const showingEl = document.getElementById('paginationShowing');
const totalPages = Math.ceil(totalItems / itemsPerPage);
const currentPage = Math.floor(currentOffset / itemsPerPage) + 1;
@@ -676,6 +741,13 @@ function renderPagination() {
pageInput.max = totalPages;
pageTotal.textContent = `/ ${totalPages}`;
// "Showing X-Y of Z"
if (showingEl) {
const from = currentOffset + 1;
const to = Math.min(currentOffset + itemsPerPage, totalItems);
showingEl.textContent = t('browser.showing_items', { from, to, total: totalItems });
}
prevBtn.disabled = currentPage === 1;
nextBtn.disabled = currentPage === totalPages;
}
@@ -845,10 +917,72 @@ function loadLastBrowserPath() {
}
}
// Folder Management
export function showManageFoldersDialog() {
// TODO: Implement folder management UI
showToast(t('browser.manage_folders_hint'), 'info');
// Folder Management — Settings table
export function loadFoldersTable() {
const tbody = document.getElementById('foldersTableBody');
if (!tbody) return;
const entries = Object.entries(mediaFolders);
if (entries.length === 0) {
tbody.innerHTML = `<tr><td colspan="5" class="empty-state">
<div class="empty-state-illustration">
<svg viewBox="0 0 64 64"><path d="M8 16h20l4-6h20a4 4 0 014 4v36a4 4 0 01-4 4H8a4 4 0 01-4-4V20a4 4 0 014-4z"/><path d="M4 24h56" stroke-dasharray="4 3" opacity="0.4"/></svg>
<p data-i18n="browser.folders_empty">${t('browser.folders_empty')}</p>
</div></td></tr>`;
return;
}
tbody.innerHTML = entries.map(([id, folder]) => {
const available = folder.available !== false;
const statusIcon = available
? '<span class="status-dot status-online">' + t('browser.folder_available') + '</span>'
: '<span class="status-dot status-offline">' + t('browser.folder_unavailable') + '</span>';
const enabledBadge = folder.enabled
? ''
: ' <span class="folder-disabled-badge">' + t('browser.folder_disabled') + '</span>';
return `<tr>
<td>${escapeHtml(id)}${enabledBadge}</td>
<td>${escapeHtml(folder.label)}</td>
<td class="path-cell" title="${escapeHtml(folder.path)}">${escapeHtml(folder.path)}</td>
<td>${statusIcon}</td>
<td class="actions-cell">
<button class="btn-icon" data-action="edit" data-folder-id="${escapeHtml(id)}" title="${t('browser.folder_edit')}">
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
</button>
<button class="btn-icon btn-danger-icon" data-action="delete" data-folder-id="${escapeHtml(id)}" title="${t('browser.folder_delete')}">
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
</button>
</td>
</tr>`;
}).join('');
}
export function showAddFolderDialog() {
document.getElementById('folderDialogTitle').textContent = t('browser.folder_dialog.title_add');
document.getElementById('folderIsEdit').value = '';
document.getElementById('folderOriginalId').value = '';
document.getElementById('folderId').value = '';
document.getElementById('folderId').disabled = false;
document.getElementById('folderLabel').value = '';
document.getElementById('folderPath').value = '';
document.getElementById('folderEnabled').checked = true;
document.getElementById('folderDialog').showModal();
}
export function showEditFolderDialog(folderId) {
const folder = mediaFolders[folderId];
if (!folder) return;
document.getElementById('folderDialogTitle').textContent = t('browser.folder_dialog.title_edit');
document.getElementById('folderIsEdit').value = '1';
document.getElementById('folderOriginalId').value = folderId;
document.getElementById('folderId').value = folderId;
document.getElementById('folderId').disabled = true;
document.getElementById('folderLabel').value = folder.label;
document.getElementById('folderPath').value = folder.path;
document.getElementById('folderEnabled').checked = folder.enabled;
document.getElementById('folderDialog').showModal();
}
export function closeFolderDialog() {
@@ -857,5 +991,90 @@ export function closeFolderDialog() {
export async function saveFolder(event) {
event.preventDefault();
closeFolderDialog();
const isEdit = document.getElementById('folderIsEdit').value === '1';
const folderId = isEdit
? document.getElementById('folderOriginalId').value
: document.getElementById('folderId').value.trim();
const label = document.getElementById('folderLabel').value.trim();
const path = document.getElementById('folderPath').value.trim();
const enabled = document.getElementById('folderEnabled').checked;
if (!folderId || !label || !path) return;
const submitBtn = document.querySelector('#folderForm button[type="submit"]');
if (submitBtn) submitBtn.disabled = true;
try {
let response;
if (isEdit) {
response = await fetch(`/api/browser/folders/update/${encodeURIComponent(folderId)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
body: JSON.stringify({ label, path, enabled }),
});
} else {
response = await fetch('/api/browser/folders/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
body: JSON.stringify({ folder_id: folderId, label, path, enabled }),
});
}
if (response.ok) {
closeFolderDialog();
showToast(t(isEdit ? 'browser.folder_updated' : 'browser.folder_created'), 'success');
await loadMediaFolders();
} else {
const result = await response.json().catch(() => ({}));
showToast(result.detail || t('browser.folder_save_error'), 'error');
}
} catch (error) {
console.error('Error saving folder:', error);
showToast(t('browser.folder_save_error'), 'error');
} finally {
if (submitBtn) submitBtn.disabled = false;
}
}
export async function deleteFolderConfirm(folderId) {
if (!await showConfirm(t('browser.folder_confirm_delete', { name: folderId }))) {
return;
}
try {
const response = await fetch(`/api/browser/folders/delete/${encodeURIComponent(folderId)}`, {
method: 'DELETE',
headers: getAuthHeaders(),
});
if (response.ok) {
showToast(t('browser.folder_deleted'), 'success');
await loadMediaFolders();
} else {
const result = await response.json().catch(() => ({}));
showToast(result.detail || t('browser.folder_delete_error'), 'error');
}
} catch (error) {
console.error('Error deleting folder:', error);
showToast(t('browser.folder_delete_error'), 'error');
}
}
// Legacy stub — now handled via settings table
export function showManageFoldersDialog() {
if (managementEnabled) {
// Switch to settings tab and scroll to the folders section
const switchTabFn = window.switchTab;
if (switchTabFn) switchTabFn('settings');
setTimeout(() => {
const section = document.getElementById('mediaFoldersSection');
if (section) {
section.setAttribute('open', '');
section.scrollIntoView({ behavior: 'smooth' });
}
}, 100);
} else {
showToast(t('browser.manage_folders_hint'), 'info');
}
}
+13 -12
View 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');
}
}
+100 -8
View File
@@ -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,11 @@ 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);
}
}
} catch (error) {
@@ -324,6 +335,26 @@ export async function fetchVersion() {
}
}
export function showUpdateBanner(update) {
const dismissed = sessionStorage.getItem('update_dismissed');
if (dismissed === update.latest) return;
const banner = document.getElementById('updateBanner');
const text = document.getElementById('updateBannerText');
const link = document.getElementById('updateBannerLink');
const closeBtn = document.getElementById('updateBannerClose');
text.textContent = t('update.available', { version: update.latest });
link.href = update.url;
link.textContent = t('update.view_release');
banner.classList.remove('hidden');
closeBtn.onclick = () => {
banner.classList.add('hidden');
sessionStorage.setItem('update_dismissed', update.latest);
};
}
// ============================================================
// Shared Utilities
// ============================================================
@@ -367,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');
@@ -473,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');
@@ -496,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
View File
@@ -1,12 +1,107 @@
// ============================================================
// Display Brightness & Power Control + Links Management
// Display Brightness, Power, Contrast, Input Source, Color Preset,
// Picture Mode Control + Links Management
// ============================================================
import { t, showToast, escapeHtml, closeDialog, showConfirm, resolveMdiIcons, fetchMdiIcon, getAuthHeaders, hasCredentials } from './core.js';
import { 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()
});
File diff suppressed because it is too large Load Diff
+28 -27
View File
@@ -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() }
});
+79 -20
View File
@@ -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,
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);
@@ -100,6 +127,8 @@ export function connectWebSocket(token) {
loadHeaderLinks();
loadLinksTable();
displayQuickAccess();
} else if (msg.type === 'update_available') {
showUpdateBanner(msg.data);
} else if (msg.type === 'audio_data') {
setFrequencyData(msg.data);
} else if (msg.type === 'error') {
@@ -117,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'));
@@ -134,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 || '');
@@ -146,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);
}
@@ -183,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();
});
+73 -5
View File
@@ -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",
@@ -173,7 +209,27 @@
"browser.play_all_error": "Failed to play folder",
"browser.error_loading": "Error loading directory",
"browser.error_loading_folders": "Failed to load media folders",
"browser.manage_folders_hint": "Folder management coming soon! For now, edit config.yaml to add media folders.",
"browser.manage_folders_hint": "Folder management is disabled. Set media_folders_management: true in config.yaml to enable.",
"browser.unavailable": "Unavailable",
"browser.folder_available": "Available",
"browser.folder_unavailable": "Unavailable (path not reachable)",
"browser.folder_disabled": "disabled",
"browser.folder_edit": "Edit folder",
"browser.folder_delete": "Delete folder",
"browser.folder_created": "Media folder created successfully",
"browser.folder_updated": "Media folder updated successfully",
"browser.folder_deleted": "Media folder deleted successfully",
"browser.folder_save_error": "Failed to save media folder",
"browser.folder_delete_error": "Failed to delete media folder",
"browser.folder_confirm_delete": "Are you sure you want to delete the folder \"{name}\"?",
"browser.folders_description": "Media folders available for browsing. Folders on network shares show availability status.",
"browser.folders_empty": "No media folders configured. Click \"+\" to add one.",
"browser.folders_table.id": "ID",
"browser.folders_table.label": "Label",
"browser.folders_table.path": "Path",
"browser.folders_table.status": "Status",
"browser.folders_table.actions": "Actions",
"settings.section.media_folders": "Media Folders",
"browser.folder_dialog.title_add": "Add Media Folder",
"browser.folder_dialog.title_edit": "Edit Media Folder",
"browser.folder_dialog.folder_id": "Folder ID *",
@@ -185,6 +241,11 @@
"browser.folder_dialog.enabled": "Enabled",
"browser.folder_dialog.cancel": "Cancel",
"browser.folder_dialog.save": "Save",
"browser.list_header.name": "Name",
"browser.list_header.bitrate": "Bitrate",
"browser.list_header.duration": "Duration",
"browser.list_header.size": "Size",
"browser.showing_items": "Showing {from}\u2013{to} of {total}",
"browser.download_error": "Failed to download file",
"connection.reconnecting": "Connection lost. Reconnecting (attempt {attempt})...",
"connection.lost": "Connection lost. Server may be unavailable.",
@@ -223,6 +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"
}
+73 -5
View File
@@ -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": "Управление папками",
@@ -173,7 +209,27 @@
"browser.play_all_error": "Не удалось воспроизвести папку",
"browser.error_loading": "Ошибка загрузки каталога",
"browser.error_loading_folders": "Не удалось загрузить медиа папки",
"browser.manage_folders_hint": "Управление папками скоро появится! Пока редактируйте config.yaml для добавления медиа папок.",
"browser.manage_folders_hint": "Управление папками отключено. Установите media_folders_management: true в config.yaml для включения.",
"browser.unavailable": "Недоступна",
"browser.folder_available": "Доступна",
"browser.folder_unavailable": "Недоступна (путь не найден)",
"browser.folder_disabled": "отключена",
"browser.folder_edit": "Редактировать папку",
"browser.folder_delete": "Удалить папку",
"browser.folder_created": "Медиа папка успешно создана",
"browser.folder_updated": "Медиа папка успешно обновлена",
"browser.folder_deleted": "Медиа папка успешно удалена",
"browser.folder_save_error": "Не удалось сохранить медиа папку",
"browser.folder_delete_error": "Не удалось удалить медиа папку",
"browser.folder_confirm_delete": "Вы уверены, что хотите удалить папку \"{name}\"?",
"browser.folders_description": "Медиа папки для просмотра. Для сетевых ресурсов показан статус доступности.",
"browser.folders_empty": "Медиа папки не настроены. Нажмите \"+\" для добавления.",
"browser.folders_table.id": "ID",
"browser.folders_table.label": "Метка",
"browser.folders_table.path": "Путь",
"browser.folders_table.status": "Статус",
"browser.folders_table.actions": "Действия",
"settings.section.media_folders": "Медиа папки",
"browser.folder_dialog.title_add": "Добавить медиа папку",
"browser.folder_dialog.title_edit": "Редактировать медиа папку",
"browser.folder_dialog.folder_id": "ID папки *",
@@ -185,6 +241,11 @@
"browser.folder_dialog.enabled": "Включено",
"browser.folder_dialog.cancel": "Отмена",
"browser.folder_dialog.save": "Сохранить",
"browser.list_header.name": "Название",
"browser.list_header.bitrate": "Битрейт",
"browser.list_header.duration": "Длительность",
"browser.list_header.size": "Размер",
"browser.showing_items": "Показано {from}\u2013{to} из {total}",
"browser.download_error": "Не удалось скачать файл",
"connection.reconnecting": "Соединение потеряно. Переподключение (попытка {attempt})...",
"connection.lost": "Соединение потеряно. Сервер может быть недоступен.",
@@ -223,6 +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
+4 -6
View File
@@ -1,6 +1,8 @@
// Minimal service worker for PWA installability.
// Minimal service worker for PWA installability only.
// This app requires a live WebSocket connection, so offline caching is not useful.
// 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 &amp; 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&#8209;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>
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "media-server-frontend",
"version": "1.0.0",
"version": "0.2.5",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "media-server-frontend",
"version": "1.0.0",
"version": "0.2.5",
"devDependencies": {
"esbuild": "^0.27.4"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "media-server-frontend",
"version": "1.0.0",
"version": "0.2.5",
"private": true,
"description": "Frontend build tooling for media server WebUI",
"scripts": {
+4 -5
View File
@@ -1,6 +1,6 @@
[project]
name = "media-server"
version = "1.0.0"
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
View File
@@ -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."
}
View File
+152
View File
@@ -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