Compare commits

...

73 Commits

Author SHA1 Message Date
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
alexei.dolgolyov 1410a8d2cb feat: typed script parameters with validation and icon-grid selector
Release / create-release (push) Successful in 1s
Lint & Test / test (push) Successful in 10s
Release / build-linux (push) Successful in 36s
Release / build-windows (push) Successful in 1m15s
- Add ScriptParameterConfig model (string, integer, float, boolean, select types)
- Server-side validation at both define-time and execute-time
- Parameters passed as SCRIPT_PARAM_* environment variables
- Web UI parameter editor in script create/edit dialog (add/remove/reorder)
- Icon-grid selector component (ported from wled-screen-controller)
- Replace audio device dropdown with icon-grid selector
- Replace callback event dropdown with icon-grid selector
- Localization for parameter UI (en, ru)
2026-03-25 11:25:03 +03:00
alexei.dolgolyov 1c0a011342 feat: tint slider tracks with 15% accent color
Lint & Test / test (push) Successful in 9s
2026-03-24 15:59:55 +03:00
alexei.dolgolyov 2b1e09ded9 feat: add Swagger API docs button to header toolbar
Lint & Test / test (push) Successful in 9s
2026-03-24 15:58:01 +03:00
alexei.dolgolyov 415231f2f2 fix: tray restart uses python -m for reliable process respawn
Release / create-release (push) Successful in 1s
Lint & Test / test (push) Successful in 15s
Release / build-linux (push) Successful in 32s
Release / build-windows (push) Successful in 1m8s
The previous os.execv approach and console_script detection both
failed on Windows. Now restart always spawns `python -m media_server.main`
via subprocess.Popen with start_new_session, which works regardless
of how the server was originally started.
2026-03-24 15:26:14 +03:00
alexei.dolgolyov 32e2ff532d fix: add --only-binary to pip download fallback (CI compatibility)
Lint & Test / test (push) Successful in 9s
Release / create-release (push) Successful in 1s
Release / build-linux (push) Successful in 37s
Release / build-windows (push) Successful in 1m15s
2026-03-24 15:07:33 +03:00
alexei.dolgolyov 309f547a5e feat: add default MDI icons to example config scripts
Lint & Test / test (push) Successful in 9s
2026-03-24 15:07:09 +03:00
alexei.dolgolyov 402183765c fix: tray main-thread message loop, numpy <2.0 pin, installer config copy
Lint & Test / test (push) Successful in 9s
Release / create-release (push) Successful in 1s
Release / build-windows (push) Failing after 30s
Release / build-linux (push) Successful in 35s
- Rewrite tray to run on main thread (pystray owns message loop, uvicorn
  in background thread) — fixes unresponsive confirmation dialogs
- Use native Windows MessageBoxW instead of tkinter (embedded Python
  has no tkinter)
- Pin numpy <2.0 to fix soundcard's numpy.fromstring (removed in 2.0)
- Strip transitive numpy 2.x wheels in build script
- Installer copies config.example.yaml as config.yaml on fresh install
- Suppress noisy screen_brightness_control warnings
2026-03-24 15:05:36 +03:00
alexei.dolgolyov d7e10b1005 fix: interpolate tag in release body template (f-string)
Lint & Test / test (push) Successful in 10s
Release / create-release (push) Successful in 1s
Release / build-linux (push) Successful in 33s
Release / build-windows (push) Successful in 1m8s
2026-03-24 14:26:14 +03:00
alexei.dolgolyov 3f14512e5d feat: add Restart and Shutdown tray actions with confirmation dialogs
Lint & Test / test (push) Successful in 24s
Release / create-release (push) Successful in 1s
Release / build-linux (push) Successful in 31s
Release / build-windows (push) Successful in 1m13s
2026-03-24 14:19:15 +03:00
alexei.dolgolyov 26b5f74c24 feat: improve installer with custom icon, launch-after-install, and running-instance detection
Lint & Test / test (push) Successful in 9s
- Use custom icon.ico for installer/uninstaller UI
- LaunchApp opens server then browser after install
- .onInit detects running instance and offers to stop it
- Use WMIC-based process kill targeting embedded Python path
- start-hidden.vbs prefers embedded Python over system Python
- Add pystray dependency to build script
- CLAUDE.md: note to consult CI/CD guide for build changes
2026-03-24 12:48:31 +03:00
alexei.dolgolyov 1f6e4f6d55 feat: add Launch option to installer finish page
Lint & Test / test (push) Successful in 9s
2026-03-23 14:05:57 +03:00
alexei.dolgolyov 6500d6f615 feat: add system tray icon with Show UI and Exit actions
Lint & Test / test (push) Successful in 9s
Adds pystray-based tray icon (green play button) that runs alongside
uvicorn. Double-click opens the web UI in the browser, Exit triggers
graceful shutdown. Disabled with --no-tray flag for headless/service mode.
2026-03-23 14:05:13 +03:00
alexei.dolgolyov 4d1bb78c83 feat: make authentication optional — no tokens = no auth
Lint & Test / test (push) Successful in 10s
When no api_tokens are configured (the new default), all endpoints
are accessible without authentication. The frontend detects this
via /api/health's auth_required field and skips the login form.

- Backend: auth.py skips verification when api_tokens is empty
- Frontend: shared getAuthHeaders()/hasCredentials() helpers replace
  scattered token logic across all JS modules
- Health endpoint exposes auth_required for frontend discovery
- config.example.yaml ships with tokens commented out
- CLI --show-token and startup log reflect disabled state

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 13:59:55 +03:00
alexei.dolgolyov f80f6e9299 fix: correct ._pth path in Windows build script
Lint & Test / test (push) Successful in 10s
Release / create-release (push) Successful in 2s
Release / build-linux (push) Successful in 35s
Release / build-windows (push) Successful in 1m28s
..\..\app was resolving two levels up from python/ dir instead of one.
Changed to ..\app so embedded Python finds the media_server package.
2026-03-23 13:35:17 +03:00
alexei.dolgolyov 02168519b7 docs: comprehensive README update with all API endpoints and features
Lint & Test / test (push) Successful in 9s
- Add display control API endpoints and monitor response schema
- Add header links section with CRUD API and configuration
- Add visualizer API endpoints (status, devices, device selection)
- Add album artwork endpoint documentation
- Add audio devices endpoint
- Add script/callback CRUD endpoints (create, update, delete)
- Add browser folder management CRUD endpoints
- Add play-folder and download endpoints
- Add WebSocket protocol documentation (all message types)
- Add full config.yaml example with all sections
- Document Dynamic WebGL background feature
- Fix all markdown lint warnings (duplicate headings, table alignment,
  missing code fence languages, blank lines around fences)
2026-03-23 02:33:03 +03:00
alexei.dolgolyov c76ffb9997 fix: handle existing release in create-release job
Lint & Test / test (push) Successful in 9s
Release / create-release (push) Successful in 1s
Release / build-linux (push) Successful in 25s
Release / build-windows (push) Successful in 58s
If the Gitea release already exists for a tag (e.g. from a retried
workflow), fall back to fetching the existing release ID instead of
failing with KeyError.
2026-03-23 02:24:50 +03:00
alexei.dolgolyov ddd8788701 Add Linux build to release workflow, fix pytest exit code 5
Lint & Test / test (push) Successful in 10s
Release / create-release (push) Failing after 1s
Release / build-windows (push) Has been skipped
Release / build-linux (push) Has been skipped
- Add build-dist-linux.sh: venv-based tarball with systemd installer
- Add build-linux job to release.yml (parallel with build-windows)
- Include Linux download in release body
- Allow pytest to pass when no tests are collected (exit code 5)
2026-03-23 02:04:06 +03:00
alexei.dolgolyov 5439af1955 Add CI/CD pipelines, NSIS installer, ES module bundling, and ruff linting
Lint & Test / test (push) Failing after 9s
Release / create-release (push) Successful in 1s
Release / build-windows (push) Successful in 59s
- Add Gitea Actions workflows: test.yml (lint + test on push/PR) and
  release.yml (build + NSIS installer + upload on v* tags)
- Add NSIS installer with optional desktop shortcut and auto-start
- Add esbuild bundler: ES module migration with IIFE bundle output
- Add build-dist-windows.sh for cross-building Windows distribution
- Fix all ruff lint errors (import sorting, unused imports, line length)
- Remove redundant scripts (start-server.bat, stop-server.bat,
  start-server-background.vbs)
- Update CLAUDE.md with CI/CD and release documentation
2026-03-23 02:01:28 +03:00
alexei.dolgolyov be48318212 Add dynamic WebGL background with audio reactivity
- WebGL shader background with flowing waves, radial pulse, and frequency ring arcs
- Reacts to captured audio data (frequency bands + bass) when visualizer is active
- Uses page accent color; adapts to dark/light theme via bg-primary blending
- Toggle button in header toolbar, state persisted in localStorage
- Cached uniform locations and color values to avoid per-frame getComputedStyle calls
- i18n support for EN/RU locales

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 01:07:46 +03:00
alexei.dolgolyov 0eca8292cb Fix loopback device status showing 'Unavailable' after change
The POST /visualizer/device response has 'success' but no 'available'
field, causing updateAudioDeviceStatus to always fall to 'Unavailable'.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:17:13 +03:00
alexei.dolgolyov 3cfc437599 Add UI animations: dialogs, tabs, settings, browser stagger, banner pulse
- Dialog modals: scale+fade entrance/exit with animated backdrop
- Tab panels: fade-in with subtle slide on switch
- Settings sections: content slide-down on expand
- Browser grid/list items: staggered cascade entrance animation
- Connection banner: slide-in + attention pulse on disconnect
- Accessibility: prefers-reduced-motion disables all animations

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:45:02 +03:00
alexei.dolgolyov a20812ec29 Add PWA support: installable standalone app with safe area handling
- Service worker, manifest, and SVG icon for PWA installability
- Root /sw.js route for full-scope service worker registration
- Meta tags: theme-color, apple-mobile-web-app, viewport-fit=cover
- Safe area insets for notched phones (container, mini-player, footer, banner)
- Dynamic theme-color sync on light/dark toggle
- Overscroll prevention and touch-action optimization
- Hide mini-player prev/next buttons on small screens
- Updated README with PWA and new feature documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 13:17:56 +03:00
alexei.dolgolyov 652f10fc4c Reduce visualizer latency, tighten UI paddings, fix mobile browser toolbar
- Visualizer: FPS 25→30, chunk_size 2048→1024, smoothing 0.65→0.15
- Beat effect: scale 0.03→0.04, glow range 0.5-0.8→0.4-0.8
- UI: reduce container/section paddings from 2rem to 1rem
- Source name: add ellipsis overflow for long names
- Mobile browser toolbar: use flex-wrap instead of column stack,
  hide "Items per page" label text on small screens

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 12:35:23 +03:00
alexei.dolgolyov 3846610042 On-demand audio visualizer capture + UI fixes
- Audio capture starts only when first client subscribes,
  stops when last client unsubscribes (saves CPU/battery)
- Add lifecycle lock to AudioAnalyzer for thread-safe start/stop
- Status badge uses local visualizer state instead of server flag
- Fix script name vertical text break on narrow screens
- Fix script grid minimum column width on small viewports

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:34:17 +03:00
alexei.dolgolyov 92d6709d58 Refactor monolithic app.js into 8 modular files
Split 3803-line app.js into focused modules:
- core.js: shared state, utilities, i18n, API commands, MDI icons
- player.js: tabs, theme, accent, vinyl, visualizer, UI updates
- websocket.js: connection, auth, reconnection
- scripts.js: scripts CRUD, quick access, execution dialog
- callbacks.js: callbacks CRUD
- browser.js: media file browser, thumbnails, pagination, search
- links.js: links CRUD, header links, display controls
- main.js: DOMContentLoaded init orchestrator

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:34:01 +03:00
alexei.dolgolyov 9404b37f05 Codebase audit fixes: stability, performance, accessibility
- Fix CORS: set allow_credentials=False (token auth, not cookies)
- Add threading.Lock for position cache thread safety
- Add shutdown_executor() for clean ThreadPoolExecutor cleanup
- Dedicated ThreadPoolExecutors for script/callback execution
- Fix Mutagen file handle leaks with try/finally close
- Reduce idle WebSocket polling (0.5s → 2.0s when no clients)
- Add :focus-visible styles for playback control buttons
- Add aria-label to icon-only header buttons
- Dynamic album art alt text for screen readers
- Persist MDI icon cache to localStorage

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 12:10:24 +03:00
alexei.dolgolyov 73a6f387e1 Add friendly media source names with brand icons
- Registry of 17 popular media apps (browsers, players, streaming)
- Substring matching resolves raw process names to friendly names
- Brand-colored SVG icons displayed inline next to source name
- Russian locale support for Yandex Music (Яндекс Музыка)
- Unknown sources fall back to .exe-stripped name

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 11:03:46 +03:00
alexei.dolgolyov b11edc25b9 Redesign header as pill-shaped toolbar group
- Unified header-toolbar container with border and rounded corners
- Consistent header-btn styling for all action buttons
- Compact locale select, separator before logout icon
- Header links integrate as part of the toolbar with divider

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 00:01:55 +03:00
alexei.dolgolyov 3d01d98da0 Style audio device select, hide mini player volume on tablet
- Native select with explicit font stack and focus glow
- Hide mini player volume section below 900px

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 22:10:28 +03:00
alexei.dolgolyov 4112367175 Add 3D album art rotation and vinyl desaturation effect
- Subtle oscillating Y/X rotation with perspective for depth
- Enhanced vinyl mode filter: more desaturation + sepia warmth

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 21:52:27 +03:00
alexei.dolgolyov 00d313daa1 Fix vinyl angle persistence on toggle, group player toggle buttons
- Save vinyl rotation angle before flipping vinylMode flag off
- Wrap vinyl + visualizer buttons in .player-toggles container
- Move margin-left:auto from individual buttons to group

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 21:44:48 +03:00
alexei.dolgolyov 0691e3d338 Add audio visualizer with spectrogram, beat-reactive art, and device selection
- New audio_analyzer service: loopback capture via soundcard + numpy FFT
- Real-time spectrogram bars below album art with accent color gradient
- Album art and vinyl pulse to bass energy beats
- WebSocket subscriber pattern for opt-in audio data streaming
- Audio device selection in Settings tab with auto-detect fallback
- Optimized FFT pipeline: vectorized cumsum bin grouping, pre-serialized JSON broadcast
- Visualizer config: enabled/fps/bins/device in config.yaml
- Optional deps: soundcard + numpy (graceful degradation if missing)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 21:42:19 +03:00
68 changed files with 10350 additions and 4115 deletions
+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
+228
View File
@@ -0,0 +1,228 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
# --- Create Gitea release ---
create-release:
runs-on: ubuntu-latest
outputs:
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:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: |
TAG="${{ gitea.ref_name }}"
VERSION="${TAG#v}"
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
IS_PRE="false"
if echo "$TAG" | grep -qE '(alpha|beta|rc)'; then
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, os, textwrap
tag = '$TAG'
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\` |
''').strip())
print(json.dumps('\n\n'.join(sections)))
")
RELEASE=$(curl -s -X POST "$BASE_URL/releases" \
-H "Authorization: token $DEPLOY_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"tag_name\": \"$TAG\",
\"name\": \"Media Server $TAG\",
\"body\": $BODY_JSON,
\"draft\": false,
\"prerelease\": $IS_PRE
}")
# Extract release ID; if creation failed (already exists), fetch existing
RELEASE_ID=$(echo "$RELEASE" | python3 -c "
import sys, json
data = json.load(sys.stdin)
if 'id' in data:
print(data['id'])
else:
print('FAILED', file=sys.stderr)
print(json.dumps(data, indent=2), file=sys.stderr)
sys.exit(1)
" 2>&1) || {
echo "::warning::Release already exists for tag $TAG — reusing existing release"
RELEASE=$(curl -s "$BASE_URL/releases/tags/$TAG" \
-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"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
# --- Build Windows installer + portable ZIP ---
build-windows:
needs: create-release
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 "${{ gitea.ref_name }}"
- name: Build NSIS installer
run: |
VERSION="${{ needs.create-release.outputs.version }}"
makensis -DVERSION="${VERSION}" installer.nsi
- name: Upload assets to release
env:
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
upload_asset "$FILE"
done
# --- Build Linux tarball ---
build-linux:
needs: create-release
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 "${{ gitea.ref_name }}"
- name: Upload assets to release
env:
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)
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"
+35
View File
@@ -0,0 +1,35 @@
name: Lint & Test
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
test:
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 dependencies
run: |
pip install --upgrade pip
pip install -e ".[dev]"
- name: Lint
run: ruff check media_server/
- name: Test
run: pytest --tb=short -q || test $? -eq 5
+4
View File
@@ -49,3 +49,7 @@ Thumbs.db
# Thumbnail cache
.cache/
# Node.js / esbuild
node_modules/
media_server/static/dist/
+64 -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,15 +134,64 @@ 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.
## CI/CD
Gitea Actions workflow at `.gitea/workflows/test.yml` runs on every push/PR to `master`:
1. **Lint** — `ruff check media_server/` (rules: E, F, I, W)
2. **Test** — `pytest --tb=short -q`
Release workflow at `.gitea/workflows/release.yml` triggers on `v*` tags:
1. **Create release** — Gitea release via REST API (detects pre-release from tag)
2. **Build Windows** — cross-builds on Linux using embedded Python + NSIS installer
3. **Upload assets** — portable ZIP + installer `.exe` attached to the release
### Releasing
```bash
# Stable release
git tag v1.0.0 && git push origin v1.0.0
# Pre-release
git tag v1.1.0-alpha.1 && git push origin v1.1.0-alpha.1
```
### Installer
The NSIS installer (`installer.nsi`) installs to `%LOCALAPPDATA%\Media Server` (no admin required) with optional:
- **Desktop shortcut**
- **Start with Windows** (Startup folder shortcut, runs hidden via VBS)
Uninstall preserves `config.yaml` (user data).
Reference: [gitea-python-ci-cd.md](https://git.dolgolyov-family.by/alexei.dolgolyov/claude-code-facts/src/branch/main/gitea-python-ci-cd.md)
**IMPORTANT:** When modifying CI/CD workflows, `installer.nsi`, or build scripts (`build-dist-*.sh`), always fetch and consult the guide above first to ensure changes stay in sync with established patterns.
### Before Pushing
Ensure CI will pass locally:
```bash
ruff check media_server/
pytest --tb=short -q
```
## Git Rules
- **ALWAYS ask for user approval before committing and pushing changes.**
+347 -121
View File
@@ -5,9 +5,14 @@ A REST API server for controlling system media playback on Windows, Linux, macOS
## Features
- **Built-in Web UI** for real-time media control and monitoring
- **Installable PWA** - Add to home screen on mobile for a native app experience
- **Audio Visualizer** - Real-time spectrum analyzer with beat-reactive album art effects
- **Dynamic WebGL Background** - Audio-reactive animated background with album art color extraction
- **Media Browser** - Browse and play media files from configured folders
- **Display Control** - Monitor brightness and power management
- **Quick Actions & Scripts** - Execute custom scripts with one click
- **Callbacks** - Trigger commands on media events (play, pause, volume, etc.)
- **Header Links** - Configurable quick-access links in the UI header
- Control any media player via system-wide media transport controls
- Play/Pause/Stop/Next/Previous track
- Volume control and mute
@@ -25,7 +30,7 @@ The media server includes a built-in web interface for controlling and monitorin
![Web UI](docs/web-ui.PNG)
### Features
### Web UI Highlights
- **Real-time status updates** via WebSocket connection
- **Album artwork display** with glow effect and automatic updates
@@ -36,21 +41,28 @@ The media server includes a built-in web interface for controlling and monitorin
- **Mini player** - Sticky compact player that appears when scrolling away from the main player
- **Connection status indicator** - Know when you're connected
- **Token authentication** - Saved in browser localStorage
- **Responsive design** - Works on desktop and mobile
- **Dark and light themes** - Toggle between dark and light modes
- **Accent color picker** - Choose from 9 preset accent colors (green, blue, purple, pink, orange, red, teal, cyan, yellow)
- **Tab-based navigation** - Player, Browser, Quick Actions, Scripts, and Callbacks tabs
- **Audio spectrum visualizer** - Real-time frequency bars with beat-reactive album art scaling and glow (on-demand WASAPI loopback capture)
- **Dynamic WebGL background** - Fragment shader-based animated background that reacts to audio beats and extracts colors from album art (toggle on/off in header)
- **Display control** - Monitor brightness adjustment and power on/off
- **Header quick links** - Configurable external URLs with icons shown in the header bar
- **Installable PWA** - Add to home screen on mobile/desktop for standalone app experience with safe area support for notched phones
- **Responsive design** - Works on desktop, tablet, and mobile
- **Dark and light themes** - Toggle between dark and light modes with dynamic status bar theming
- **Accent color picker** - Choose from 9 preset accent colors or pick a custom color
- **Tab-based navigation** - Player, Display, Browser, Quick Actions, and Settings tabs
- **Multi-language support** - English and Russian locales with automatic detection
### Accessing the Web UI
1. Start the media server:
```bash
python -m media_server.main
```
2. Open your browser and navigate to:
```
```text
http://localhost:8765/
```
@@ -58,6 +70,32 @@ The media server includes a built-in web interface for controlling and monitorin
4. Start playing media in any supported player and watch the UI update in real-time!
### Installing as a PWA
The Web UI can be installed as a Progressive Web App for a native app-like experience:
1. Open the Web UI in Chrome/Edge on your phone or desktop
2. Tap the **Install** icon in the address bar (or "Add to Home Screen" on mobile)
3. The app launches in standalone mode — no browser chrome, with proper safe area handling for notched phones
### Audio Visualizer
The Web UI includes a real-time audio spectrum visualizer that captures system audio output:
- **On-demand capture** - Audio capture starts only when a client enables the visualizer, and stops when the last client disconnects
- **Beat-reactive effects** - Album art pulses and glows in response to bass frequencies
- **Dynamic WebGL background** - Animated shader background that reacts to bass frequencies and adapts colors from current album art
- **Configurable device** - Select which audio output device to capture in Settings
Requires `soundcard` and `numpy` Python packages. Enable in `config.yaml`:
```yaml
visualizer_enabled: true
visualizer_fps: 30 # Frame rate (10-60)
visualizer_bins: 32 # Frequency bins (8-128)
# visualizer_device: "Speakers" # optional: specific device name
```
### Remote Access
To access the Web UI from other devices on your network:
@@ -106,7 +144,7 @@ The Media Browser feature allows you to browse and play media files from configu
![Media Browser](docs/media-browser.PNG)
### Browser Features
### Browser Highlights
- **Folder Configuration** - Mount multiple media folders (music/video directories)
- **Recursive Navigation** - Browse through folder hierarchies with breadcrumb navigation
@@ -114,13 +152,14 @@ The Media Browser feature allows you to browse and play media files from configu
- **Thumbnail Display** - Automatically generated thumbnails from album art (lazy-loaded)
- **Metadata Extraction** - View title, artist, album, duration, bitrate, file size, and more
- **Remote Playback** - Play files on the PC running the media server (not in the browser)
- **Play All** - Play all media files in the current folder
- **Play All** - Play all media files in the current folder (generates M3U playlist)
- **File Download** - Download individual media files directly from the browser
- **Search & Filter** - Real-time search across files in the current folder
- **Pagination** - Navigate large folders with configurable page sizes (25, 50, 100, 200, 500)
- **Last Path Memory** - Automatically returns to your last browsed location
- **Folder Management** - Create, edit, and delete media folders from the UI
### Configuration
### Browser Setup
Add media folders in your `config.yaml`:
@@ -150,9 +189,9 @@ When you play a file from the Media Browser:
### Media Player Compatibility
**⚠️ Important Limitation:** Not all media players expose their playback information to the Windows Media Session API. This means some players will open and play the file, but the Media Server UI won't show playback status, track information, or allow remote control.
**Important Limitation:** Not all media players expose their playback information to the Windows Media Session API. This means some players will open and play the file, but the Media Server UI won't show playback status, track information, or allow remote control.
**Compatible Players** (work with playback tracking):
**Compatible Players** (work with playback tracking):
- **VLC Media Player** - Full support
- **Groove Music** (Windows 10/11 built-in) - Full support
@@ -160,39 +199,84 @@ When you play a file from the Media Browser:
- **Chrome/Edge/Firefox** - Full support for web players
- **foobar2000** - Full support (with proper configuration/plugins)
**Limited/No Support:**
**Limited/No Support:**
- **Windows Media Player Classic** - Opens files but doesn't expose session info
- **Windows Media Player** (classic version) - Limited session support
**Recommendation:** Set **VLC Media Player** or **Groove Music** as your default audio player for the best experience with the Media Browser.
#### Changing Your Default Media Player (Windows)
#### Changing Your Default Media Player (on Windows)
1. Open Windows Settings Apps Default apps
1. Open Windows Settings > Apps > Default apps
2. Search for "Music player" or "Video player"
3. Select VLC Media Player or Groove Music
4. Files opened from Media Browser will now use the selected player
### API Endpoints
## Display Control
The Media Browser exposes several REST API endpoints:
The Display Control feature allows you to manage monitor brightness and power state from the Web UI or via API.
| Endpoint | Method | Description |
|--------------------------|--------|-----------------------------------|
| `/api/browser/folders` | GET | List configured media folders |
| `/api/browser/browse` | GET | Browse directory contents |
| `/api/browser/metadata` | GET | Get media file metadata |
| `/api/browser/thumbnail` | GET | Get thumbnail image |
| `/api/browser/play` | POST | Open file with default player |
- **Brightness adjustment** - Set brightness (0-100%) for each monitor
- **Power management** - Turn monitors on or off
- **Multi-monitor support** - See all connected monitors with model, manufacturer, and resolution info
- **Technology**: DDC-CI on Windows, XRandR/ACPI on Linux, IOKit on macOS
All endpoints require bearer token authentication.
### Display API
### Security Notes
| Endpoint | Method | Body | Description |
|------------------------------------------|----------|---------------------------|--------------------------------------------------|
| `/api/display/monitors` | GET | - | List monitors (use `?refresh=true` to refresh) |
| `/api/display/brightness/{monitor_id}` | POST | `{"brightness": 0-100}` | Set monitor brightness |
| `/api/display/power/{monitor_id}` | POST | `{"on": true\ | false}` |
- **Path Traversal Protection** - All paths are validated to prevent directory traversal attacks
- **Folder Restrictions** - Only configured folders are accessible
- **Authentication Required** - All endpoints require a valid API token
**Monitor response:**
```json
{
"id": 0,
"name": "Monitor Name",
"brightness": 100,
"power_supported": true,
"power_on": true,
"model": "Model Number",
"manufacturer": "Manufacturer",
"resolution": "1920x1080",
"is_primary": true
}
```
## Header Links
Configure quick-access links that appear in the Web UI header bar with custom icons.
### Links Setup
Add links in your `config.yaml`:
```yaml
links:
spotify:
url: "https://open.spotify.com"
icon: "mdi:spotify"
label: "Spotify"
settings:
url: "https://your-server.com/settings"
icon: "mdi:cog"
label: "Settings"
description: "System settings"
```
### Links API
| Endpoint | Method | Body | Description |
|-----------------------------------|----------|-------------------------------------|------------------|
| `/api/links/list` | GET | - | List all links |
| `/api/links/create/{link_name}` | POST | `{url, icon, label, description}` | Create link |
| `/api/links/update/{link_name}` | PUT | `{url, icon, label, description}` | Update link |
| `/api/links/delete/{link_name}` | DELETE | - | Delete link |
All connected WebSocket clients receive a `links_changed` notification when links are modified.
## Requirements
@@ -201,7 +285,7 @@ All endpoints require bearer token authentication.
## Installation
### Windows
### Installing on Windows
```bash
pip install -r requirements.txt
@@ -209,7 +293,7 @@ pip install -r requirements.txt
Required packages: `winsdk`, `pywin32`, `pycaw`, `comtypes`
### Linux
### Installing on Linux
```bash
# Install system dependencies
@@ -218,7 +302,7 @@ sudo apt-get install python3-dbus python3-gi libdbus-1-dev libglib2.0-dev
pip install -r requirements.txt
```
### macOS
### Installing on macOS
```bash
pip install -r requirements.txt
@@ -226,7 +310,7 @@ pip install -r requirements.txt
No additional dependencies - uses built-in `osascript`.
### Android (Termux)
### Installing on Android (Termux)
```bash
# In Termux
@@ -239,26 +323,31 @@ Requires Termux and Termux:API apps from F-Droid.
## Quick Start
1. Generate configuration with API token:
```bash
python -m media_server.main --generate-config
```
2. View your API token:
```bash
python -m media_server.main --show-token
```
3. Start the server:
```bash
python -m media_server.main
```
4. **Open the Web UI** (recommended):
- Navigate to `http://localhost:8765/` in your browser
- Enter your API token from step 2
- Start playing media and control it from the web interface!
5. Or test via API:
```bash
# Health check (no auth required)
curl http://localhost:8765/api/health
@@ -267,13 +356,14 @@ Requires Termux and Termux:API apps from F-Droid.
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:8765/api/media/status
```
## Configuration
## Configuration Reference
Configuration file locations:
- Windows: `%APPDATA%\media-server\config.yaml`
- Linux/macOS: `~/.config/media-server/config.yaml`
### config.yaml
### Full config.yaml Example
```yaml
host: 0.0.0.0
@@ -287,29 +377,75 @@ api_tokens:
poll_interval: 1.0
log_level: INFO
# Audio device for system volume control (null = default device)
audio_device: null
# Audio visualizer (requires soundcard + numpy)
visualizer_enabled: true
visualizer_fps: 30
visualizer_bins: 32
visualizer_device: null # null = auto-detect loopback
# Media folders for browser
media_folders:
music:
path: "C:\\Users\\YourUsername\\Music"
label: "My Music"
enabled: true
# Thumbnail size: "small" (150x150), "medium" (300x300), or "both"
thumbnail_size: "medium"
# Custom scripts (execute via API/UI)
scripts:
lock_screen:
command: "rundll32.exe user32.dll,LockWorkStation"
label: "Lock Screen"
description: "Lock the workstation"
icon: "mdi:lock"
timeout: 5
shell: true
# Callbacks (execute after media actions)
callbacks:
on_turn_off:
command: "rundll32.exe user32.dll,LockWorkStation"
timeout: 5
shell: true
# Header quick links
links:
spotify:
url: "https://open.spotify.com"
icon: "mdi:spotify"
label: "Spotify"
```
### Authentication
The media server supports multiple API tokens with friendly labels. This allows you to:
- Issue different tokens for different clients (Home Assistant, mobile apps, web UI, etc.)
- Identify which client is making requests in the server logs
- Revoke individual tokens without affecting other clients
**Token labels** appear in all server logs, making it easy to track and debug client connections:
```
```text
2026-02-06 03:36:20,806 - media_server.services.websocket_manager - [home_assistant] - INFO - WebSocket client connected
2026-02-06 03:28:24,258 - media_server.routes.scripts - [mobile] - INFO - Executing script: lock_screen
```
**Viewing your tokens:**
```bash
python -m media_server.main --show-token
```
Output:
```
```text
Config directory: C:\Users\...\AppData\Roaming\media-server
API Tokens:
@@ -334,13 +470,14 @@ export MEDIA_SERVER_LOG_LEVEL=DEBUG
### Health Check
```
```text
GET /api/health
```
No authentication required. Returns server status and platform info.
**Response:**
```json
{
"status": "healthy",
@@ -351,12 +488,13 @@ No authentication required. Returns server status and platform info.
### Get Media Status
```
```text
GET /api/media/status
Authorization: Bearer <token>
```
**Response:**
```json
{
"state": "playing",
@@ -372,60 +510,66 @@ Authorization: Bearer <token>
}
```
### Album Artwork
```text
GET /api/media/artwork
Authorization: Bearer <token>
```
Returns current album artwork as PNG/JPEG/WebP binary. Also accepts token as a query parameter.
### Media Controls
All control endpoints require authentication and return `{"success": true}` on success.
| Endpoint | Method | Body | Description |
|----------|--------|------|-------------|
| `/api/media/play` | POST | - | Resume playback |
| `/api/media/pause` | POST | - | Pause playback |
| `/api/media/stop` | POST | - | Stop playback |
| `/api/media/next` | POST | - | Next track |
| `/api/media/previous` | POST | - | Previous track |
| `/api/media/volume` | POST | `{"volume": 75}` | Set volume (0-100) |
| `/api/media/mute` | POST | - | Toggle mute |
| `/api/media/seek` | POST | `{"position": 60.0}` | Seek to position (seconds) |
| `/api/media/turn_on` | POST | - | Execute on_turn_on callback |
| `/api/media/turn_off` | POST | - | Execute on_turn_off callback |
| `/api/media/toggle` | POST | - | Execute on_toggle callback |
| Endpoint | Method | Body | Description |
|-------------------------|----------|------------------------|--------------------------------|
| `/api/media/play` | POST | - | Resume playback |
| `/api/media/pause` | POST | - | Pause playback |
| `/api/media/stop` | POST | - | Stop playback |
| `/api/media/next` | POST | - | Next track |
| `/api/media/previous` | POST | - | Previous track |
| `/api/media/volume` | POST | `{"volume": 75}` | Set volume (0-100) |
| `/api/media/mute` | POST | - | Toggle mute |
| `/api/media/seek` | POST | `{"position": 60.0}` | Seek to position (seconds) |
| `/api/media/turn_on` | POST | - | Execute on_turn_on callback |
| `/api/media/turn_off` | POST | - | Execute on_turn_off callback |
| `/api/media/toggle` | POST | - | Execute on_toggle callback |
### Script Execution
### Visualizer API
The server supports executing pre-defined scripts via API and the Web UI. Scripts and callbacks can be managed directly from the Web UI — add, edit, delete, and execute with real-time output display.
| Endpoint | Method | Body | Description |
|-----------------------------------|----------|-----------------------------------------|----------------------------------------|
| `/api/media/visualizer/status` | GET | - | Check availability and running state |
| `/api/media/visualizer/devices` | GET | - | List loopback audio devices |
| `/api/media/visualizer/device` | POST | `{"device_name": "..." \ | null}` |
### Audio Devices
```text
GET /api/audio/devices
Authorization: Bearer <token>
```
Returns a list of available audio output devices.
### Script Management
Scripts can be managed via API or directly from the Web UI (Quick Actions tab).
![Script and Callback Management](docs/scripts-management.PNG)
#### List Scripts
| Endpoint | Method | Body | Description |
|----------------------------------------|----------|---------------------------------------------------------|--------------------|
| `/api/scripts/list` | GET | - | List all scripts |
| `/api/scripts/execute/{script_name}` | POST | `{"args": []}` | Execute a script |
| `/api/scripts/create/{script_name}` | POST | `{command, label, description, icon, timeout, shell}` | Create a script |
| `/api/scripts/update/{script_name}` | PUT | `{command, label, description, icon, timeout, shell}` | Update a script |
| `/api/scripts/delete/{script_name}` | DELETE | - | Delete a script |
```
GET /api/scripts/list
Authorization: Bearer <token>
```
**Execute response:**
**Response:**
```json
[
{
"name": "lock_screen",
"label": "Lock Screen",
"description": "Lock the workstation",
"timeout": 5
}
]
```
#### Execute Script
```
POST /api/scripts/execute/{script_name}
Authorization: Bearer <token>
Content-Type: application/json
{"args": []}
```
**Response:**
```json
{
"success": true,
@@ -436,7 +580,7 @@ Content-Type: application/json
}
```
### Configuring Scripts
### Script Config Options
Add scripts in your `config.yaml`:
@@ -478,21 +622,33 @@ scripts:
shell: true
```
Script configuration options:
Script fields:
| Field | Required | Description |
|-------|----------|-------------|
| `command` | Yes | Command to execute |
| `label` | No | User-friendly display name (defaults to script name) |
| `description` | No | Description of what the script does |
| `icon` | No | Custom MDI icon (e.g., `mdi:power`) |
| `timeout` | No | Execution timeout in seconds (default: 30, max: 300) |
| `working_dir` | No | Working directory for the command |
| `shell` | No | Run in shell (default: true) |
| Field | Required | Description |
|-----------------|------------|--------------------------------------------------------|
| `command` | Yes | Command to execute |
| `label` | No | User-friendly display name (defaults to script name) |
| `description` | No | Description of what the script does |
| `icon` | No | Custom MDI icon (e.g., `mdi:power`) |
| `timeout` | No | Execution timeout in seconds (default: 30, max: 300) |
| `working_dir` | No | Working directory for the command |
| `shell` | No | Run in shell (default: true) |
### Configuring Callbacks
### Callback Management
Callbacks are optional commands executed after media actions. Add them in your `config.yaml`:
Callbacks are commands executed after media actions. They can be managed via API or the Web UI (Quick Actions tab).
| Endpoint | Method | Body | Description |
|-------------------------------------------|----------|--------------------------------------------|--------------------------|
| `/api/callbacks/list` | GET | - | List all callbacks |
| `/api/callbacks/execute/{callback_name}` | POST | - | Execute (for debugging) |
| `/api/callbacks/create/{callback_name}` | POST | `{command, timeout, working_dir, shell}` | Create a callback |
| `/api/callbacks/update/{callback_name}` | PUT | `{command, timeout, working_dir, shell}` | Update a callback |
| `/api/callbacks/delete/{callback_name}` | DELETE | - | Delete a callback |
### Callback Config Options
Add callbacks in your `config.yaml`:
```yaml
callbacks:
@@ -556,28 +712,83 @@ callbacks:
Available callbacks:
| Callback | Triggered by | Description |
|----------|--------------|-------------|
| `on_play` | `/api/media/play` | After play succeeds |
| `on_pause` | `/api/media/pause` | After pause succeeds |
| `on_stop` | `/api/media/stop` | After stop succeeds |
| `on_next` | `/api/media/next` | After next track succeeds |
| `on_previous` | `/api/media/previous` | After previous track succeeds |
| `on_volume` | `/api/media/volume` | After volume change succeeds |
| `on_mute` | `/api/media/mute` | After mute toggle |
| `on_seek` | `/api/media/seek` | After seek succeeds |
| `on_turn_on` | `/api/media/turn_on` | Callback-only action |
| `on_turn_off` | `/api/media/turn_off` | Callback-only action |
| `on_toggle` | `/api/media/toggle` | Callback-only action |
| Callback | Triggered by | Description |
|-----------------|-------------------------|---------------------------------|
| `on_play` | `/api/media/play` | After play succeeds |
| `on_pause` | `/api/media/pause` | After pause succeeds |
| `on_stop` | `/api/media/stop` | After stop succeeds |
| `on_next` | `/api/media/next` | After next track succeeds |
| `on_previous` | `/api/media/previous` | After previous track succeeds |
| `on_volume` | `/api/media/volume` | After volume change succeeds |
| `on_mute` | `/api/media/mute` | After mute toggle |
| `on_seek` | `/api/media/seek` | After seek succeeds |
| `on_turn_on` | `/api/media/turn_on` | Callback-only action |
| `on_turn_off` | `/api/media/turn_off` | Callback-only action |
| `on_toggle` | `/api/media/toggle` | Callback-only action |
Callback configuration options:
Callback fields:
| Field | Required | Description |
|-------|----------|-------------|
| `command` | Yes | Command to execute |
| `timeout` | No | Execution timeout in seconds (default: 30, max: 300) |
| `working_dir` | No | Working directory for the command |
| `shell` | No | Run in shell (default: true) |
| Field | Required | Description |
|-----------------|------------|--------------------------------------------------------|
| `command` | Yes | Command to execute |
| `timeout` | No | Execution timeout in seconds (default: 30, max: 300) |
| `working_dir` | No | Working directory for the command |
| `shell` | No | Run in shell (default: true) |
### Browser API
| Endpoint | Method | Body | Description |
|---------------------------------------------|----------|----------------------------------------|--------------------------------------------|
| `/api/browser/folders` | GET | - | List configured media folders |
| `/api/browser/browse` | GET | - | Browse directory (query: folder_id, path) |
| `/api/browser/metadata` | GET | - | Get file metadata (query: file_path) |
| `/api/browser/thumbnail` | GET | - | Get thumbnail image (query: file_path) |
| `/api/browser/download` | GET | - | Download file (query: folder_id, path) |
| `/api/browser/play` | POST | `{"file_path": "..."}` | Open file with default player |
| `/api/browser/play-folder` | POST | `{"folder_id": "...", "path": ""}` | Play all files in folder (M3U) |
| `/api/browser/folders/create` | POST | `{folder_id, label, path, enabled}` | Create folder config |
| `/api/browser/folders/update/{folder_id}` | PUT | `{label, path, enabled}` | Update folder config |
| `/api/browser/folders/delete/{folder_id}` | DELETE | - | Delete folder config |
All endpoints require bearer token authentication.
### Security Notes
- **Path Traversal Protection** - All paths are validated to prevent directory traversal attacks
- **Folder Restrictions** - Only configured folders are accessible
- **Authentication Required** - All endpoints require a valid API token
- **Output Limits** - Script stdout/stderr capped at 10KB
### WebSocket
```text
WebSocket /api/media/ws
Authorization: Bearer <token>
```
The WebSocket connection provides real-time updates and low-latency control.
**Messages from server:**
| Type | Data | Description |
|---------------------|------------------------|----------------------------------------|
| `status` | Media status object | Initial status on connection |
| `status_update` | Media status object | Status changes during playback |
| `audio_data` | `[0.1, 0.2, ...]` | Visualizer frequency data (30 fps) |
| `scripts_changed` | `{}` | Scripts were created/updated/deleted |
| `links_changed` | `{}` | Links were created/updated/deleted |
| `pong` | - | Response to client ping |
| `error` | `{"message": "..."}` | Error messages |
**Messages from client:**
| Type | Data | Description |
|-----------------------|------------------------|------------------------------------------------------------|
| `ping` | - | Keepalive ping |
| `get_status` | - | Request current status |
| `volume` | `{"volume": 0-100}` | Low-latency volume control via WebSocket |
| `enable_visualizer` | - | Subscribe to audio data (starts capture) |
| `disable_visualizer` | - | Unsubscribe from audio data (stops capture on last client) |
## Running as a Service
@@ -595,20 +806,23 @@ To remove the scheduled task:
Unregister-ScheduledTask -TaskName "MediaServer" -Confirm:$false
```
### Windows Service
### Windows Service (Alternative)
Install:
```bash
python -m media_server.service.install_windows install
```
Start/Stop:
```bash
python -m media_server.service.install_windows start
python -m media_server.service.install_windows stop
```
Remove:
```bash
python -m media_server.service.install_windows remove
```
@@ -616,24 +830,27 @@ python -m media_server.service.install_windows remove
### Linux (systemd)
Install:
```bash
sudo ./service/install_linux.sh install
```
Enable and start for your user:
```bash
sudo systemctl enable media-server@$USER
sudo systemctl start media-server@$USER
```
View logs:
```bash
journalctl -u media-server@$USER -f
```
## Command Line Options
```
```text
python -m media_server.main [OPTIONS]
Options:
@@ -652,15 +869,19 @@ Options:
## Supported Media Players
### Windows
### Players on Windows
- Spotify
- Windows Media Player
- VLC
- Groove Music
- Web browsers (Chrome, Edge, Firefox)
- foobar2000
- AIMP
- Web browsers (Chrome, Edge, Firefox, Opera, Brave)
- Any app using Windows Media Transport Controls
### Linux
### Players on Linux
- Any MPRIS-compliant player:
- Spotify
- VLC
@@ -669,28 +890,33 @@ Options:
- Web browsers
- MPD (with MPRIS bridge)
### macOS
### Players on macOS
- Spotify
- Apple Music
- VLC (partial)
- QuickTime Player
### Android (via Termux)
### Players on Android (via Termux)
- System media controls
- Limited seek support
## Troubleshooting
### "No active media session"
- Ensure a media player is running and has played content
- On Windows, check that the app supports media transport controls
- On Linux, verify MPRIS with: `dbus-send --print-reply --dest=org.freedesktop.DBus /org/freedesktop/DBus org.freedesktop.DBus.ListNames | grep mpris`
### Permission errors on Linux
- Ensure your user has access to the D-Bus session bus
- For systemd service, the `DBUS_SESSION_BUS_ADDRESS` must be set correctly
### Volume control not working
- Windows: Run as administrator if needed
- Linux: Ensure PulseAudio/PipeWire is running
+31
View File
@@ -0,0 +1,31 @@
## v0.1.8 (2026-04-18)
### Bug Fixes
- Fix numpy failing to import in the Windows installer — preserve required numpy submodules (`lib`, `linalg`, `ma`, `polynomial`, `fft`, `ctypeslib`, `matrixlib`) during build cleanup ([68614c9](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/68614c9))
- Fix numpy failing to locate `libopenblas` DLL in the Windows installer — generate `_distributor_init_local.py` at build time and call `os.add_dll_directory()` at runtime ([456eb3a](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/456eb3a))
- Fix visualizer toggle button not reflecting actual availability after audio device load ([ee51849](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ee51849))
- Fix visualizer WebSocket re-subscription firing before availability is confirmed from the API — moved from `connectWebSocket` to `loadAudioDevices` ([ee51849](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ee51849))
---
### Development / Internal
#### CI/Build
- Generate `numpy/_distributor_init_local.py` in Windows build script to fix DLL loading in embedded Python ([456eb3a](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/456eb3a))
#### Other
- Broaden audio library import errors from `ImportError` to `Exception` and log at `warning` level with error details ([ee51849](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ee51849))
---
<details>
<summary>All Commits</summary>
| Hash | Message | Author |
|------|---------|--------|
| [68614c9](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/68614c9) | fix(windows): keep required numpy submodules in build cleanup | alexei.dolgolyov |
| [456eb3a](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/456eb3a) | fix(windows): fix numpy DLL loading in embedded Python distribution | alexei.dolgolyov |
| [ee51849](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ee51849) | fix(visualizer): sync state and re-subscribe from audio device load | 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.
}
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
set -euo pipefail
# Build Linux distribution (self-contained venv + tarball)
# Usage: ./build-dist-linux.sh [VERSION]
source "$(dirname "$0")/build-common.sh"
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"
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 "."
# Remove the installed package (app source is on PYTHONPATH via launcher)
rm -rf "${DIST_DIR}"/venv/lib/python*/site-packages/media_server*
rm -rf "${DIST_DIR}"/venv/lib/python*/site-packages/media_server*.dist-info
deactivate
# Trim venv site-packages
LINUX_SP=$(echo "${DIST_DIR}"/venv/lib/python*/site-packages)
cleanup_site_packages "$LINUX_SP" "so" "so"
copy_app_files "$DIST_DIR"
# --- Create launcher ---
cat > "${DIST_DIR}/media-server.sh" << 'LAUNCHER'
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
export PYTHONPATH="$SCRIPT_DIR/app"
source "$SCRIPT_DIR/venv/bin/activate"
exec python -m media_server.main "$@"
LAUNCHER
chmod +x "${DIST_DIR}/media-server.sh"
# --- Create systemd service installer ---
cat > "${DIST_DIR}/install-service.sh" << 'SERVICE'
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SERVICE_NAME="media-server"
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
if [ "$EUID" -ne 0 ]; then
echo "Please run with sudo: sudo ./install-service.sh"
exit 1
fi
REAL_USER="${SUDO_USER:-$USER}"
cat > "$SERVICE_FILE" << EOF
[Unit]
Description=Media Server
After=network.target sound.target
[Service]
Type=simple
User=${REAL_USER}
WorkingDirectory=${SCRIPT_DIR}
ExecStart=${SCRIPT_DIR}/media-server.sh
Restart=on-failure
RestartSec=5
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable "${SERVICE_NAME}"
systemctl start "${SERVICE_NAME}"
echo "Service '${SERVICE_NAME}' installed and started."
echo "Check status: systemctl status ${SERVICE_NAME}"
SERVICE
chmod +x "${DIST_DIR}/install-service.sh"
# --- Package ---
echo "Creating archive..."
cp -r "${DIST_DIR}" "${BUILD_OUTPUT}"
tar -czf "${BUILD_OUTPUT}.tar.gz" -C build "MediaServer-v${VERSION_CLEAN}-linux-x64"
echo "Build complete: ${BUILD_OUTPUT}.tar.gz"
+165
View File
@@ -0,0 +1,165 @@
#!/usr/bin/env bash
set -euo pipefail
# Cross-build Windows distribution on Linux
# Usage: ./build-dist-windows.sh [VERSION]
source "$(dirname "$0")/build-common.sh"
detect_version "${1:-}"
echo "Building Media Server v${VERSION_CLEAN} for Windows"
# --- Configuration ---
PYTHON_VERSION="3.11.9"
PYTHON_SHORT="311"
DIST_DIR="dist/media-server"
WHEEL_DIR="build/win-wheels"
SITE_PACKAGES="${DIST_DIR}/python/Lib/site-packages"
BUILD_OUTPUT="build/MediaServer-v${VERSION_CLEAN}-win-x64"
clean_dist "${DIST_DIR}" "${WHEEL_DIR}" "${SITE_PACKAGES}"
# --- 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
PTH_FILE=$(ls "${DIST_DIR}"/python/python*._pth | head -1)
sed -i 's/^#\s*import site/import site/' "$PTH_FILE"
echo 'Lib\site-packages' >> "$PTH_FILE"
echo '..\app' >> "$PTH_FILE"
# --- Download Windows wheels ---
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>=0.27.0"
"httptools>=0.5.0"
"websockets>=10.4"
"python-dotenv>=0.13"
"pydantic>=2.0"
"pydantic-settings>=2.0"
"pyyaml>=6.0"
"mutagen>=1.47.0"
"pillow>=10.0.0"
)
# 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"
)
# 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"
)
# 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[@]}"
# 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 --pre --quiet --no-cache-dir --dest "$WHEEL_DIR" \
--only-binary :all: \
"$dep"
done
# Remove numpy 2.x wheels pulled as transitive deps (soundcard requires <2.0)
for f in "$WHEEL_DIR"/numpy-2*; do
[ -f "$f" ] && echo "Removing incompatible: $(basename "$f")" && rm "$f"
done
# Install wheels into site-packages
echo "Installing wheels..."
for whl in "$WHEEL_DIR"/*.whl; do
unzip -qo "$whl" -d "$SITE_PACKAGES"
done
# numpy wheels from PyPI don't include _distributor_init_local.py unless
# patched by delvewheel. In embedded Python, os.add_dll_directory() is never
# called, so libopenblas can't be found and numpy fails to import.
# Generate the missing loader here instead.
if [ -d "${SITE_PACKAGES}/numpy" ]; then
cat > "${SITE_PACKAGES}/numpy/_distributor_init_local.py" << 'EOF'
import os
import sys
if sys.platform == 'win32':
_libs = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'numpy.libs'))
if os.path.isdir(_libs):
os.add_dll_directory(_libs)
EOF
echo "Generated numpy/_distributor_init_local.py"
fi
cleanup_site_packages "$SITE_PACKAGES" "pyd" "dll"
verify_frontend
copy_app_files "$DIST_DIR"
# Copy scripts needed for auto-start
mkdir -p "${DIST_DIR}/scripts"
cp scripts/start-hidden.vbs "${DIST_DIR}/scripts/"
# --- Create launcher ---
cat > "${DIST_DIR}/media-server.bat" << 'LAUNCHER'
@echo off
setlocal
set "ROOT=%~dp0"
"%ROOT%python\python.exe" -m media_server.main %*
LAUNCHER
# --- Package ---
echo "Creating archives..."
mkdir -p build
# Portable ZIP
cp -r "${DIST_DIR}" "${BUILD_OUTPUT}"
cd build
zip -qr "MediaServer-v${VERSION_CLEAN}-win-x64.zip" "MediaServer-v${VERSION_CLEAN}-win-x64"
cd ..
echo "Build complete: build/MediaServer-v${VERSION_CLEAN}-win-x64.zip"
echo "Dist directory ready for NSIS: ${DIST_DIR}"
+16 -5
View File
@@ -1,13 +1,14 @@
# Media Server Configuration
# Copy this file to config.yaml and customize as needed.
# A secure token will be auto-generated on first run if not specified.
# By default, authentication is DISABLED (no tokens = open access).
# To enable auth, uncomment and configure the api_tokens section below.
# API Tokens - Multiple tokens with friendly labels
# This allows you to identify which client is making requests in the logs
api_tokens:
home_assistant: "your-home-assistant-token-here"
mobile: "your-mobile-app-token-here"
web_ui: "your-web-ui-token-here"
# api_tokens:
# home_assistant: "your-home-assistant-token-here"
# mobile: "your-mobile-app-token-here"
# web_ui: "your-web-ui-token-here"
# Server settings
host: "0.0.0.0"
@@ -19,6 +20,7 @@ scripts:
command: "rundll32.exe user32.dll,LockWorkStation"
label: "Lock Screen"
description: "Lock the workstation"
icon: "mdi:lock"
timeout: 5
shell: true
@@ -26,6 +28,7 @@ scripts:
command: "shutdown /h"
label: "Hibernate"
description: "Hibernate the PC"
icon: "mdi:power-sleep"
timeout: 10
shell: true
@@ -33,6 +36,7 @@ scripts:
command: "rundll32.exe powrprof.dll,SetSuspendState 0,1,0"
label: "Sleep"
description: "Put PC to sleep"
icon: "mdi:sleep"
timeout: 10
shell: true
@@ -40,6 +44,7 @@ scripts:
command: "shutdown /s /t 0"
label: "Shutdown"
description: "Shutdown the PC immediately"
icon: "mdi:power"
timeout: 10
shell: true
@@ -47,9 +52,15 @@ scripts:
command: "shutdown /r /t 0"
label: "Restart"
description: "Restart the PC immediately"
icon: "mdi:restart"
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:
+26
View File
@@ -0,0 +1,26 @@
import * as esbuild from 'esbuild';
const srcDir = 'media_server/static';
const outDir = `${srcDir}/dist`;
const watch = process.argv.includes('--watch');
/** @type {esbuild.BuildOptions} */
const jsOpts = {
entryPoints: [`${srcDir}/js/app.js`],
bundle: true,
format: 'iife',
outfile: `${outDir}/app.bundle.js`,
minify: true,
sourcemap: true,
target: ['es2020'],
logLevel: 'info',
};
if (watch) {
const jsCtx = await esbuild.context(jsOpts);
await jsCtx.watch();
console.log('Watching for changes...');
} else {
await esbuild.build(jsOpts);
}
+180
View File
@@ -0,0 +1,180 @@
; Media Server NSIS Installer
; Cross-compilable: apt install nsis && makensis -DVERSION="1.0.0" installer.nsi
!include "MUI2.nsh"
!include "FileFunc.nsh"
; --- Configuration ---
!define APPNAME "Media Server"
!define EXENAME "media-server.bat"
!define VBSNAME "start-hidden.vbs"
!ifndef VERSION
!define VERSION "0.0.0"
!endif
Name "${APPNAME} ${VERSION}"
OutFile "build\MediaServer-v${VERSION}-setup.exe"
InstallDir "$LOCALAPPDATA\${APPNAME}"
RequestExecutionLevel user
; --- UI ---
!define MUI_ICON "media_server\static\icons\icon.ico"
!define MUI_UNICON "media_server\static\icons\icon.ico"
!define MUI_ABORTWARNING
!define MUI_FINISHPAGE_RUN ""
!define MUI_FINISHPAGE_RUN_TEXT "Launch ${APPNAME}"
!define MUI_FINISHPAGE_RUN_FUNCTION LaunchApp
!insertmacro MUI_PAGE_WELCOME
!insertmacro MUI_PAGE_DIRECTORY
!insertmacro MUI_PAGE_COMPONENTS
!insertmacro MUI_PAGE_INSTFILES
!insertmacro MUI_PAGE_FINISH
!insertmacro MUI_UNPAGE_CONFIRM
!insertmacro MUI_UNPAGE_INSTFILES
!insertmacro MUI_LANGUAGE "English"
; --- Functions ---
Function LaunchApp
ExecShell "open" "wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"'
; Give the server a moment to start, then open the UI in the default browser
Sleep 2000
ExecShell "open" "http://localhost:8765/"
FunctionEnd
Function .onInit
; Check if server is running by trying to open its Python executable exclusively
IfFileExists "$INSTDIR\python\python.exe" 0 done
ClearErrors
FileOpen $0 "$INSTDIR\python\python.exe" a
IfErrors locked
; File opened fine — server is not running
FileClose $0
Goto done
locked:
MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION \
"${APPNAME} is currently running.$\n$\nYes = Stop the server and continue$\nNo = Continue without stopping (may cause errors)$\nCancel = Abort installation" \
IDYES kill IDNO done
Abort
kill:
nsExec::ExecToLog 'wmic process where "ExecutablePath like $\'%Media Server%python%$\'" call terminate'
Sleep 2000
done:
FunctionEnd
; --- Sections ---
Section "!Core (required)" SecCore
SectionIn RO
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\*.*"
; Create config.yaml from example if it doesn't already exist (preserve user config on upgrade)
IfFileExists "$INSTDIR\config.yaml" +2
CopyFiles /SILENT "$INSTDIR\config.example.yaml" "$INSTDIR\config.yaml"
; Create uninstaller
WriteUninstaller "$INSTDIR\uninstall.exe"
; Start Menu shortcuts
CreateDirectory "$SMPROGRAMS\${APPNAME}"
CreateShortcut "$SMPROGRAMS\${APPNAME}\${APPNAME}.lnk" \
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
"$INSTDIR\app\media_server\static\icons\icon.ico" 0
CreateShortcut "$SMPROGRAMS\${APPNAME}\${APPNAME} (Console).lnk" \
"$INSTDIR\${EXENAME}" "" \
"$INSTDIR\app\media_server\static\icons\icon.ico" 0
CreateShortcut "$SMPROGRAMS\${APPNAME}\Uninstall.lnk" \
"$INSTDIR\uninstall.exe"
; Registry for Add/Remove Programs
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
"DisplayName" "${APPNAME}"
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
"DisplayVersion" "${VERSION}"
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
"UninstallString" "$\"$INSTDIR\uninstall.exe$\""
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
"InstallLocation" "$INSTDIR"
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
"Publisher" "Alexei Dolgolyov"
WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
"NoModify" 1
WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
"NoRepair" 1
; Calculate installed size
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
IntFmt $0 "0x%08X" $0
WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
"EstimatedSize" "$0"
SectionEnd
Section "Desktop shortcut" SecDesktop
CreateShortcut "$DESKTOP\${APPNAME}.lnk" \
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
"$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\app\media_server\static\icons\icon.ico" 0
SectionEnd
; --- Section descriptions ---
!insertmacro MUI_FUNCTION_DESCRIPTION_BEGIN
!insertmacro MUI_DESCRIPTION_TEXT ${SecCore} \
"Core application files, embedded Python, and Start Menu shortcuts."
!insertmacro MUI_DESCRIPTION_TEXT ${SecDesktop} \
"Create a desktop shortcut to launch ${APPNAME}."
!insertmacro MUI_DESCRIPTION_TEXT ${SecAutostart} \
"Automatically start ${APPNAME} when you log in to Windows."
!insertmacro MUI_FUNCTION_DESCRIPTION_END
; --- Uninstaller ---
Section "Uninstall"
; Stop running instance
nsExec::ExecToLog 'wmic process where "ExecutablePath like $\'%Media Server%python%$\'" call terminate'
nsExec::ExecToLog 'taskkill /F /IM media-server.exe'
; Remove application files
RMDir /r "$INSTDIR\python"
RMDir /r "$INSTDIR\app"
RMDir /r "$INSTDIR\scripts"
Delete "$INSTDIR\${EXENAME}"
Delete "$INSTDIR\VERSION"
Delete "$INSTDIR\uninstall.exe"
; Preserve config.yaml (user data) — only remove the example
Delete "$INSTDIR\config.example.yaml"
; Remove shortcuts
Delete "$DESKTOP\${APPNAME}.lnk"
Delete "$SMSTARTUP\${APPNAME}.lnk"
RMDir /r "$SMPROGRAMS\${APPNAME}"
; Remove registry
DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}"
; Remove install dir only if empty (config.yaml may remain)
RMDir "$INSTDIR"
SectionEnd
+21 -1
View File
@@ -1,3 +1,23 @@
"""Media Server - REST API for controlling system media playback."""
__version__ = "1.0.0"
from importlib.metadata import PackageNotFoundError, version
from pathlib import Path
def _detect_version() -> str:
# 1. Package metadata (works when pip-installed in dev)
try:
return version("media-server")
except PackageNotFoundError:
pass
# 2. VERSION file written by build scripts (production builds)
# Located at install root, two levels up from this package
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()
+20 -2
View File
@@ -15,6 +15,11 @@ security = HTTPBearer(auto_error=False)
token_label_var: ContextVar[str] = ContextVar("token_label", default="unknown")
def auth_enabled() -> bool:
"""Check if authentication is enabled (i.e. at least one token is configured)."""
return bool(settings.api_tokens)
def get_token_label(token: str) -> Optional[str]:
"""Get the label for a token. Returns None if token is invalid.
@@ -36,14 +41,19 @@ async def verify_token(
) -> str:
"""Verify the API token from the Authorization header.
When no tokens are configured, authentication is skipped entirely.
Reuses the label from middleware context when already validated.
Returns:
The token label
The token label (or "anonymous" when auth is disabled)
Raises:
HTTPException: If the token is missing or invalid
HTTPException: If the token is missing or invalid (only when auth enabled)
"""
if not auth_enabled():
token_label_var.set("anonymous")
return "anonymous"
# Reuse label already set by middleware to avoid redundant O(n) scan
existing = token_label_var.get("unknown")
if existing != "unknown":
@@ -80,6 +90,10 @@ class TokenAuth:
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> str | None:
"""Verify the token and return the label or raise an exception."""
if not auth_enabled():
token_label_var.set("anonymous")
return "anonymous"
if credentials is None:
if self.auto_error:
raise HTTPException(
@@ -122,6 +136,10 @@ async def verify_token_or_query(
Raises:
HTTPException: If the token is missing or invalid
"""
if not auth_enabled():
token_label_var.set("anonymous")
return "anonymous"
# Reuse label already set by middleware
existing = token_label_var.get("unknown")
if existing != "unknown":
+70 -8
View File
@@ -1,7 +1,6 @@
"""Configuration management for the media server."""
import os
import secrets
from pathlib import Path
from typing import Optional
@@ -27,6 +26,26 @@ class CallbackConfig(BaseModel):
shell: bool = Field(default=True, description="Run command in shell")
class ScriptParameterConfig(BaseModel):
"""Configuration for a script parameter."""
type: str = Field(
...,
description="Parameter type: string, integer, float, boolean, select",
pattern=r"^(string|integer|float|boolean|select)$",
)
description: str = Field(default="", description="Parameter description")
required: bool = Field(default=False, description="Whether the parameter is required")
default: Optional[str | int | float | bool] = Field(
default=None, description="Default value if not provided"
)
min: Optional[float] = Field(default=None, description="Minimum value (numeric types only)")
max: Optional[float] = Field(default=None, description="Maximum value (numeric types only)")
options: Optional[list[str]] = Field(
default=None, description="Allowed values (select type only)"
)
class ScriptConfig(BaseModel):
"""Configuration for a custom script."""
@@ -37,6 +56,9 @@ class ScriptConfig(BaseModel):
timeout: int = Field(default=30, description="Execution timeout in seconds", ge=1, le=300)
working_dir: Optional[str] = Field(default=None, description="Working directory")
shell: bool = Field(default=True, description="Run command in shell")
parameters: dict[str, ScriptParameterConfig] = Field(
default_factory=dict, description="Named parameters with type and validation rules"
)
class LinkConfig(BaseModel):
@@ -62,10 +84,10 @@ class Settings(BaseSettings):
host: str = Field(default="0.0.0.0", description="Server bind address")
port: int = Field(default=8765, description="Server port")
# Authentication
# Authentication (empty = auth disabled, anyone can access the API)
api_tokens: dict[str, str] = Field(
default_factory=lambda: {"default": secrets.token_urlsafe(32)},
description="Named API tokens for access control (label: token pairs)",
default_factory=dict,
description="Named API tokens for access control (label: token pairs). Empty = no auth.",
)
# Media controller settings
@@ -76,7 +98,10 @@ class Settings(BaseSettings):
# Audio device settings
audio_device: Optional[str] = Field(
default=None,
description="Audio device name to control (None = default device). Use /api/audio/devices to list available devices.",
description=(
"Audio device name to control (None = default device)."
" Use /api/audio/devices to list available devices."
),
)
# Logging
@@ -99,6 +124,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(
@@ -112,6 +141,39 @@ class Settings(BaseSettings):
description="Quick links displayed as icons in the header",
)
# Audio visualizer
visualizer_enabled: bool = Field(
default=True,
description="Enable audio spectrum visualizer (requires soundcard + numpy)",
)
visualizer_fps: int = Field(
default=30,
description="Visualizer update rate in frames per second",
ge=10,
le=60,
)
visualizer_bins: int = Field(
default=32,
description="Number of frequency bins for the visualizer",
ge=8,
le=128,
)
visualizer_device: Optional[str] = Field(
default=None,
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."""
@@ -163,9 +225,9 @@ def generate_default_config(path: Optional[Path] = None) -> Path:
config = {
"host": "0.0.0.0",
"port": 8765,
"api_tokens": {
"default": secrets.token_urlsafe(32),
},
# "api_tokens": {
# "default": "your-secret-token-here",
# },
"poll_interval": 1.0,
"log_level": "INFO",
# Audio device to control (use GET /api/audio/devices to list available devices)
+28
View File
@@ -451,6 +451,34 @@ class ConfigManager:
del settings.links[name]
logger.info(f"Link '{name}' deleted from config")
def set_setting(self, key: str, value) -> None:
"""Set a top-level config setting and persist to YAML.
Args:
key: Setting name (e.g., "visualizer_device").
value: Setting value (None removes the key).
"""
with self._lock:
if not self._config_path.exists():
data = {}
else:
with open(self._config_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
if value is None:
data.pop(key, None)
else:
data[key] = value
self._config_path.parent.mkdir(parents=True, exist_ok=True)
with open(self._config_path, "w", encoding="utf-8") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
# Update in-memory settings
if hasattr(settings, key):
setattr(settings, key, value)
logger.info("Setting '%s' updated to: %s", key, value)
# Global config manager instance
config_manager = ConfigManager()
+175 -31
View File
@@ -2,6 +2,7 @@
import argparse
import logging
import socket
import sys
from contextlib import asynccontextmanager
from pathlib import Path
@@ -15,8 +16,17 @@ from fastapi.staticfiles import StaticFiles
from . import __version__
from .auth import get_token_label, token_label_var
from .config import settings, generate_default_config, get_config_dir
from .routes import audio_router, browser_router, callbacks_router, display_router, health_router, links_router, media_router, scripts_router
from .config import generate_default_config, get_config_dir, settings
from .routes import (
audio_router,
browser_router,
callbacks_router,
display_router,
health_router,
links_router,
media_router,
scripts_router,
)
from .services import get_media_controller
from .services.websocket_manager import ws_manager
@@ -42,6 +52,9 @@ def setup_logging():
handlers=[handler],
)
# Suppress noisy third-party loggers
logging.getLogger("screen_brightness_control").setLevel(logging.ERROR)
@asynccontextmanager
async def lifespan(app: FastAPI):
@@ -50,19 +63,66 @@ async def lifespan(app: FastAPI):
logger = logging.getLogger(__name__)
logger.info(f"Media Server starting on {settings.host}:{settings.port}")
# Log all configured tokens
for label, token in settings.api_tokens.items():
logger.info(f"API Token [{label}]: {token[:8]}...")
# Log authentication status
if settings.api_tokens:
for label, token in settings.api_tokens.items():
logger.info(f"API Token [{label}]: {token[:8]}...")
else:
logger.warning("No API tokens configured — authentication is DISABLED")
# Start WebSocket status monitor
controller = get_media_controller()
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
# Register audio visualizer (capture starts on-demand when clients subscribe)
analyzer = None
if settings.visualizer_enabled:
from .services.audio_analyzer import get_audio_analyzer
analyzer = get_audio_analyzer(
num_bins=settings.visualizer_bins,
target_fps=settings.visualizer_fps,
device_name=settings.visualizer_device,
)
if analyzer.available:
await ws_manager.start_audio_monitor(analyzer)
logger.info("Audio visualizer available (capture on-demand)")
else:
logger.info("Audio visualizer unavailable (install soundcard + numpy)")
yield
# Stop update checker
if update_checker is not None:
await update_checker.stop()
# Stop audio visualizer
await ws_manager.stop_audio_monitor()
if analyzer and analyzer.running:
analyzer.stop()
# Stop WebSocket status monitor
await ws_manager.stop_status_monitor()
# Clean up platform-specific resources
import platform as _platform
if _platform.system() == "Windows":
from .services.windows_media import shutdown_executor
shutdown_executor()
logger.info("Media Server shutting down")
@@ -79,10 +139,11 @@ def create_app() -> FastAPI:
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
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
@@ -91,24 +152,28 @@ def create_app() -> FastAPI:
@app.middleware("http")
async def token_logging_middleware(request: Request, call_next):
"""Extract token label and set in context for logging."""
token_label = "unknown"
if not settings.api_tokens:
token_label_var.set("anonymous")
else:
token_label = "unknown"
# Try Authorization header
auth_header = request.headers.get("authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:]
label = get_token_label(token)
if label:
token_label = label
# Try Authorization header
auth_header = request.headers.get("authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:]
label = get_token_label(token)
if label:
token_label = label
# Try query parameter (for artwork endpoint)
elif "token" in request.query_params:
token = request.query_params["token"]
label = get_token_label(token)
if label:
token_label = label
# Try query parameter (for artwork endpoint)
elif "token" in request.query_params:
token = request.query_params["token"]
label = get_token_label(token)
if label:
token_label = label
token_label_var.set(token_label)
token_label_var.set(token_label)
response = await call_next(request)
return response
@@ -125,6 +190,15 @@ def create_app() -> FastAPI:
# Mount static files and serve UI at root
static_dir = Path(__file__).parent / "static"
if static_dir.exists():
@app.get("/sw.js", include_in_schema=False)
async def serve_service_worker():
"""Serve service worker from root scope for PWA installability."""
return FileResponse(
static_dir / "sw.js",
media_type="application/javascript",
headers={"Cache-Control": "no-cache"},
)
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
@app.get("/", include_in_schema=False)
@@ -162,28 +236,98 @@ def main():
action="store_true",
help="Show the current API token and exit",
)
parser.add_argument(
"--no-tray",
action="store_true",
help="Disable system tray icon (for headless/service mode)",
)
args = parser.parse_args()
if args.generate_config:
config_path = generate_default_config()
print(f"Configuration file generated at: {config_path}")
print(f"API Token has been saved to the config file.")
print("Authentication is disabled by default. Add api_tokens to enable it.")
return
if args.show_token:
print(f"Config directory: {get_config_dir()}")
print(f"\nAPI Tokens:")
for label, token in settings.api_tokens.items():
print(f" {label:20} {token}")
if settings.api_tokens:
print("\nAPI Tokens:")
for label, token in settings.api_tokens.items():
print(f" {label:20} {token}")
else:
print("\nAuthentication is DISABLED (no tokens configured)")
return
uvicorn.run(
"media_server.main:app",
host=args.host,
port=args.port,
reload=False,
)
# 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
if use_tray:
import asyncio
import threading
# Run uvicorn in a background thread so tray owns the main thread message loop
uv_config = uvicorn.Config(
"media_server.main:app",
host=args.host,
port=args.port,
log_level=settings.log_level.lower(),
)
server = uvicorn.Server(uv_config)
def run_server():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(server.serve())
server_thread = threading.Thread(target=run_server, daemon=True)
server_thread.start()
# Tray on main thread (blocking)
tray = TrayManager(
port=args.port,
on_exit=lambda: setattr(server, "should_exit", True),
)
tray.run()
# Tray exited — wait for server to finish graceful shutdown
server_thread.join(timeout=10)
if tray.restart_requested:
import subprocess
# Always restart via `python -m media_server.main` — this works
# regardless of how we were originally started (console_script,
# python -m, or direct script invocation).
cmd = [sys.executable, "-m", "media_server.main"]
subprocess.Popen(
cmd,
cwd=Path.cwd(),
start_new_session=True,
)
else:
uvicorn.run(
"media_server.main:app",
host=args.host,
port=args.port,
reload=False,
)
if __name__ == "__main__":
+2 -2
View File
@@ -1,11 +1,11 @@
"""Pydantic models for the media server API."""
from .media import (
MediaInfo,
MediaState,
MediaStatus,
VolumeRequest,
SeekRequest,
MediaInfo,
VolumeRequest,
)
__all__ = [
+10 -1
View File
@@ -9,4 +9,13 @@ from .links import router as links_router
from .media import router as media_router
from .scripts import router as scripts_router
__all__ = ["audio_router", "browser_router", "callbacks_router", "display_router", "health_router", "links_router", "media_router", "scripts_router"]
__all__ = [
"audio_router",
"browser_router",
"callbacks_router",
"display_router",
"health_router",
"links_router",
"media_router",
"scripts_router",
]
+22 -6
View File
@@ -4,20 +4,19 @@ import asyncio
import logging
import tempfile
from pathlib import Path
from typing import Optional
from urllib.parse import unquote
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from fastapi.responses import FileResponse, StreamingResponse
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from ..auth import verify_token, verify_token_or_query
from ..config import MediaFolderConfig, settings
from ..config_manager import config_manager
from ..services import get_media_controller
from ..services.browser_service import BrowserService
from ..services.metadata_service import MetadataService
from ..services.thumbnail_service import ThumbnailService
from ..services import get_media_controller
from ..services.websocket_manager import ws_manager
logger = logging.getLogger(__name__)
@@ -25,6 +24,15 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/browser", tags=["browser"])
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.
@@ -84,17 +92,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")
@@ -113,6 +126,7 @@ 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():
@@ -170,6 +184,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)
@@ -218,6 +233,7 @@ async def delete_folder(
Raises:
HTTPException: If folder doesn't exist.
"""
_require_folder_management()
try:
config_manager.delete_media_folder(folder_id)
@@ -281,7 +297,7 @@ async def browse(
logger.warning(f"Folder temporarily unavailable: {e}")
raise HTTPException(
status_code=503,
detail=f"Folder is temporarily unavailable. It may be a network share that is not accessible at the moment."
detail="Folder is temporarily unavailable. It may be a network share that is not accessible at the moment."
)
except Exception as e:
logger.error(f"Error browsing directory (type: {type(e).__name__}): {e}")
+14 -5
View File
@@ -2,9 +2,9 @@
import asyncio
import logging
import re
import subprocess
import time
from concurrent.futures import ThreadPoolExecutor
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
@@ -17,6 +17,9 @@ from ..config_manager import config_manager
router = APIRouter(prefix="/api/callbacks", tags=["callbacks"])
logger = logging.getLogger(__name__)
# Dedicated executor for callback/subprocess execution
_callback_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="callback")
class CallbackInfo(BaseModel):
"""Information about a configured callback."""
@@ -127,10 +130,10 @@ async def execute_callback(
logger.info(f"Executing callback for debugging: {callback_name}")
try:
# Execute in thread pool to not block
# Execute in dedicated thread pool to not block the default executor
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
None,
_callback_executor,
lambda: _run_callback(
command=callback_config.command,
timeout=callback_config.timeout,
@@ -234,7 +237,10 @@ async def create_callback(
if callback_name in settings.callbacks:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Callback '{callback_name}' already exists. Use PUT /api/callbacks/update/{callback_name} to update it.",
detail=(
f"Callback '{callback_name}' already exists."
f" Use PUT /api/callbacks/update/{callback_name} to update it."
),
)
# Create callback config
@@ -279,7 +285,10 @@ async def update_callback(
if callback_name not in settings.callbacks:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Callback '{callback_name}' not found. Use POST /api/callbacks/create/{callback_name} to create it.",
detail=(
f"Callback '{callback_name}' not found."
f" Use POST /api/callbacks/create/{callback_name} to create it."
),
)
# Create updated callback config
-1
View File
@@ -7,7 +7,6 @@ from pydantic import BaseModel, Field
from ..auth import verify_token
from ..services.display_service import (
get_brightness,
list_monitors,
set_brightness,
set_power,
+17 -4
View File
@@ -3,20 +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
+70 -13
View File
@@ -3,14 +3,13 @@
import asyncio
import logging
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
from fastapi import status
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect, status
from fastapi.responses import Response
from ..auth import verify_token, verify_token_or_query
from ..config import settings
from ..models import MediaStatus, VolumeRequest, SeekRequest
from ..services import get_media_controller, get_current_album_art
from ..models import MediaStatus, SeekRequest, VolumeRequest
from ..services import get_current_album_art, get_media_controller
from ..services.websocket_manager import ws_manager
logger = logging.getLogger(__name__)
@@ -268,10 +267,63 @@ async def get_artwork(_: str = Depends(verify_token_or_query)) -> Response:
return Response(content=art_bytes, media_type=content_type)
@router.get("/visualizer/status")
async def visualizer_status(_: str = Depends(verify_token)) -> dict:
"""Check if audio visualizer is available and running."""
from ..services.audio_analyzer import get_audio_analyzer
analyzer = get_audio_analyzer()
return {
"available": analyzer.available,
"running": analyzer.running,
"current_device": analyzer.current_device,
}
@router.get("/visualizer/devices")
async def visualizer_devices(_: str = Depends(verify_token)) -> list[dict[str, str]]:
"""List available loopback audio devices for the visualizer."""
from ..services.audio_analyzer import AudioAnalyzer
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, AudioAnalyzer.list_loopback_devices)
@router.post("/visualizer/device")
async def set_visualizer_device(
request: dict,
_: str = Depends(verify_token),
) -> dict:
"""Set the loopback audio device for the visualizer.
Body: {"device_name": "Device Name" | null}
Passing null resets to auto-detect.
"""
from ..services.audio_analyzer import get_audio_analyzer
device_name = request.get("device_name")
analyzer = get_audio_analyzer()
# 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,
"running": analyzer.running,
}
@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.
@@ -288,15 +340,16 @@ async def websocket_endpoint(
- {"type": "get_status"} - Request current status
"""
# Verify token
from ..auth import get_token_label, token_label_var
from ..auth import auth_enabled, get_token_label, token_label_var
label = get_token_label(token) if token else None
if label is None:
await websocket.close(code=4001, reason="Invalid authentication token")
return
# Set label in context for logging
token_label_var.set(label)
if auth_enabled():
label = get_token_label(token) if token else None
if label is None:
await websocket.close(code=4001, reason="Invalid authentication token")
return
token_label_var.set(label)
else:
token_label_var.set("anonymous")
await ws_manager.connect(websocket)
@@ -321,6 +374,10 @@ async def websocket_endpoint(
if volume is not None:
controller = get_media_controller()
await controller.set_volume(int(volume))
elif data.get("type") == "enable_visualizer":
await ws_manager.subscribe_visualizer(websocket)
elif data.get("type") == "disable_visualizer":
await ws_manager.unsubscribe_visualizer(websocket)
except WebSocketDisconnect:
await ws_manager.disconnect(websocket)
+245 -18
View File
@@ -5,24 +5,30 @@ import logging
import re
import subprocess
import time
from concurrent.futures import ThreadPoolExecutor
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from ..auth import verify_token
from ..config import ScriptConfig, settings
from ..config import ScriptConfig, ScriptParameterConfig, settings
from ..config_manager import config_manager
from ..services.websocket_manager import ws_manager
router = APIRouter(prefix="/api/scripts", tags=["scripts"])
# Dedicated executor for script/subprocess execution (avoids blocking the default pool)
_script_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="script")
logger = logging.getLogger(__name__)
class ScriptExecuteRequest(BaseModel):
"""Request model for script execution with optional arguments."""
"""Request model for script execution with optional parameters."""
args: list[str] = Field(default_factory=list, description="Additional arguments")
params: dict[str, str | int | float | bool] = Field(
default_factory=dict, description="Named parameters (validated against script schema)"
)
class ScriptExecuteResponse(BaseModel):
@@ -37,6 +43,18 @@ class ScriptExecuteResponse(BaseModel):
execution_time: float | None = None
class ScriptParameterInfo(BaseModel):
"""Information about a script parameter."""
type: str
description: str = ""
required: bool = False
default: str | int | float | bool | None = None
min: float | None = None
max: float | None = None
options: list[str] | None = None
class ScriptInfo(BaseModel):
"""Information about an available script."""
@@ -46,6 +64,7 @@ class ScriptInfo(BaseModel):
description: str
icon: str | None = None
timeout: int
parameters: dict[str, ScriptParameterInfo] = Field(default_factory=dict)
@router.get("/list")
@@ -63,11 +82,126 @@ async def list_scripts(_: str = Depends(verify_token)) -> list[ScriptInfo]:
description=config.description,
icon=config.icon,
timeout=config.timeout,
parameters={
pname: ScriptParameterInfo(**pconfig.model_dump())
for pname, pconfig in config.parameters.items()
},
)
for name, config in settings.scripts.items()
]
def _validate_params(
params: dict[str, str | int | float | bool],
param_defs: dict[str, ScriptParameterConfig],
) -> dict[str, str]:
"""Validate parameters against script schema and return env vars.
Args:
params: User-supplied parameter values.
param_defs: Parameter definitions from script config.
Returns:
Dict of environment variables (SCRIPT_PARAM_<NAME> -> str value).
Raises:
HTTPException: On validation failure.
"""
# Reject unknown parameters
unknown = set(params.keys()) - set(param_defs.keys())
if unknown:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unknown parameters: {', '.join(sorted(unknown))}",
)
env_vars: dict[str, str] = {}
for pname, pdef in param_defs.items():
value = params.get(pname)
# Apply default if missing
if value is None and pdef.default is not None:
value = pdef.default
# Check required
if value is None:
if pdef.required:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Required parameter '{pname}' is missing",
)
continue
# Type validation and coercion
if pdef.type == "integer":
try:
value = int(value)
except (TypeError, ValueError):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Parameter '{pname}' must be an integer, got: {value!r}",
)
if pdef.min is not None and value < pdef.min:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Parameter '{pname}' must be >= {pdef.min}, got: {value}",
)
if pdef.max is not None and value > pdef.max:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Parameter '{pname}' must be <= {pdef.max}, got: {value}",
)
elif pdef.type == "float":
try:
value = float(value)
except (TypeError, ValueError):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Parameter '{pname}' must be a number, got: {value!r}",
)
if pdef.min is not None and value < pdef.min:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Parameter '{pname}' must be >= {pdef.min}, got: {value}",
)
if pdef.max is not None and value > pdef.max:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Parameter '{pname}' must be <= {pdef.max}, got: {value}",
)
elif pdef.type == "boolean":
if isinstance(value, str):
if value.lower() in ("true", "1", "yes"):
value = True
elif value.lower() in ("false", "0", "no"):
value = False
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Parameter '{pname}' must be a boolean, got: {value!r}",
)
elif not isinstance(value, bool):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Parameter '{pname}' must be a boolean, got: {value!r}",
)
elif pdef.type == "select":
value = str(value)
if pdef.options and value not in pdef.options:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Parameter '{pname}' must be one of {pdef.options}, got: {value!r}",
)
else:
# string — just convert to str
value = str(value)
env_vars[f"SCRIPT_PARAM_{pname.upper()}"] = str(value)
return env_vars
@router.post("/execute/{script_name}")
async def execute_script(
script_name: str,
@@ -78,7 +212,7 @@ async def execute_script(
Args:
script_name: Name of the script to execute (must be defined in config)
request: Optional arguments to pass to the script
request: Optional parameters to pass to the script
Returns:
Execution result including stdout, stderr, and exit code
@@ -90,26 +224,24 @@ async def execute_script(
detail=f"Script '{script_name}' not found. Use /api/scripts/list to see available scripts.",
)
script_config = settings.scripts[script_name]
args = request.args if request else []
params = request.params if request else {}
# Validate parameters and build env vars
extra_env = _validate_params(params, script_config.parameters)
logger.info(f"Executing script: {script_name}")
try:
# Build command
command = script_config.command
if args:
# Append arguments to command
command = f"{command} {' '.join(args)}"
# Execute in thread pool to not block
# Execute in dedicated thread pool to not block the default executor
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
None,
_script_executor,
lambda: _run_script(
command=command,
command=script_config.command,
timeout=script_config.timeout,
shell=script_config.shell,
working_dir=script_config.working_dir,
extra_env=extra_env,
),
)
@@ -136,6 +268,7 @@ def _run_script(
timeout: int,
shell: bool,
working_dir: str | None,
extra_env: dict[str, str] | None = None,
) -> dict[str, Any]:
"""Run a script synchronously.
@@ -144,11 +277,16 @@ def _run_script(
timeout: Timeout in seconds
shell: Whether to run in shell
working_dir: Working directory
extra_env: Additional environment variables (e.g. SCRIPT_PARAM_*)
Returns:
Dict with exit_code, stdout, stderr, execution_time
"""
start_time = time.time()
env = None
if extra_env:
import os
env = {**os.environ, **extra_env}
try:
result = subprocess.run(
command,
@@ -157,6 +295,7 @@ def _run_script(
capture_output=True,
text=True,
timeout=timeout,
env=env,
)
execution_time = time.time() - start_time
return {
@@ -186,6 +325,24 @@ def _run_script(
# Script management endpoints
class ScriptParameterCreateRequest(BaseModel):
"""Request model for a script parameter definition."""
type: str = Field(
..., description="Parameter type: string, integer, float, boolean, select"
)
description: str = Field(default="", description="Parameter description")
required: bool = Field(default=False, description="Whether the parameter is required")
default: str | int | float | bool | None = Field(
default=None, description="Default value if not provided"
)
min: float | None = Field(default=None, description="Minimum value (numeric types only)")
max: float | None = Field(default=None, description="Maximum value (numeric types only)")
options: list[str] | None = Field(
default=None, description="Allowed values (select type only)"
)
class ScriptCreateRequest(BaseModel):
"""Request model for creating or updating a script."""
@@ -196,6 +353,60 @@ class ScriptCreateRequest(BaseModel):
timeout: int = Field(default=30, description="Execution timeout in seconds", ge=1, le=300)
working_dir: str | None = Field(default=None, description="Working directory")
shell: bool = Field(default=True, description="Run command in shell")
parameters: dict[str, ScriptParameterCreateRequest] = Field(
default_factory=dict, description="Named parameters with type and validation rules"
)
def _validate_parameter_definitions(
parameters: dict[str, ScriptParameterCreateRequest],
) -> None:
"""Validate parameter definitions are well-formed.
Args:
parameters: Parameter definitions to validate.
Raises:
HTTPException: If any definition is invalid.
"""
valid_types = {"string", "integer", "float", "boolean", "select"}
for pname, pdef in parameters.items():
if not re.match(r"^[a-zA-Z][a-zA-Z0-9_]*$", pname):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
f"Parameter name '{pname}' must start with a letter"
" and contain only alphanumeric characters and underscores"
),
)
if pdef.type not in valid_types:
allowed = ", ".join(sorted(valid_types))
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Parameter '{pname}' has invalid type '{pdef.type}'. Must be one of: {allowed}",
)
if pdef.type == "select":
if not pdef.options or len(pdef.options) == 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Parameter '{pname}' of type 'select' must have a non-empty 'options' list",
)
if pdef.type not in ("integer", "float"):
if pdef.min is not None or pdef.max is not None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Parameter '{pname}': 'min'/'max' are only valid for integer/float types",
)
if pdef.min is not None and pdef.max is not None and pdef.min > pdef.max:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Parameter '{pname}': 'min' ({pdef.min}) must be <= 'max' ({pdef.max})",
)
def _validate_script_name(name: str) -> None:
@@ -254,8 +465,16 @@ async def create_script(
detail=f"Script '{script_name}' already exists. Use PUT /api/scripts/update/{script_name} to update it.",
)
# Create script config
script_config = ScriptConfig(**request.model_dump())
# Validate parameter definitions
_validate_parameter_definitions(request.parameters)
# Build ScriptConfig with ScriptParameterConfig instances
data = request.model_dump()
data["parameters"] = {
pname: ScriptParameterConfig(**pdef)
for pname, pdef in data.get("parameters", {}).items()
}
script_config = ScriptConfig(**data)
# Add to config file and in-memory
try:
@@ -302,8 +521,16 @@ async def update_script(
detail=f"Script '{script_name}' not found. Use POST /api/scripts/create/{script_name} to create it.",
)
# Create updated script config
script_config = ScriptConfig(**request.model_dump())
# Validate parameter definitions
_validate_parameter_definitions(request.parameters)
# Build ScriptConfig with ScriptParameterConfig instances
data = request.model_dump()
data["parameters"] = {
pname: ScriptParameterConfig(**pdef)
for pname, pdef in data.get("parameters", {}).items()
}
script_config = ScriptConfig(**data)
# Update config file and in-memory
try:
+6 -9
View File
@@ -13,15 +13,12 @@ Usage:
import os
import sys
import socket
import logging
try:
import win32serviceutil
import win32service
import win32event
import servicemanager
import win32api
import win32event
import win32service
import win32serviceutil
WIN32_AVAILABLE = True
except ImportError:
@@ -64,8 +61,9 @@ class MediaServerService:
def main(self):
"""Main service loop."""
import uvicorn
from media_server.main import app
from media_server.config import settings
from media_server.main import app
config = uvicorn.Config(
app,
@@ -95,10 +93,9 @@ def install_service():
try:
# Get the path to the Python executable
python_exe = sys.executable
# Get the path to this module
module_path = os.path.abspath(__file__)
os.path.abspath(__file__)
win32serviceutil.InstallService(
MediaServerService._svc_name_,
+1 -1
View File
@@ -40,8 +40,8 @@ def get_media_controller() -> "MediaController":
system = platform.system()
if system == "Windows":
from .windows_media import WindowsMediaController
from ..config import settings
from .windows_media import WindowsMediaController
_controller_instance = WindowsMediaController(audio_device=settings.audio_device)
elif system == "Linux":
+1 -2
View File
@@ -10,11 +10,10 @@ Installation:
4. Grant necessary permissions to Termux:API
"""
import asyncio
import json
import logging
import subprocess
from typing import Optional, Any
from typing import Any, Optional
from ..models import MediaState, MediaStatus
from .media_controller import MediaController
+333
View File
@@ -0,0 +1,333 @@
"""Audio spectrum analyzer service using system loopback capture."""
import logging
import platform
import threading
import time
logger = logging.getLogger(__name__)
_np = None
_sc = None
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 Exception as e:
logger.warning("numpy unavailable - audio visualizer disabled: %s", e)
return _np
def _load_soundcard():
global _sc
if _sc is None:
try:
import soundcard as sc
_sc = sc
except Exception as e:
logger.warning("soundcard unavailable - audio visualizer disabled: %s", e)
return _sc
class AudioAnalyzer:
"""Captures system audio loopback and performs real-time FFT analysis."""
def __init__(
self,
num_bins: int = 32,
sample_rate: int = 44100,
chunk_size: int = 1024,
target_fps: int = 30,
device_name: str | None = None,
):
self.num_bins = num_bins
self.sample_rate = sample_rate
self.chunk_size = chunk_size
self.target_fps = target_fps
self.device_name = device_name
self._running = False
self._thread: threading.Thread | None = None
self._lock = threading.Lock()
self._lifecycle_lock = threading.Lock()
self._data: dict | None = None
self._current_device_name: str | None = None
# Pre-compute logarithmic bin edges
self._bin_edges = self._compute_bin_edges()
def _compute_bin_edges(self) -> list[int]:
"""Compute logarithmic frequency bin boundaries for perceptual grouping."""
np = _load_numpy()
if np is None:
return []
fft_size = self.chunk_size // 2 + 1
min_freq = 20.0
max_freq = min(16000.0, self.sample_rate / 2)
edges = []
for i in range(self.num_bins + 1):
freq = min_freq * (max_freq / min_freq) ** (i / self.num_bins)
bin_idx = int(freq * self.chunk_size / self.sample_rate)
edges.append(min(bin_idx, fft_size - 1))
return edges
@property
def available(self) -> bool:
"""Whether audio capture dependencies are available."""
return _load_numpy() is not None and _load_soundcard() is not None
@property
def running(self) -> bool:
"""Whether capture is currently active."""
return self._running
def start(self) -> bool:
"""Start audio capture in a background thread. Returns False if unavailable."""
with self._lifecycle_lock:
if self._running:
return True
if not self.available:
return False
self._running = True
self._thread = threading.Thread(target=self._capture_loop, daemon=True)
self._thread.start()
return True
def stop(self) -> None:
"""Stop audio capture and cleanup."""
with self._lifecycle_lock:
self._running = False
if self._thread:
self._thread.join(timeout=3.0)
self._thread = None
with self._lock:
self._data = None
def get_frequency_data(self) -> dict | None:
"""Return latest frequency data (thread-safe). None if not running."""
with self._lock:
return self._data
@staticmethod
def list_loopback_devices() -> list[dict[str, str]]:
"""List all available loopback audio devices."""
sc = _load_soundcard()
if sc is None:
return []
devices = []
try:
# COM may be needed on Windows for WASAPI
if platform.system() == "Windows":
try:
import comtypes
comtypes.CoInitializeEx(comtypes.COINIT_MULTITHREADED)
except Exception:
pass
loopback_mics = sc.all_microphones(include_loopback=True)
for mic in loopback_mics:
if mic.isloopback:
devices.append({"id": mic.id, "name": mic.name})
except Exception as e:
logger.warning("Failed to list loopback devices: %s", e)
return devices
def _find_loopback_device(self):
"""Find a loopback device for system audio capture."""
sc = _load_soundcard()
if sc is None:
return None
try:
loopback_mics = sc.all_microphones(include_loopback=True)
# If a specific device is requested, find it by name (partial match)
if self.device_name:
target = self.device_name.lower()
for mic in loopback_mics:
if mic.isloopback and target in mic.name.lower():
logger.info("Found requested loopback device: %s", mic.name)
self._current_device_name = mic.name
return mic
logger.warning("Requested device '%s' not found, falling back to default", self.device_name)
# Default: first loopback device
for mic in loopback_mics:
if mic.isloopback:
logger.info("Found loopback device: %s", mic.name)
self._current_device_name = mic.name
return mic
# Fallback: try to get default speaker's loopback
default_speaker = sc.default_speaker()
if default_speaker:
for mic in loopback_mics:
if default_speaker.name in mic.name:
logger.info("Found speaker loopback: %s", mic.name)
self._current_device_name = mic.name
return mic
except Exception as e:
logger.warning("Failed to find loopback device: %s", e)
return None
def set_device(self, device_name: str | None) -> bool:
"""Change the loopback device. Restarts capture if running. Returns True on success."""
was_running = self._running
if was_running:
self.stop()
self.device_name = device_name
self._current_device_name = None
if was_running:
return self.start()
return True
@property
def current_device(self) -> str | None:
"""Return the name of the currently active loopback device."""
return self._current_device_name
def _capture_loop(self) -> None:
"""Background thread: capture audio and compute FFT continuously."""
# Initialize COM on Windows (required for WASAPI/SoundCard)
if platform.system() == "Windows":
try:
import comtypes
comtypes.CoInitializeEx(comtypes.COINIT_MULTITHREADED)
except Exception:
try:
import ctypes
ctypes.windll.ole32.CoInitializeEx(0, 0)
except Exception as e:
logger.warning("Failed to initialize COM: %s", e)
np = _load_numpy()
sc = _load_soundcard()
if np is None or sc is None:
self._running = False
return
device = self._find_loopback_device()
if device is None:
logger.warning("No loopback audio device found - visualizer disabled")
self._running = False
return
interval = 1.0 / self.target_fps
window = np.hanning(self.chunk_size)
# 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)
try:
with device.recorder(
samplerate=self.sample_rate,
channels=1,
blocksize=self.chunk_size,
) as recorder:
logger.info("Audio capture started on: %s", device.name)
while self._running:
t0 = time.monotonic()
try:
data = recorder.record(numframes=self.chunk_size)
except Exception as e:
logger.debug("Audio capture read error: %s", e)
time.sleep(interval)
continue
# Mono mix if needed
if data.ndim > 1:
mono = data.mean(axis=1)
else:
mono = data.ravel()
if len(mono) < self.chunk_size:
time.sleep(interval)
continue
# Apply window and compute FFT
windowed = mono[:self.chunk_size] * window
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
# Normalize to 0-1
max_val = bins.max()
if max_val > 0:
bins *= (1.0 / max_val)
# 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)
with self._lock:
self._data = {"frequencies": frequencies, "bass": bass}
# Throttle to target FPS
elapsed = time.monotonic() - t0
if elapsed < interval:
time.sleep(interval - elapsed)
except Exception as e:
logger.error("Audio capture loop error: %s", e)
finally:
self._running = False
logger.info("Audio capture stopped")
# Global singleton
_analyzer: AudioAnalyzer | None = None
def get_audio_analyzer(
num_bins: int = 32,
sample_rate: int = 44100,
target_fps: int = 25,
device_name: str | None = None,
) -> AudioAnalyzer:
"""Get or create the global AudioAnalyzer instance."""
global _analyzer
if _analyzer is None:
_analyzer = AudioAnalyzer(
num_bins=num_bins,
sample_rate=sample_rate,
target_fps=target_fps,
device_name=device_name,
)
return _analyzer
-2
View File
@@ -1,12 +1,10 @@
"""Browser service for media file browsing and path validation."""
import logging
import os
import stat as stat_module
import time
from datetime import datetime
from pathlib import Path
from typing import Optional
from ..config import settings
+1 -1
View File
@@ -6,7 +6,7 @@ import logging
import platform
import struct
import time
from dataclasses import dataclass, field
from dataclasses import dataclass
logger = logging.getLogger(__name__)
@@ -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
+1 -1
View File
@@ -3,7 +3,7 @@
import asyncio
import logging
import subprocess
from typing import Optional, Any
from typing import Any, Optional
from ..models import MediaState, MediaStatus
from .media_controller import MediaController
-6
View File
@@ -3,7 +3,6 @@
import asyncio
import logging
import subprocess
import json
from typing import Optional
from ..models import MediaState, MediaStatus
@@ -203,11 +202,6 @@ class MacOSMediaController(MediaController):
async def play(self) -> bool:
"""Resume playback using media key simulation."""
# Use system media key
script = '''
tell application "System Events"
key code 16 using {command down, option down}
end tell
'''
# Fallback: try specific app
active_app = self._get_active_app()
if active_app == "Spotify":
+80 -71
View File
@@ -2,7 +2,6 @@
import logging
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
@@ -21,68 +20,75 @@ class MetadataService:
Dictionary with audio metadata.
"""
try:
import mutagen
from mutagen import File as MutagenFile
audio = MutagenFile(str(file_path), easy=True)
if audio is None:
return {"error": "Unable to read audio file"}
metadata = {
"type": "audio",
"filename": file_path.name,
"path": str(file_path),
}
try:
metadata = {
"type": "audio",
"filename": file_path.name,
"path": str(file_path),
}
# Extract duration
if hasattr(audio.info, "length"):
metadata["duration"] = round(audio.info.length, 2)
# Extract duration
if hasattr(audio.info, "length"):
metadata["duration"] = round(audio.info.length, 2)
# Extract bitrate
if hasattr(audio.info, "bitrate"):
metadata["bitrate"] = audio.info.bitrate
# Extract bitrate
if hasattr(audio.info, "bitrate"):
metadata["bitrate"] = audio.info.bitrate
# Extract sample rate
if hasattr(audio.info, "sample_rate"):
metadata["sample_rate"] = audio.info.sample_rate
elif hasattr(audio.info, "samplerate"):
metadata["sample_rate"] = audio.info.samplerate
# Extract sample rate
if hasattr(audio.info, "sample_rate"):
metadata["sample_rate"] = audio.info.sample_rate
elif hasattr(audio.info, "samplerate"):
metadata["sample_rate"] = audio.info.samplerate
# Extract channels
if hasattr(audio.info, "channels"):
metadata["channels"] = audio.info.channels
# Extract channels
if hasattr(audio.info, "channels"):
metadata["channels"] = audio.info.channels
# Extract tags (use easy=True for consistent tag names)
if audio is not None and hasattr(audio, "tags") and audio.tags:
# Easy tags provide lists, so we take the first item
tags = audio.tags
# Extract tags (use easy=True for consistent tag names)
if audio is not None and hasattr(audio, "tags") and audio.tags:
# Easy tags provide lists, so we take the first item
tags = audio.tags
if "title" in tags:
metadata["title"] = tags["title"][0] if isinstance(tags["title"], list) else tags["title"]
if "title" in tags:
metadata["title"] = tags["title"][0] if isinstance(tags["title"], list) else tags["title"]
if "artist" in tags:
metadata["artist"] = tags["artist"][0] if isinstance(tags["artist"], list) else tags["artist"]
if "artist" in tags:
metadata["artist"] = tags["artist"][0] if isinstance(tags["artist"], list) else tags["artist"]
if "album" in tags:
metadata["album"] = tags["album"][0] if isinstance(tags["album"], list) else tags["album"]
if "album" in tags:
metadata["album"] = tags["album"][0] if isinstance(tags["album"], list) else tags["album"]
if "albumartist" in tags:
metadata["album_artist"] = tags["albumartist"][0] if isinstance(tags["albumartist"], list) else tags["albumartist"]
if "albumartist" in tags:
metadata["album_artist"] = (
tags["albumartist"][0] if isinstance(tags["albumartist"], list) else tags["albumartist"]
)
if "date" in tags:
metadata["date"] = tags["date"][0] if isinstance(tags["date"], list) else tags["date"]
if "date" in tags:
metadata["date"] = tags["date"][0] if isinstance(tags["date"], list) else tags["date"]
if "genre" in tags:
metadata["genre"] = tags["genre"][0] if isinstance(tags["genre"], list) else tags["genre"]
if "genre" in tags:
metadata["genre"] = tags["genre"][0] if isinstance(tags["genre"], list) else tags["genre"]
if "tracknumber" in tags:
metadata["track_number"] = tags["tracknumber"][0] if isinstance(tags["tracknumber"], list) else tags["tracknumber"]
if "tracknumber" in tags:
metadata["track_number"] = (
tags["tracknumber"][0] if isinstance(tags["tracknumber"], list) else tags["tracknumber"]
)
# If no title tag, use filename
if "title" not in metadata:
metadata["title"] = file_path.stem
# If no title tag, use filename
if "title" not in metadata:
metadata["title"] = file_path.stem
return metadata
return metadata
finally:
if hasattr(audio, 'close'):
audio.close()
except ImportError:
logger.error("mutagen library not installed, cannot extract metadata")
@@ -106,7 +112,6 @@ class MetadataService:
Dictionary with video metadata.
"""
try:
import mutagen
from mutagen import File as MutagenFile
video = MutagenFile(str(file_path))
@@ -117,40 +122,44 @@ class MetadataService:
"title": file_path.stem,
}
metadata = {
"type": "video",
"filename": file_path.name,
"path": str(file_path),
}
try:
metadata = {
"type": "video",
"filename": file_path.name,
"path": str(file_path),
}
# Extract duration
if hasattr(video.info, "length"):
metadata["duration"] = round(video.info.length, 2)
# Extract duration
if hasattr(video.info, "length"):
metadata["duration"] = round(video.info.length, 2)
# Extract bitrate
if hasattr(video.info, "bitrate"):
metadata["bitrate"] = video.info.bitrate
# Extract bitrate
if hasattr(video.info, "bitrate"):
metadata["bitrate"] = video.info.bitrate
# Extract video-specific properties if available
if hasattr(video.info, "width"):
metadata["width"] = video.info.width
# Extract video-specific properties if available
if hasattr(video.info, "width"):
metadata["width"] = video.info.width
if hasattr(video.info, "height"):
metadata["height"] = video.info.height
if hasattr(video.info, "height"):
metadata["height"] = video.info.height
# Try to extract title from tags
if hasattr(video, "tags") and video.tags:
tags = video.tags
if hasattr(tags, "get"):
title = tags.get("title") or tags.get("TITLE") or tags.get("\xa9nam")
if title:
metadata["title"] = title[0] if isinstance(title, list) else str(title)
# Try to extract title from tags
if hasattr(video, "tags") and video.tags:
tags = video.tags
if hasattr(tags, "get"):
title = tags.get("title") or tags.get("TITLE") or tags.get("\xa9nam")
if title:
metadata["title"] = title[0] if isinstance(title, list) else str(title)
# If no title tag, use filename
if "title" not in metadata:
metadata["title"] = file_path.stem
# If no title tag, use filename
if "title" not in metadata:
metadata["title"] = file_path.stem
return metadata
return metadata
finally:
if hasattr(video, 'close'):
video.close()
except ImportError:
logger.error("mutagen library not installed, cannot extract metadata")
+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.
"""
...
+9 -6
View File
@@ -3,9 +3,7 @@
import asyncio
import hashlib
import logging
import os
import shutil
import subprocess
from pathlib import Path
from typing import Optional
@@ -151,10 +149,10 @@ class ThumbnailService:
Thumbnail bytes (JPEG) or None if no album art.
"""
try:
import mutagen
from io import BytesIO
from mutagen import File as MutagenFile
from PIL import Image
from io import BytesIO
audio = MutagenFile(str(file_path))
if audio is None:
@@ -232,9 +230,10 @@ class ThumbnailService:
Thumbnail bytes (JPEG) or None if ffmpeg not available.
"""
try:
from PIL import Image
from io import BytesIO
from PIL import Image
# Check if ffmpeg is available
if not shutil.which("ffmpeg"):
logger.debug("ffmpeg not available, cannot generate video thumbnail")
@@ -247,7 +246,11 @@ class ThumbnailService:
cmd = [
"ffmpeg",
"-i", str(file_path),
"-vf", f"thumbnail,scale={target_size[0]}:{target_size[1]}:force_original_aspect_ratio=increase,crop={target_size[0]}:{target_size[1]}",
"-vf", (
f"thumbnail,scale={target_size[0]}:{target_size[1]}"
f":force_original_aspect_ratio=increase"
f",crop={target_size[0]}:{target_size[1]}"
),
"-frames:v", "1",
"-f", "image2pipe",
"-vcodec", "mjpeg",
+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
+128 -4
View File
@@ -1,6 +1,7 @@
"""WebSocket connection manager and status broadcaster."""
import asyncio
import json
import logging
import time
from typing import Any, Callable, Coroutine
@@ -18,11 +19,16 @@ 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
self._last_broadcast_time: float = 0.0
self._running: bool = False
# Audio visualizer
self._visualizer_subscribers: set[WebSocket] = set()
self._audio_task: asyncio.Task | None = None
self._audio_analyzer = None
async def connect(self, websocket: WebSocket) -> None:
"""Accept a new WebSocket connection."""
@@ -34,16 +40,31 @@ 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)
async def disconnect(self, websocket: WebSocket) -> None:
"""Remove a WebSocket connection."""
"""Remove a WebSocket connection. Stops audio capture if last visualizer subscriber."""
should_stop = False
async with self._lock:
self._active_connections.discard(websocket)
was_subscriber = websocket in self._visualizer_subscribers
self._visualizer_subscribers.discard(websocket)
if was_subscriber and len(self._visualizer_subscribers) == 0:
should_stop = True
if should_stop:
await self._maybe_stop_capture()
logger.info(
"WebSocket client disconnected. Total: %d", len(self._active_connections)
)
@@ -83,6 +104,108 @@ class ConnectionManager:
await self.broadcast(message)
logger.info("Broadcast sent: links_changed")
async def subscribe_visualizer(self, websocket: WebSocket) -> None:
"""Subscribe a client to audio visualizer data. Starts capture on first subscriber."""
should_start = False
async with self._lock:
self._visualizer_subscribers.add(websocket)
if len(self._visualizer_subscribers) == 1 and self._audio_analyzer:
should_start = True
if should_start:
await self._maybe_start_capture()
logger.debug("Visualizer subscriber added. Total: %d", len(self._visualizer_subscribers))
async def unsubscribe_visualizer(self, websocket: WebSocket) -> None:
"""Unsubscribe a client from audio visualizer data. Stops capture on last subscriber."""
should_stop = False
async with self._lock:
self._visualizer_subscribers.discard(websocket)
if len(self._visualizer_subscribers) == 0:
should_stop = True
if should_stop:
await self._maybe_stop_capture()
logger.debug("Visualizer subscriber removed. Total: %d", len(self._visualizer_subscribers))
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()
started = await loop.run_in_executor(None, self._audio_analyzer.start)
if started:
logger.info("Audio capture started (first subscriber)")
else:
logger.warning("Audio capture failed to start")
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()
await loop.run_in_executor(None, self._audio_analyzer.stop)
logger.info("Audio capture stopped (no subscribers)")
async def start_audio_monitor(self, analyzer) -> None:
"""Register the audio analyzer. Capture starts on-demand when clients subscribe."""
self._audio_analyzer = analyzer
if analyzer and analyzer.available:
self._audio_task = asyncio.create_task(self._audio_broadcast_loop())
logger.info("Audio visualizer broadcast loop started (capture on-demand)")
async def stop_audio_monitor(self) -> None:
"""Stop audio frequency broadcasting."""
if self._audio_task:
self._audio_task.cancel()
try:
await self._audio_task
except asyncio.CancelledError:
pass
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
_last_data = None
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)
continue
data = self._audio_analyzer.get_frequency_data()
if data is None or data is _last_data:
await asyncio.sleep(interval)
continue
_last_data = data
# Pre-serialize once for all subscribers (avoids per-client JSON encoding)
text = json.dumps({"type": "audio_data", "data": data}, separators=(',', ':'))
async def _send(ws: WebSocket) -> WebSocket | None:
try:
await ws.send_text(text)
return None
except Exception:
return ws
results = await asyncio.gather(*(_send(ws) for ws in subscribers))
failed = [ws for ws in results if ws is not None]
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)
def status_changed(
self, old: dict[str, Any] | None, new: dict[str, Any]
) -> bool:
@@ -137,6 +260,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)
@@ -166,7 +290,7 @@ class ConnectionManager:
has_clients = len(self._active_connections) > 0
if not has_clients:
await asyncio.sleep(self._poll_interval)
await asyncio.sleep(2.0) # Sleep longer when no clients connected
continue
status = await get_status_func()
+147 -122
View File
@@ -2,8 +2,10 @@
import asyncio
import logging
import threading
import time as _time
from concurrent.futures import ThreadPoolExecutor
from typing import Optional, Any
from typing import Any
from ..models import MediaState, MediaStatus
from .media_controller import MediaController
@@ -16,8 +18,10 @@ _executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="winrt")
# Global storage for current album art (as bytes)
_current_album_art_bytes: bytes | None = None
# Lock protecting _position_cache and _track_skip_pending from concurrent access
_position_lock = threading.Lock()
# Global storage for position tracking
import time as _time
_position_cache = {
"track_id": "",
"base_position": 0.0,
@@ -43,6 +47,8 @@ def get_current_album_art() -> bytes | None:
try:
from winsdk.windows.media.control import (
GlobalSystemMediaTransportControlsSessionManager as MediaManager,
)
from winsdk.windows.media.control import (
GlobalSystemMediaTransportControlsSessionPlaybackStatus as PlaybackStatus,
)
@@ -57,11 +63,11 @@ _volume_control = None
_configured_device_name: str | None = None
try:
from ctypes import cast, POINTER
from comtypes import CLSCTX_ALL, CoInitialize, CoUninitialize
from pycaw.pycaw import AudioUtilities, IAudioEndpointVolume
import warnings
from ctypes import POINTER, cast
from comtypes import CLSCTX_ALL
from pycaw.pycaw import AudioUtilities, IAudioEndpointVolume
# Suppress pycaw warnings about missing device properties
warnings.filterwarnings("ignore", category=UserWarning, module="pycaw")
@@ -224,124 +230,138 @@ def _sync_get_media_status() -> dict[str, Any]:
is_playing = result["state"] == "playing"
current_title = result.get('title', '')
# Check if track skip is pending and title changed
skip_just_completed = False
if _track_skip_pending["active"]:
if current_title and current_title != _track_skip_pending["old_title"]:
# Title changed - clear the skip flag and start grace period
_track_skip_pending["active"] = False
_track_skip_pending["old_title"] = ""
_track_skip_pending["grace_until"] = current_time + 300.0 # Long grace period
_track_skip_pending["stale_pos"] = -999 # Reset stale position tracking
skip_just_completed = True
# Reset position cache for new track
new_track_id = f"{current_title}:{result.get('artist', '')}:{result.get('duration', 0)}"
_position_cache["track_id"] = new_track_id
with _position_lock:
# Check if track skip is pending and title changed
skip_just_completed = False
if _track_skip_pending["active"]:
if current_title and current_title != _track_skip_pending["old_title"]:
# Title changed - clear the skip flag and start grace period
_track_skip_pending["active"] = False
_track_skip_pending["old_title"] = ""
_track_skip_pending["grace_until"] = current_time + 300.0 # Long grace period
_track_skip_pending["stale_pos"] = -999 # Reset stale position tracking
skip_just_completed = True
# Reset position cache for new track
new_track_id = (
f"{current_title}:{result.get('artist', '')}:{result.get('duration', 0)}"
)
_position_cache["track_id"] = new_track_id
_position_cache["base_position"] = 0.0
_position_cache["base_time"] = current_time
_position_cache["last_smtc_pos"] = -999 # Force fresh start
_position_cache["is_playing"] = is_playing
logger.debug(
f"Track skip complete, new title: {current_title},"
f" grace until: {_track_skip_pending['grace_until']}"
)
elif current_time - _track_skip_pending["skip_time"] > 5.0:
# Timeout after 5 seconds
_track_skip_pending["active"] = False
logger.debug("Track skip timeout")
# Check if we're in grace period (after skip, ignore high SMTC positions)
in_grace_period = current_time < _track_skip_pending.get("grace_until", 0)
# If track skip is pending or just completed, use cached/reset position
if _track_skip_pending["active"]:
pos = 0.0
_position_cache["base_position"] = 0.0
_position_cache["base_time"] = current_time
_position_cache["last_smtc_pos"] = -999 # Force fresh start
_position_cache["is_playing"] = is_playing
logger.debug(f"Track skip complete, new title: {current_title}, grace until: {_track_skip_pending['grace_until']}")
elif current_time - _track_skip_pending["skip_time"] > 5.0:
# Timeout after 5 seconds
_track_skip_pending["active"] = False
logger.debug("Track skip timeout")
elif skip_just_completed:
# Just completed skip - interpolate from 0
if is_playing:
elapsed = current_time - _position_cache["base_time"]
pos = elapsed
else:
pos = 0.0
elif in_grace_period:
# Grace period after track skip
# SMTC position is stale (from old track) and won't update until seek/pause
# We interpolate from 0 and only trust SMTC when it changes or reports low value
# Check if we're in grace period (after skip, ignore high SMTC positions)
in_grace_period = current_time < _track_skip_pending.get("grace_until", 0)
# Calculate interpolated position from start of new track
if is_playing:
elapsed = current_time - _position_cache.get("base_time", current_time)
interpolated_pos = _position_cache.get("base_position", 0.0) + elapsed
else:
interpolated_pos = _position_cache.get("base_position", 0.0)
# If track skip is pending or just completed, use cached/reset position
if _track_skip_pending["active"]:
pos = 0.0
_position_cache["base_position"] = 0.0
_position_cache["base_time"] = current_time
_position_cache["is_playing"] = is_playing
elif skip_just_completed:
# Just completed skip - interpolate from 0
if is_playing:
elapsed = current_time - _position_cache["base_time"]
pos = elapsed
# Get the stale position we've been tracking
stale_pos = _track_skip_pending.get("stale_pos", -999)
# Detect if SMTC position changed significantly from the stale value (user seeked)
smtc_changed = stale_pos >= 0 and abs(smtc_pos - stale_pos) > 3.0
# Trust SMTC if:
# 1. It reports a low position (indicating new track started)
# 2. It changed from the stale value (user seeked)
if smtc_pos < 10.0 or smtc_changed:
# SMTC is now trustworthy
_position_cache["base_position"] = smtc_pos
_position_cache["base_time"] = current_time
_position_cache["last_smtc_pos"] = smtc_pos
_position_cache["is_playing"] = is_playing
pos = smtc_pos
_track_skip_pending["grace_until"] = 0
_track_skip_pending["stale_pos"] = -999
logger.debug(
f"Grace period: accepting SMTC pos {smtc_pos}"
f" (low={smtc_pos < 10}, changed={smtc_changed})"
)
else:
# SMTC is stale - keep interpolating
pos = interpolated_pos
# Record the stale position for change detection
if stale_pos < 0:
_track_skip_pending["stale_pos"] = smtc_pos
# Keep grace period active indefinitely while SMTC is stale
_track_skip_pending["grace_until"] = current_time + 300.0
logger.debug(
f"Grace period: SMTC stale ({smtc_pos}),"
f" using interpolated {interpolated_pos}"
)
else:
pos = 0.0
elif in_grace_period:
# Grace period after track skip
# SMTC position is stale (from old track) and won't update until seek/pause
# We interpolate from 0 and only trust SMTC when it changes or reports low value
# Normal position tracking
# Create track ID from title + artist + duration
track_id = f"{current_title}:{result.get('artist', '')}:{result.get('duration', 0)}"
# Calculate interpolated position from start of new track
if is_playing:
elapsed = current_time - _position_cache.get("base_time", current_time)
interpolated_pos = _position_cache.get("base_position", 0.0) + elapsed
else:
interpolated_pos = _position_cache.get("base_position", 0.0)
# Detect if SMTC position changed (new track, seek, or state change)
smtc_pos_changed = abs(smtc_pos - _position_cache.get("last_smtc_pos", -999)) > 0.5
track_changed = track_id != _position_cache.get("track_id", "")
# Get the stale position we've been tracking
stale_pos = _track_skip_pending.get("stale_pos", -999)
if smtc_pos_changed or track_changed:
# SMTC updated - store new baseline
_position_cache["track_id"] = track_id
_position_cache["last_smtc_pos"] = smtc_pos
_position_cache["base_position"] = smtc_pos
_position_cache["base_time"] = current_time
_position_cache["is_playing"] = is_playing
pos = smtc_pos
elif is_playing:
# Interpolate position based on elapsed time
elapsed = current_time - _position_cache.get("base_time", current_time)
pos = _position_cache.get("base_position", smtc_pos) + elapsed
else:
# Paused - use base position
pos = _position_cache.get("base_position", smtc_pos)
# Detect if SMTC position changed significantly from the stale value (user seeked)
smtc_changed = stale_pos >= 0 and abs(smtc_pos - stale_pos) > 3.0
# Update playing state
if _position_cache.get("is_playing") != is_playing:
_position_cache["base_position"] = (
pos if is_playing else _position_cache.get("base_position", smtc_pos)
)
_position_cache["base_time"] = current_time
_position_cache["is_playing"] = is_playing
# Trust SMTC if:
# 1. It reports a low position (indicating new track started)
# 2. It changed from the stale value (user seeked)
if smtc_pos < 10.0 or smtc_changed:
# SMTC is now trustworthy
_position_cache["base_position"] = smtc_pos
_position_cache["base_time"] = current_time
_position_cache["last_smtc_pos"] = smtc_pos
_position_cache["is_playing"] = is_playing
pos = smtc_pos
_track_skip_pending["grace_until"] = 0
_track_skip_pending["stale_pos"] = -999
logger.debug(f"Grace period: accepting SMTC pos {smtc_pos} (low={smtc_pos < 10}, changed={smtc_changed})")
else:
# SMTC is stale - keep interpolating
pos = interpolated_pos
# Record the stale position for change detection
if stale_pos < 0:
_track_skip_pending["stale_pos"] = smtc_pos
# Keep grace period active indefinitely while SMTC is stale
_track_skip_pending["grace_until"] = current_time + 300.0
logger.debug(f"Grace period: SMTC stale ({smtc_pos}), using interpolated {interpolated_pos}")
else:
# Normal position tracking
# Create track ID from title + artist + duration
track_id = f"{current_title}:{result.get('artist', '')}:{result.get('duration', 0)}"
# Detect if SMTC position changed (new track, seek, or state change)
smtc_pos_changed = abs(smtc_pos - _position_cache.get("last_smtc_pos", -999)) > 0.5
track_changed = track_id != _position_cache.get("track_id", "")
if smtc_pos_changed or track_changed:
# SMTC updated - store new baseline
_position_cache["track_id"] = track_id
_position_cache["last_smtc_pos"] = smtc_pos
_position_cache["base_position"] = smtc_pos
_position_cache["base_time"] = current_time
_position_cache["is_playing"] = is_playing
pos = smtc_pos
elif is_playing:
# Interpolate position based on elapsed time
elapsed = current_time - _position_cache.get("base_time", current_time)
pos = _position_cache.get("base_position", smtc_pos) + elapsed
else:
# Paused - use base position
pos = _position_cache.get("base_position", smtc_pos)
# Update playing state
if _position_cache.get("is_playing") != is_playing:
_position_cache["base_position"] = pos if is_playing else _position_cache.get("base_position", smtc_pos)
_position_cache["base_time"] = current_time
_position_cache["is_playing"] = is_playing
# Sanity check: position should be non-negative and <= duration
if pos >= 0:
if result["duration"] and pos <= result["duration"]:
result["position"] = pos
elif result["duration"] and pos > result["duration"]:
result["position"] = result["duration"]
elif not result["duration"]:
result["position"] = pos
# Sanity check: position should be non-negative and <= duration
if pos >= 0:
if result["duration"] and pos <= result["duration"]:
result["position"] = pos
elif result["duration"] and pos > result["duration"]:
result["position"] = result["duration"]
elif not result["duration"]:
result["position"] = pos
logger.debug(f"Timeline: duration={result['duration']}, position={result['position']}")
except Exception as e:
@@ -483,6 +503,11 @@ def _sync_seek(position: float) -> bool:
return False
def shutdown_executor() -> None:
"""Shut down the WinRT thread pool executor."""
_executor.shutdown(wait=False)
class WindowsMediaController(MediaController):
"""Media controller for Windows using WinRT and pycaw."""
@@ -602,10 +627,10 @@ class WindowsMediaController(MediaController):
result = await self._run_command("next")
if result:
# Set flag to force position to 0 until title changes
_track_skip_pending["active"] = True
_track_skip_pending["old_title"] = old_title
_track_skip_pending["skip_time"] = _time.time()
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
@@ -620,10 +645,10 @@ class WindowsMediaController(MediaController):
result = await self._run_command("previous")
if result:
# Set flag to force position to 0 until title changes
_track_skip_pending["active"] = True
_track_skip_pending["old_title"] = old_title
_track_skip_pending["skip_time"] = _time.time()
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
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 208 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#1db954;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1ed760;stop-opacity:1" />
</linearGradient>
</defs>
<circle cx="50" cy="50" r="45" fill="url(#grad)"/>
<path fill="white" d="M35 25 L35 75 L75 50 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 421 B

+122 -14
View File
@@ -2,9 +2,16 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Media Server</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Cdefs%3E%3ClinearGradient id='grad' x1='0%25' y1='0%25' x2='100%25' y2='100%25'%3E%3Cstop offset='0%25' style='stop-color:%231db954;stop-opacity:1' /%3E%3Cstop offset='100%25' style='stop-color:%231ed760;stop-opacity:1' /%3E%3C/linearGradient%3E%3C/defs%3E%3Ccircle cx='50' cy='50' r='45' fill='url(%23grad)'/%3E%3Cpath fill='white' d='M35 25 L35 75 L75 50 Z'/%3E%3C/svg%3E">
<meta name="description" content="Remote media player control and file browser">
<meta name="theme-color" content="#121212">
<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">
<link rel="manifest" href="/static/manifest.json">
<link rel="icon" type="image/svg+xml" href="/static/icons/icon.svg">
<link rel="apple-touch-icon" href="/static/icons/icon.svg">
<link rel="stylesheet" href="/static/css/styles.css">
</head>
<body class="loading-translations">
@@ -50,6 +57,9 @@
</div>
</div>
<!-- Dynamic Background -->
<canvas id="bg-shader-canvas" class="bg-shader-canvas"></canvas>
<!-- Auth Modal -->
<div id="auth-overlay" class="hidden">
<div class="auth-modal">
@@ -71,15 +81,21 @@
<span class="status-dot" id="status-dot" aria-live="polite"></span>
<span class="version-label" id="version-label"></span>
</div>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<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>
<div class="accent-picker">
<button class="accent-picker-btn" onclick="toggleAccentPicker()" title="Accent color">
<button class="header-btn" 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="theme-toggle" onclick="toggleTheme()" data-i18n-title="player.theme" title="Toggle theme" id="theme-toggle">
<button class="header-btn" 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">
<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>
@@ -87,14 +103,24 @@
<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" onchange="changeLocale()" title="Change language">
<option value="en">English</option>
<option value="ru">Русский</option>
<select id="locale-select" class="header-locale" onchange="changeLocale()" title="Change language">
<option value="en">EN</option>
<option value="ru">RU</option>
</select>
<button class="clear-token-btn" onclick="clearToken()" data-i18n-title="auth.logout.title" data-i18n="auth.logout" title="Clear saved token">Logout</button>
<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">
<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>
@@ -131,6 +157,7 @@
<div class="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">
<canvas id="spectrogram-canvas" class="spectrogram-canvas" width="300" height="64"></canvas>
</div>
<div class="player-details">
@@ -185,10 +212,15 @@
</div>
<div class="source-info">
<span data-i18n="player.source">Source:</span> <span id="source" data-i18n="player.unknown_source">Unknown</span>
<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>
</button>
<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>
</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>
</div>
</div>
</div>
</div>
@@ -258,6 +290,7 @@
<span id="pageTotal">/ 1</span>
</div>
<button id="nextPage" onclick="nextPage()" data-i18n="browser.next">Next</button>
<span class="pagination-showing" id="paginationShowing"></span>
</div>
</div>
@@ -273,6 +306,57 @@
<!-- Settings Section (Scripts, Callbacks, Links management) -->
<div class="settings-container" data-tab-content="settings" role="tabpanel" id="panel-settings">
<details class="settings-section" open id="audioDeviceSection" style="display: none;">
<summary data-i18n="settings.section.audio">Audio</summary>
<div class="settings-section-content">
<p class="settings-section-description" data-i18n="settings.audio.description">
Select which audio output device to capture for the visualizer.
</p>
<div class="audio-device-selector">
<label>
<span data-i18n="settings.audio.device">Loopback Device</span>
<select id="audioDeviceSelect" onchange="onAudioDeviceChanged()">
<option value="" data-i18n="settings.audio.auto">Auto-detect</option>
</select>
</label>
<div class="audio-device-status" id="audioDeviceStatus"></div>
</div>
</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" 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">
@@ -422,6 +506,14 @@
<span data-i18n="scripts.field.timeout">Timeout (seconds)</span>
<input type="number" id="scriptTimeout" value="30" min="1" max="300">
</label>
<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>
</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>
@@ -430,6 +522,22 @@
</form>
</dialog>
<!-- Script Parameters Input Dialog (shown before executing scripts with params) -->
<dialog id="scriptParamsDialog">
<div class="dialog-header">
<h3 id="scriptParamsDialogTitle">Execute Script</h3>
</div>
<form id="scriptParamsForm" 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="submit" class="btn-primary" data-i18n="scripts.params.execute">Execute</button>
</div>
</form>
</dialog>
<!-- Add/Edit Callback Dialog -->
<dialog id="callbackDialog">
<div class="dialog-header">
@@ -611,6 +719,6 @@
</div>
</footer>
<script src="/static/js/app.js"></script>
<script src="/static/dist/app.bundle.js"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+316
View File
@@ -0,0 +1,316 @@
// ============================================================
// Background: WebGL shader-based dynamic background
// ============================================================
import { frequencyData } from './player.js';
let bgCanvas = null;
let bgGL = null;
let bgProgram = null;
let bgUniforms = null; // Cached uniform locations
let bgAnimFrame = null;
let bgEnabled = localStorage.getItem('dynamicBackground') === 'true';
let bgStartTime = 0;
let bgSmoothedBands = new Float32Array(16);
let bgSmoothedBass = 0;
let bgAccentRGB = [0.114, 0.725, 0.329]; // Cached accent color (default green)
let bgBgColorRGB = [0.071, 0.071, 0.071]; // Cached page background (#121212)
const BG_BAND_COUNT = 16;
const BG_SMOOTHING = 0.12;
// ---- Shaders ----
const BG_VERT_SRC = `
attribute vec2 a_position;
void main() {
gl_Position = vec4(a_position, 0.0, 1.0);
}
`;
const BG_FRAG_SRC = `
precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;
uniform float u_bass;
uniform float u_bands[16];
uniform vec3 u_accent;
uniform vec3 u_bgColor;
// Smooth noise
float hash(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}
float noise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
f = f * f * (3.0 - 2.0 * f);
float a = hash(i);
float b = hash(i + vec2(1.0, 0.0));
float c = hash(i + vec2(0.0, 1.0));
float d = hash(i + vec2(1.0, 1.0));
return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
}
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution;
float aspect = u_resolution.x / u_resolution.y;
// Center coordinates for radial effects
vec2 center = (uv - 0.5) * vec2(aspect, 1.0);
float dist = length(center);
float angle = atan(center.y, center.x);
// Slow base animation
float t = u_time * 0.15;
// === Layer 1: Flowing wave field ===
float waves = 0.0;
for (int i = 0; i < 5; i++) {
float fi = float(i);
float freq = 1.5 + fi * 0.8;
float speed = t * (0.6 + fi * 0.15);
// Sample a band for this wave layer
int bandIdx = i * 3;
float bandVal = 0.0;
// Manual indexing (GLSL ES doesn't allow variable array index in some drivers)
for (int j = 0; j < 16; j++) {
if (j == bandIdx) bandVal = u_bands[j];
}
float amp = 0.015 + bandVal * 0.06;
waves += amp * sin(uv.x * freq * 6.2832 + speed + sin(uv.y * 3.0 + t) * 2.0);
waves += amp * 0.5 * sin(uv.y * freq * 4.0 - speed * 0.7 + cos(uv.x * 2.5 + t) * 1.5);
}
// === Layer 2: Radial pulse (bass-driven) ===
float pulse = smoothstep(0.6 + u_bass * 0.3, 0.0, dist) * (0.08 + u_bass * 0.15);
// === Layer 3: Frequency ring arcs ===
float rings = 0.0;
for (int i = 0; i < 8; i++) {
float fi = float(i);
float bandVal = 0.0;
for (int j = 0; j < 16; j++) {
if (j == i * 2) bandVal = u_bands[j];
}
float radius = 0.15 + fi * 0.1;
float ringWidth = 0.008 + bandVal * 0.025;
float ring = smoothstep(ringWidth, 0.0, abs(dist - radius - bandVal * 0.05));
// Fade ring by angle sector for variety
float angleFade = 0.5 + 0.5 * sin(angle * (2.0 + fi) + t * (1.0 + fi * 0.3));
rings += ring * angleFade * (0.3 + bandVal * 0.7);
}
// === Layer 4: Subtle noise texture ===
float n = noise(uv * 4.0 + t * 0.5) * 0.03;
// Combine layers
float intensity = waves + pulse + rings * 0.5 + n;
// Color: accent color with varying brightness
vec3 col = u_accent * intensity;
// Subtle secondary hue shift for depth
vec3 shifted = u_accent.gbr; // Rotated accent
col += shifted * rings * 0.15;
// Vignette
float vignette = 1.0 - smoothstep(0.3, 1.2, dist);
col *= vignette;
// Blend over page background
col = clamp(col, 0.0, 1.0);
float colBright = (col.r + col.g + col.b) / 3.0;
float bgLum = dot(u_bgColor, vec3(0.299, 0.587, 0.114));
// Dark bg: add accent light. Light bg: tint white toward accent via multiply.
vec3 darkResult = u_bgColor + col;
vec3 lightResult = u_bgColor * mix(vec3(1.0), u_accent, colBright * 2.0);
vec3 finalColor = clamp(mix(darkResult, lightResult, bgLum), 0.0, 1.0);
gl_FragColor = vec4(finalColor, 1.0);
}
`;
// ---- WebGL setup ----
function initBackgroundGL() {
bgCanvas = document.getElementById('bg-shader-canvas');
if (!bgCanvas) return false;
bgGL = bgCanvas.getContext('webgl', { alpha: false, antialias: false, depth: false, stencil: false });
if (!bgGL) {
console.warn('WebGL not available for background shader');
return false;
}
const gl = bgGL;
// Compile shaders
const vs = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(vs, BG_VERT_SRC);
gl.compileShader(vs);
if (!gl.getShaderParameter(vs, gl.COMPILE_STATUS)) {
console.error('BG vertex shader:', gl.getShaderInfoLog(vs));
return false;
}
const fs = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fs, BG_FRAG_SRC);
gl.compileShader(fs);
if (!gl.getShaderParameter(fs, gl.COMPILE_STATUS)) {
console.error('BG fragment shader:', gl.getShaderInfoLog(fs));
return false;
}
bgProgram = gl.createProgram();
gl.attachShader(bgProgram, vs);
gl.attachShader(bgProgram, fs);
gl.linkProgram(bgProgram);
if (!gl.getProgramParameter(bgProgram, gl.LINK_STATUS)) {
console.error('BG program link:', gl.getProgramInfoLog(bgProgram));
return false;
}
// Fullscreen quad
const buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
-1, -1, 1, -1, -1, 1,
-1, 1, 1, -1, 1, 1
]), gl.STATIC_DRAW);
const aPos = gl.getAttribLocation(bgProgram, 'a_position');
gl.enableVertexAttribArray(aPos);
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
gl.useProgram(bgProgram);
// Cache uniform locations once (avoids per-frame lookups)
bgUniforms = {
resolution: gl.getUniformLocation(bgProgram, 'u_resolution'),
time: gl.getUniformLocation(bgProgram, 'u_time'),
bass: gl.getUniformLocation(bgProgram, 'u_bass'),
bands: gl.getUniformLocation(bgProgram, 'u_bands'),
accent: gl.getUniformLocation(bgProgram, 'u_accent'),
bgColor: gl.getUniformLocation(bgProgram, 'u_bgColor'),
};
bgStartTime = performance.now() / 1000;
updateBackgroundColors();
resizeBackgroundCanvas();
window.addEventListener('resize', resizeBackgroundCanvas);
return true;
}
function resizeBackgroundCanvas() {
if (!bgCanvas) return;
const dpr = Math.min(window.devicePixelRatio || 1, 1.5); // Cap DPR for performance
const w = Math.floor(window.innerWidth * dpr);
const h = Math.floor(window.innerHeight * dpr);
if (bgCanvas.width !== w || bgCanvas.height !== h) {
bgCanvas.width = w;
bgCanvas.height = h;
}
}
// ---- Cached color/theme updates (called on accent or theme change, not per-frame) ----
export function updateBackgroundColors() {
const style = getComputedStyle(document.documentElement);
const accentHex = style.getPropertyValue('--accent').trim();
if (accentHex && accentHex.length >= 7) {
bgAccentRGB[0] = parseInt(accentHex.slice(1, 3), 16) / 255;
bgAccentRGB[1] = parseInt(accentHex.slice(3, 5), 16) / 255;
bgAccentRGB[2] = parseInt(accentHex.slice(5, 7), 16) / 255;
}
const bgHex = style.getPropertyValue('--bg-primary').trim();
if (bgHex && bgHex.length >= 7) {
bgBgColorRGB[0] = parseInt(bgHex.slice(1, 3), 16) / 255;
bgBgColorRGB[1] = parseInt(bgHex.slice(3, 5), 16) / 255;
bgBgColorRGB[2] = parseInt(bgHex.slice(5, 7), 16) / 255;
}
}
// ---- Render loop ----
function renderBackgroundFrame() {
bgAnimFrame = requestAnimationFrame(renderBackgroundFrame);
const gl = bgGL;
if (!gl || !bgUniforms) return;
resizeBackgroundCanvas();
gl.viewport(0, 0, bgCanvas.width, bgCanvas.height);
const time = performance.now() / 1000 - bgStartTime;
// Smooth audio data from the imported frequencyData (shared with visualizer)
if (frequencyData && frequencyData.frequencies) {
const bins = frequencyData.frequencies;
const step = Math.max(1, Math.floor(bins.length / BG_BAND_COUNT));
for (let i = 0; i < BG_BAND_COUNT; i++) {
const idx = Math.min(i * step, bins.length - 1);
const target = bins[idx] || 0;
bgSmoothedBands[i] += (target - bgSmoothedBands[i]) * (1 - BG_SMOOTHING);
}
const targetBass = frequencyData.bass || 0;
bgSmoothedBass += (targetBass - bgSmoothedBass) * (1 - BG_SMOOTHING);
} else {
// Gentle decay when no audio
for (let i = 0; i < BG_BAND_COUNT; i++) {
bgSmoothedBands[i] *= 0.95;
}
bgSmoothedBass *= 0.95;
}
// 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);
gl.uniform3f(bgUniforms.accent, bgAccentRGB[0], bgAccentRGB[1], bgAccentRGB[2]);
gl.uniform3f(bgUniforms.bgColor, bgBgColorRGB[0], bgBgColorRGB[1], bgBgColorRGB[2]);
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
function startBackground() {
if (bgAnimFrame) return;
if (!bgGL && !initBackgroundGL()) return;
bgCanvas.classList.add('visible');
document.body.classList.add('dynamic-bg-active');
renderBackgroundFrame();
}
function stopBackground() {
if (bgAnimFrame) {
cancelAnimationFrame(bgAnimFrame);
bgAnimFrame = null;
}
if (bgCanvas) {
bgCanvas.classList.remove('visible');
}
document.body.classList.remove('dynamic-bg-active');
}
// ---- Public API ----
export function toggleDynamicBackground() {
bgEnabled = !bgEnabled;
localStorage.setItem('dynamicBackground', bgEnabled);
applyDynamicBackground();
}
export function applyDynamicBackground() {
const btn = document.getElementById('bgToggle');
if (bgEnabled) {
startBackground();
if (btn) btn.classList.add('active');
} else {
stopBackground();
if (btn) btn.classList.remove('active');
}
}
File diff suppressed because it is too large Load Diff
+232
View File
@@ -0,0 +1,232 @@
// ============================================================
// Callbacks: CRUD management
// ============================================================
import { t, showToast, escapeHtml, closeDialog, showConfirm, getAuthHeaders, hasCredentials } from './core.js';
import { IconSelect } from './icon-select.js';
import { callbackEventIcons } from './icons.js';
export let callbackFormDirty = false;
export function setCallbackFormDirty(value) { callbackFormDirty = value; }
let _callbackEventIconSelect = null;
function _ensureCallbackEventIconSelect() {
if (_callbackEventIconSelect) return;
const select = document.getElementById('callbackName');
if (!select) return;
const items = Object.entries(callbackEventIcons).map(([value, icon]) => ({
value,
icon,
label: value,
}));
_callbackEventIconSelect = new IconSelect({
target: select,
items,
columns: 3,
placeholder: t('callbacks.placeholder.event'),
onChange: () => { callbackFormDirty = true; },
});
}
let _loadCallbacksPromise = null;
export async function loadCallbacksTable() {
if (_loadCallbacksPromise) return _loadCallbacksPromise;
_loadCallbacksPromise = _loadCallbacksTableImpl();
_loadCallbacksPromise.finally(() => { _loadCallbacksPromise = null; });
return _loadCallbacksPromise;
}
async function _loadCallbacksTableImpl() {
const tbody = document.getElementById('callbacksTableBody');
try {
const response = await fetch('/api/callbacks/list', {
headers: getAuthHeaders()
});
if (!response.ok) {
throw new Error('Failed to fetch callbacks');
}
const callbacksList = await response.json();
if (callbacksList.length === 0) {
tbody.innerHTML = '<tr><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>' + t('callbacks.empty') + '</p></div></td></tr>';
return;
}
tbody.innerHTML = callbacksList.map(callback => `
<tr>
<td><code>${escapeHtml(callback.name)}</code></td>
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"
title="${escapeHtml(callback.command)}">${escapeHtml(callback.command)}</td>
<td>${callback.timeout}s</td>
<td>
<div class="action-buttons">
<button class="action-btn execute" data-action="execute" data-callback-name="${escapeHtml(callback.name)}" title="Execute callback">
<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
</button>
<button class="action-btn" data-action="edit" data-callback-name="${escapeHtml(callback.name)}" title="Edit callback">
<svg viewBox="0 0 24 24"><path 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="action-btn delete" data-action="delete" data-callback-name="${escapeHtml(callback.name)}" title="Delete callback">
<svg viewBox="0 0 24 24"><path 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>
</div>
</td>
</tr>
`).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>';
}
}
export function showAddCallbackDialog() {
const dialog = document.getElementById('callbackDialog');
const form = document.getElementById('callbackForm');
const title = document.getElementById('callbackDialogTitle');
form.reset();
document.getElementById('callbackIsEdit').value = 'false';
document.getElementById('callbackName').disabled = false;
title.textContent = t('callbacks.dialog.add');
_ensureCallbackEventIconSelect();
if (_callbackEventIconSelect) _callbackEventIconSelect.setValue('', false);
callbackFormDirty = false;
document.body.classList.add('dialog-open');
dialog.showModal();
}
export async function showEditCallbackDialog(callbackName) {
const dialog = document.getElementById('callbackDialog');
const title = document.getElementById('callbackDialogTitle');
try {
const response = await fetch('/api/callbacks/list', {
headers: getAuthHeaders()
});
if (!response.ok) {
throw new Error('Failed to fetch callback details');
}
const callbacksList = await response.json();
const callback = callbacksList.find(c => c.name === callbackName);
if (!callback) {
showToast('Callback not found', 'error');
return;
}
document.getElementById('callbackIsEdit').value = 'true';
document.getElementById('callbackName').value = callbackName;
document.getElementById('callbackName').disabled = true;
_ensureCallbackEventIconSelect();
if (_callbackEventIconSelect) _callbackEventIconSelect.setValue(callbackName, false);
document.getElementById('callbackCommand').value = callback.command;
document.getElementById('callbackTimeout').value = callback.timeout;
document.getElementById('callbackWorkingDir').value = callback.working_dir || '';
title.textContent = t('callbacks.dialog.edit');
callbackFormDirty = false;
document.body.classList.add('dialog-open');
dialog.showModal();
} catch (error) {
console.error('Error loading callback for edit:', error);
showToast('Failed to load callback details', 'error');
}
}
export async function closeCallbackDialog() {
if (callbackFormDirty) {
if (!await showConfirm(t('callbacks.confirm.unsaved'))) {
return;
}
}
const dialog = document.getElementById('callbackDialog');
callbackFormDirty = false;
closeDialog(dialog);
document.body.classList.remove('dialog-open');
}
export async function saveCallback(event) {
event.preventDefault();
const submitBtn = event.target.querySelector('button[type="submit"]');
if (submitBtn) submitBtn.disabled = true;
const isEdit = document.getElementById('callbackIsEdit').value === 'true';
const callbackName = document.getElementById('callbackName').value;
const data = {
command: document.getElementById('callbackCommand').value,
timeout: parseInt(document.getElementById('callbackTimeout').value) || 30,
working_dir: document.getElementById('callbackWorkingDir').value || null,
shell: true
};
const endpoint = isEdit ?
`/api/callbacks/update/${callbackName}` :
`/api/callbacks/create/${callbackName}`;
const method = isEdit ? 'PUT' : 'POST';
try {
const response = await fetch(endpoint, {
method,
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
body: JSON.stringify(data)
});
const result = await response.json();
if (response.ok && result.success) {
showToast(`Callback ${isEdit ? 'updated' : 'created'} successfully`, 'success');
callbackFormDirty = false;
closeCallbackDialog();
loadCallbacksTable();
} else {
showToast(result.detail || `Failed to ${isEdit ? 'update' : 'create'} callback`, 'error');
}
} catch (error) {
console.error('Error saving callback:', error);
showToast(`Error ${isEdit ? 'updating' : 'creating'} callback`, 'error');
} finally {
if (submitBtn) submitBtn.disabled = false;
}
}
export async function deleteCallbackConfirm(callbackName) {
if (!await showConfirm(t('callbacks.confirm.delete').replace('{name}', callbackName))) {
return;
}
try {
const response = await fetch(`/api/callbacks/delete/${callbackName}`, {
method: 'DELETE',
headers: getAuthHeaders()
});
const result = await response.json();
if (response.ok && result.success) {
showToast('Callback deleted successfully', 'success');
loadCallbacksTable();
} else {
showToast(result.detail || 'Failed to delete callback', 'error');
}
} catch (error) {
console.error('Error deleting callback:', error);
showToast('Error deleting callback', 'error');
}
}
+574
View File
@@ -0,0 +1,574 @@
// ============================================================
// Core: Shared state, constants, utilities, i18n, API commands
// ============================================================
// SVG path constants (avoid rebuilding innerHTML on every state update)
export const SVG_PLAY = '<path d="M8 5v14l11-7z"/>';
export const SVG_PAUSE = '<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>';
export const SVG_STOP = '<path d="M6 6h12v12H6z"/>';
export const SVG_IDLE = '<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"/>';
export const SVG_MUTED = '<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>';
export const SVG_UNMUTED = '<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"/>';
// Empty state illustration SVGs
export const EMPTY_SVG_FOLDER = '<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>';
export const EMPTY_SVG_FILE = '<svg viewBox="0 0 64 64"><path d="M16 4h22l14 14v38a4 4 0 01-4 4H16a4 4 0 01-4-4V8a4 4 0 014-4z"/><path d="M38 4v14h14"/><path d="M22 32h20M22 40h14" opacity="0.5"/></svg>';
export function emptyStateHtml(svgStr, text) {
return `<div class="empty-state-illustration">${svgStr}<p>${text}</p></div>`;
}
// Media source registry: substring key → { name, icon }
export const MEDIA_SOURCES = {
'spotify': {
name: 'Spotify',
icon: '<svg viewBox="0 0 24 24"><path fill="#1DB954" d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z"/></svg>'
},
'yandex music': {
name: 'Yandex Music',
icon: '<svg viewBox="0 0 24 24"><path fill="#FFCC00" d="M12 0C5.376 0 0 5.376 0 12s5.376 12 12 12 12-5.376 12-12S18.624 0 12 0zm0 2.4a9.6 9.6 0 110 19.2 9.6 9.6 0 010-19.2z"/><path fill="#FFCC00" d="M13.2 6h-2.4v7.2L7.2 6H4.8l5.4 12h1.2l.6-1.35V6z"/></svg>'
},
'яндекс музыка': {
name: 'Яндекс Музыка',
icon: '<svg viewBox="0 0 24 24"><path fill="#FFCC00" d="M12 0C5.376 0 0 5.376 0 12s5.376 12 12 12 12-5.376 12-12S18.624 0 12 0zm0 2.4a9.6 9.6 0 110 19.2 9.6 9.6 0 010-19.2z"/><path fill="#FFCC00" d="M13.2 6h-2.4v7.2L7.2 6H4.8l5.4 12h1.2l.6-1.35V6z"/></svg>'
},
'chrome': {
name: 'Google Chrome',
icon: '<svg viewBox="0 0 24 24"><circle fill="#4587F3" cx="12" cy="12" r="11"/><path fill="#DB4437" d="M12 1C7.2 1 3.1 3.8 1.3 7.9L7.7 12l1.8-3.1c.7-1.1 1.9-1.9 3.3-1.9h9.7C21 3.5 16.9 1 12 1z"/><path fill="#0F9D58" d="M7.7 12L1.3 7.9C.5 9.2 0 10.6 0 12c0 4.5 2.8 8.4 6.8 10l3.8-6.6L7.7 12z"/><path fill="#FFCD40" d="M6.8 22c2.7 1.5 6.4 1.7 9.4.2 2.8-1.4 4.9-3.9 5.8-6.8l-6.5-3.4-1.8 3.1c-.7 1.1-1.9 1.9-3.3 1.9-.9 0-1.7-.3-2.4-.7L6.8 22z"/><circle fill="#F1F1F1" cx="12" cy="12" r="4.8"/><circle fill="#4587F3" cx="12" cy="12" r="3.8"/></svg>'
},
'msedge': {
name: 'Microsoft Edge',
icon: '<svg viewBox="0 0 24 24"><path fill="#0078D4" d="M21.86 17.86q.14 0 .25-.12.1-.13.1-.25 0-.06 0-.13-.12-.76-.39-1.49-.26-.72-.65-1.39-.4-.66-.92-1.25-.53-.58-1.15-1.06-.61-.48-1.3-.85-.69-.37-1.44-.6-.75-.22-1.53-.3-.8-.07-1.6 0h-.04q-.51.03-1.03.14-.5.12-1 .31-.49.2-.95.46-.46.27-.89.6-.42.32-.8.7-.37.4-.69.83-.31.44-.57.92-.25.49-.44 1 .09-.14.21-.28.12-.14.26-.27.14-.12.3-.23.16-.1.33-.18.18-.08.37-.14.18-.06.38-.08.2-.02.4-.01.21.01.41.06.28.07.53.2.25.12.47.3.21.18.39.4.18.21.32.45.14.25.23.52.1.26.14.54.04.28.02.56-.02.36-.12.72-.1.35-.27.68-.17.33-.4.62-.24.3-.52.56-.28.25-.6.46-.32.2-.67.35.44.1.9.14.44.03.89-.02.45-.05.88-.17.44-.12.85-.3.41-.2.79-.44.37-.25.71-.55.34-.3.63-.65.3-.35.54-.73.24-.39.42-.8.18-.42.3-.86.12-.43.18-.88.06-.45.06-.9 0-.48-.07-.95-.07-.47-.22-.93z"/><path fill="#50E6FF" d="M11.89.03Q10.03.17 8.3.88 6.57 1.59 5.1 2.77 3.65 3.94 2.55 5.5 1.44 7.06.79 8.88.14 10.7 0 12.65q.01.22.02.45 0 .22.03.44.04.42.12.83.08.42.2.83.12.4.28.79.16.39.36.76.2.37.43.72.24.34.51.66.27.32.57.6.3.29.63.54.33.25.68.46.35.21.72.38.38.17.77.28.39.12.79.18.41.06.82.05.41 0 .82-.07.41-.08.79-.22.39-.14.74-.34.36-.2.68-.44.33-.25.6-.54.28-.3.5-.63.23-.33.4-.7.17-.36.27-.75-1.1.9-2.44 1.36-1.33.46-2.77.46-1.26 0-2.44-.39-1.18-.39-2.17-1.08-1-1.08-1.6-2.02-.6-.94-.87-2-.27-1.07-.25-2.2.02-.55.12-1.08.1-.54.29-1.05.18-.52.44-1 .27-.49.6-.94.34-.44.74-.83.4-.38.85-.71.45-.32.94-.57.49-.25 1.02-.42.52-.16 1.07-.24.55-.07 1.1-.05.81.04 1.57.25.77.2 1.46.56.7.36 1.29.85.6.5 1.07 1.1.48.6.82 1.29.34.69.54 1.44.2.76.24 1.55.04.79-.08 1.57-.11.78-.37 1.52-.26.74-.66 1.4-.39.67-.91 1.24-.52.57-1.14 1.02-.62.44-1.32.76-.7.32-1.45.49-.75.16-1.52.18z"/></svg>'
},
'firefox': {
name: 'Firefox',
icon: '<svg viewBox="0 0 24 24"><path fill="#FF7139" d="M12 0C5.37 0 0 5.37 0 12s5.37 12 12 12 12-5.37 12-12S18.63 0 12 0zm6.73 7.27c-.47-.77-1.22-1.6-1.7-1.87.54.97.86 2.07.93 3.15 0 0-.02.03-.02.05-.38-1.34-1.14-2.15-1.78-3.05-.03-.05-.06-.1-.1-.15-.03-.05-.05-.1-.06-.15 0-.02-.01-.04-.02-.05l-.01.02c-.02.03-.03.05-.04.08 0 0 0 .01-.01.02l.01-.02c-.64 1.07-1.72 2.2-2.1 3.56-.46.01-.9.09-1.32.23l-.06.03c-.03-.2-.04-.4-.04-.6 0-.67.15-1.3.4-1.87-1.08.4-1.93 1.12-2.53 1.72-.33-.36-.36-1.56-.34-1.8-.01 0-.03.02-.04.02-.27.2-.52.42-.75.66-.28.3-.53.62-.76.96-.12.2-.24.4-.34.6-.15.32-.27.66-.36 1-.02.07-.03.14-.05.21v.03c-.06.3-.1.6-.12.9v.1c0 .07 0 .14-.01.21C7.3 13.8 7.52 16.37 9 18.26l.04.05c-1.55-1-2.57-2.64-2.87-4.42-.04.2-.06.4-.07.6-.01.2-.02.4-.01.6.02.6.13 1.2.3 1.77.2.57.46 1.12.8 1.62.17.25.36.48.56.7.2.22.42.43.66.62 1.83 1.47 4.17 1.87 6.34 1.21.26-.08.5-.17.74-.28 1.1-.5 2.06-1.27 2.78-2.23.03-.03.05-.07.07-.1.08-.1.15-.2.22-.32.5-.77.84-1.62 1.02-2.5.02-.1.04-.2.05-.3.1-.57.14-1.15.12-1.73 0-.1-.01-.19-.02-.29.06-1.2-.15-2.42-.63-3.53-.1-.23-.2-.45-.32-.67z"/></svg>'
},
'opera': {
name: 'Opera',
icon: '<svg viewBox="0 0 24 24"><path fill="#FF1B2D" d="M12 0C5.37 0 0 5.37 0 12s5.37 12 12 12c2.75 0 5.28-.93 7.3-2.49-1.24.77-2.68 1.22-4.22 1.22-2.2 0-4.17-1.1-5.55-2.83C8.1 18.1 7.2 15.22 7.2 12s.9-6.1 2.33-7.9C10.91 2.37 12.88 1.27 15.08 1.27c1.54 0 2.98.45 4.22 1.22C17.28.93 14.75 0 12 0z"/><path fill="#FF1B2D" d="M15.08 1.27c-2.2 0-4.17 1.1-5.55 2.83C8.1 5.9 7.2 8.78 7.2 12s.9 6.1 2.33 7.9c1.38 1.73 3.35 2.83 5.55 2.83 2.2 0 4.17-1.1 5.55-2.83C22.06 18.1 22.96 15.22 22.96 12s-.9-6.1-2.33-7.9c-1.38-1.73-3.35-2.83-5.55-2.83z" opacity=".75"/></svg>'
},
'brave': {
name: 'Brave',
icon: '<svg viewBox="0 0 24 24"><path fill="#FB542B" d="M12 0L3.6 4.8v9.6L12 24l8.4-9.6V4.8L12 0zm5.7 14.1l-1.2 1.8c-.3.3-.6.6-.9.9l-2.1 1.5-1.5.9-1.5-.9-2.1-1.5c-.3-.3-.6-.6-.9-.9l-1.2-1.8c-.3-.6-.3-1.2 0-1.5l.6-1.5.6-1.2.6-1.2.3-.6c.15-.3.45-.3.6 0l.6.9c.15.3.45.3.6 0l.6-.9.6-.9c.15-.3.45-.3.6 0l.6.9.6.9c.15.3.45.3.6 0l.6-.9c.15-.3.45-.3.6 0l.3.6.6 1.2.6 1.2.6 1.5c.3.3.3.9 0 1.5z"/></svg>'
},
'yandex': {
name: 'Yandex Browser',
icon: '<svg viewBox="0 0 24 24"><path fill="#FF0000" d="M12 0C5.37 0 0 5.37 0 12s5.37 12 12 12 12-5.37 12-12S18.63 0 12 0z"/><path fill="#FFF" d="M13.5 5h-2.1l-3.9 8.1V5H5.4v14h2.1l4.05-8.55V19h2.1V5z"/></svg>'
},
'vlc': {
name: 'VLC',
icon: '<svg viewBox="0 0 24 24"><path fill="#FF8800" d="M12 1.5L7.5 16h9L12 1.5z"/><path fill="#FF5722" d="M6 18.5c-1.5 0-2.5.5-2.5 1.5s2.5 2.5 8.5 2.5 8.5-1.5 8.5-2.5-1-1.5-2.5-1.5H6z"/><path fill="#FF8800" d="M6 18.5h12l-1.5-2.5h-9L6 18.5z"/></svg>'
},
'aimp': {
name: 'AIMP',
icon: '<svg viewBox="0 0 24 24"><path fill="#F7A600" d="M12 0C5.37 0 0 5.37 0 12s5.37 12 12 12 12-5.37 12-12S18.63 0 12 0z"/><path fill="#FFF" d="M12 4l-7 14h3l1.5-3h5l1.5 3h3L12 4zm0 5l1.75 3.5h-3.5L12 9z"/></svg>'
},
'foobar': {
name: 'foobar2000',
icon: '<svg viewBox="0 0 24 24"><rect fill="#1F1A17" width="24" height="24" rx="4"/><path fill="#D89B2B" d="M6 6h3v12H6V6zm4.5 0H13v12h-2.5V6zm4 0H17v12h-2.5V6z"/></svg>'
},
'music.ui': {
name: 'Groove Music',
icon: '<svg viewBox="0 0 24 24"><circle fill="#7B83EB" cx="12" cy="12" r="11"/><path fill="#FFF" d="M15 7v7a3 3 0 11-2-2.83V7h2z"/></svg>'
},
'itunes': {
name: 'iTunes',
icon: '<svg viewBox="0 0 24 24"><path fill="#EA4CC0" d="M12 0C5.37 0 0 5.37 0 12s5.37 12 12 12 12-5.37 12-12S18.63 0 12 0z"/><path fill="#FFF" d="M16.5 6.5l-7 1.75v7.25a2.5 2.5 0 11-1.5-2.29V9.5l7-1.75v4.75a2.5 2.5 0 11-1.5-2.29V6.5z" opacity=".9"/></svg>'
},
'apple music': {
name: 'Apple Music',
icon: '<svg viewBox="0 0 24 24"><path fill="#FC3C44" d="M12 0C5.37 0 0 5.37 0 12s5.37 12 12 12 12-5.37 12-12S18.63 0 12 0z"/><path fill="#FFF" d="M16.5 6.5l-7 1.75v7.25a2.5 2.5 0 11-1.5-2.29V9.5l7-1.75v4.75a2.5 2.5 0 11-1.5-2.29V6.5z" opacity=".9"/></svg>'
},
'deezer': {
name: 'Deezer',
icon: '<svg viewBox="0 0 24 24"><rect fill="#000" width="24" height="24" rx="4"/><g fill="#A238FF"><rect x="2" y="16" width="3" height="2" rx=".5"/><rect x="6.5" y="14" width="3" height="4" rx=".5"/><rect x="11" y="10" width="3" height="8" rx=".5"/><rect x="15.5" y="12" width="3" height="6" rx=".5"/><rect x="19" y="8" width="3" height="10" rx=".5"/></g></svg>'
},
'tidal': {
name: 'TIDAL',
icon: '<svg viewBox="0 0 24 24"><path fill="#000" d="M12 4.8L8 8.8l4 4-4 4-4-4 4-4-4-4 4-4 4 4zm4 0l4 4-4 4-4-4 4-4z"/></svg>'
},
};
export function resolveMediaSource(raw) {
if (!raw) return null;
const lower = raw.toLowerCase();
for (const [key, info] of Object.entries(MEDIA_SOURCES)) {
if (lower.includes(key)) return info;
}
return { name: raw.replace(/\.exe$/i, ''), icon: null };
}
// Cached DOM references (populated once after DOMContentLoaded)
export const dom = {};
export function cacheDom() {
dom.trackTitle = document.getElementById('track-title');
dom.artist = document.getElementById('artist');
dom.album = document.getElementById('album');
dom.miniTrackTitle = document.getElementById('mini-track-title');
dom.miniArtist = document.getElementById('mini-artist');
dom.albumArt = document.getElementById('album-art');
dom.albumArtGlow = document.getElementById('album-art-glow');
dom.miniAlbumArt = document.getElementById('mini-album-art');
dom.volumeSlider = document.getElementById('volume-slider');
dom.volumeDisplay = document.getElementById('volume-display');
dom.miniVolumeSlider = document.getElementById('mini-volume-slider');
dom.miniVolumeDisplay = document.getElementById('mini-volume-display');
dom.progressFill = document.getElementById('progress-fill');
dom.currentTime = document.getElementById('current-time');
dom.totalTime = document.getElementById('total-time');
dom.progressBar = document.getElementById('progress-bar');
dom.miniProgressFill = document.getElementById('mini-progress-fill');
dom.miniCurrentTime = document.getElementById('mini-current-time');
dom.miniTotalTime = document.getElementById('mini-total-time');
dom.playbackState = document.getElementById('playback-state');
dom.stateIcon = document.getElementById('state-icon');
dom.playPauseIcon = document.getElementById('play-pause-icon');
dom.miniPlayPauseIcon = document.getElementById('mini-play-pause-icon');
dom.muteIcon = document.getElementById('mute-icon');
dom.miniMuteIcon = document.getElementById('mini-mute-icon');
dom.statusDot = document.getElementById('status-dot');
dom.source = document.getElementById('source');
dom.sourceIcon = document.getElementById('sourceIcon');
dom.btnPlayPause = document.getElementById('btn-play-pause');
dom.btnNext = document.getElementById('btn-next');
dom.btnPrevious = document.getElementById('btn-previous');
dom.miniBtnPlayPause = document.getElementById('mini-btn-play-pause');
dom.miniPlayer = document.getElementById('mini-player');
}
// Timing constants
export const VOLUME_THROTTLE_MS = 16;
export const POSITION_INTERPOLATION_MS = 100;
export const SEARCH_DEBOUNCE_MS = 200;
export const TOAST_DURATION_MS = 3000;
export const WS_BACKOFF_BASE_MS = 3000;
export const WS_BACKOFF_MAX_MS = 30000;
export const WS_MAX_RECONNECT_ATTEMPTS = 20;
export const WS_PING_INTERVAL_MS = 30000;
export const VOLUME_RELEASE_DELAY_MS = 500;
// Shared state (accessed across multiple modules)
export let ws = null;
export function setWs(value) { ws = value; }
export let currentState = 'idle';
export function setCurrentState(value) { currentState = value; }
export let currentDuration = 0;
export function setCurrentDuration(value) { currentDuration = value; }
export let currentPosition = 0;
export function setCurrentPosition(value) { currentPosition = value; }
export let isUserAdjustingVolume = false;
export function setIsUserAdjustingVolume(value) { isUserAdjustingVolume = value; }
export let volumeUpdateTimer = null;
export function setVolumeUpdateTimer(value) { volumeUpdateTimer = value; }
export let scripts = [];
export function setScripts(value) { scripts = value; }
export let lastStatus = null;
export function setLastStatus(value) { lastStatus = value; }
export let currentPlayState = 'idle';
export function setCurrentPlayState(value) { currentPlayState = value; }
// ============================================================
// Internationalization (i18n)
// ============================================================
let currentLocale = 'en';
let translations = {};
const supportedLocales = {
'en': 'English',
'ru': 'Русский'
};
// Minimal inline fallback for critical UI elements
const fallbackTranslations = {
'app.title': 'Media Server',
'auth.connect': 'Connect',
'auth.placeholder': 'Enter API Token',
'player.status.connected': 'Connected',
'player.status.disconnected': 'Disconnected'
};
export function t(key, params = {}) {
let text = translations[key] || fallbackTranslations[key] || key;
Object.keys(params).forEach(param => {
text = text.replace(new RegExp(`\\{${param}\\}`, 'g'), params[param]);
});
return text;
}
async function loadTranslations(locale) {
try {
const response = await fetch(`/static/locales/${locale}.json`);
if (!response.ok) {
throw new Error(`Failed to load ${locale}.json`);
}
return await response.json();
} catch (error) {
console.error(`Error loading translations for ${locale}:`, error);
if (locale !== 'en') {
return await loadTranslations('en');
}
return {};
}
}
function detectBrowserLocale() {
const browserLang = navigator.language || navigator.languages?.[0] || 'en';
const langCode = browserLang.split('-')[0];
return supportedLocales[langCode] ? langCode : 'en';
}
export async function initLocale() {
const savedLocale = localStorage.getItem('locale') || detectBrowserLocale();
await setLocale(savedLocale);
}
async function setLocale(locale) {
if (!supportedLocales[locale]) {
locale = 'en';
}
translations = await loadTranslations(locale);
currentLocale = locale;
document.documentElement.setAttribute('data-locale', locale);
document.documentElement.setAttribute('lang', locale);
localStorage.setItem('locale', locale);
updateAllText();
updateLocaleSelect();
document.body.classList.remove('loading-translations');
document.body.classList.add('translations-loaded');
}
export function changeLocale() {
const select = document.getElementById('locale-select');
const newLocale = select.value;
if (newLocale && newLocale !== currentLocale) {
localStorage.setItem('locale', newLocale);
setLocale(newLocale);
}
}
function updateLocaleSelect() {
const select = document.getElementById('locale-select');
if (select) {
select.value = currentLocale;
}
}
// Note: updateAllText calls functions from other modules via late-bound references.
// These are set from app.js after all modules are loaded.
let _updatePlaybackState = null;
let _updateConnectionStatus = null;
let _loadScriptsTable = null;
let _loadCallbacksTable = null;
let _loadLinksTable = null;
let _displayQuickAccess = null;
let _renderAccentSwatches = null;
export function registerUpdateCallbacks(callbacks) {
_updatePlaybackState = callbacks.updatePlaybackState;
_updateConnectionStatus = callbacks.updateConnectionStatus;
_loadScriptsTable = callbacks.loadScriptsTable;
_loadCallbacksTable = callbacks.loadCallbacksTable;
_loadLinksTable = callbacks.loadLinksTable;
_displayQuickAccess = callbacks.displayQuickAccess;
_renderAccentSwatches = callbacks.renderAccentSwatches;
}
function updateAllText() {
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
el.textContent = t(key);
});
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const key = el.getAttribute('data-i18n-placeholder');
el.placeholder = t(key);
});
document.querySelectorAll('[data-i18n-title]').forEach(el => {
const key = el.getAttribute('data-i18n-title');
el.title = t(key);
});
// Re-apply dynamic content with new translations
if (_updatePlaybackState) _updatePlaybackState(currentState);
const connected = ws && ws.readyState === WebSocket.OPEN;
if (_updateConnectionStatus) _updateConnectionStatus(connected);
if (lastStatus) {
const fallbackTitle = lastStatus.state === 'idle' ? t('player.no_media') : t('player.title_unavailable');
document.getElementById('track-title').textContent = lastStatus.title || fallbackTitle;
const initSrc = resolveMediaSource(lastStatus.source);
document.getElementById('source').textContent = initSrc ? initSrc.name : t('player.unknown_source');
document.getElementById('sourceIcon').innerHTML = initSrc?.icon || '';
}
if (hasCredentials()) {
if (_loadScriptsTable) _loadScriptsTable();
if (_loadCallbacksTable) _loadCallbacksTable();
if (_loadLinksTable) _loadLinksTable();
if (_displayQuickAccess) _displayQuickAccess();
}
if (_renderAccentSwatches) _renderAccentSwatches();
}
export async function fetchVersion() {
try {
const response = await fetch('/api/health');
if (response.ok) {
const data = await response.json();
const label = document.getElementById('version-label');
if (data.version) {
label.textContent = `v${data.version}`;
}
if (data.update_available) {
showUpdateBanner(data.update_available);
}
}
} catch (error) {
console.error('Error fetching version:', error);
}
}
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
// ============================================================
export function formatTime(seconds) {
if (!seconds || seconds < 0) return '0:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
export function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
export function showToast(message, type = 'success') {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
container.appendChild(toast);
requestAnimationFrame(() => {
toast.classList.add('show');
});
setTimeout(() => {
toast.classList.remove('show');
toast.addEventListener('transitionend', () => toast.remove(), { once: true });
setTimeout(() => { if (toast.parentNode) toast.remove(); }, 500);
}, TOAST_DURATION_MS);
}
export function closeDialog(dialog) {
dialog.classList.add('dialog-closing');
dialog.addEventListener('animationend', () => {
dialog.classList.remove('dialog-closing');
dialog.close();
}, { once: true });
}
export function showConfirm(message) {
return new Promise((resolve) => {
const dialog = document.getElementById('confirmDialog');
const msg = document.getElementById('confirmDialogMessage');
const btnCancel = document.getElementById('confirmDialogCancel');
const btnConfirm = document.getElementById('confirmDialogConfirm');
msg.textContent = message;
function cleanup() {
btnCancel.removeEventListener('click', onCancel);
btnConfirm.removeEventListener('click', onConfirm);
dialog.removeEventListener('close', onClose);
closeDialog(dialog);
}
function onCancel() { cleanup(); resolve(false); }
function onConfirm() { cleanup(); resolve(true); }
function onClose() { cleanup(); resolve(false); }
btnCancel.addEventListener('click', onCancel);
btnConfirm.addEventListener('click', onConfirm);
dialog.addEventListener('close', onClose);
dialog.showModal();
});
}
// ============================================================
// Auth Helpers
// ============================================================
// Set to false when server reports auth_required: false
export let authRequired = true;
export function setAuthRequired(value) { authRequired = value; }
/**
* Build Authorization headers for API requests.
* Returns empty object when auth is disabled or no token is stored.
*/
export function getAuthHeaders() {
const token = localStorage.getItem('media_server_token');
return token ? { 'Authorization': `Bearer ${token}` } : {};
}
/**
* Check if we have sufficient credentials to call the API.
* True when auth is disabled OR a token is stored.
*/
export function hasCredentials() {
return !authRequired || !!localStorage.getItem('media_server_token');
}
// ============================================================
// API Commands
// ============================================================
export async function sendCommand(endpoint, body = null) {
const options = {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
};
if (body) {
options.body = JSON.stringify(body);
}
try {
const response = await fetch(`/api/media/${endpoint}`, options);
if (!response.ok) {
const data = await response.json().catch(() => ({}));
console.error(`Command ${endpoint} failed:`, response.status);
showToast(data.detail || `Command failed: ${endpoint}`, 'error');
}
} catch (error) {
console.error(`Error sending command ${endpoint}:`, error);
showToast(`Connection error: ${endpoint}`, 'error');
}
}
export function togglePlayPause() {
if (currentState === 'playing') {
sendCommand('pause');
} else {
sendCommand('play');
}
}
export function nextTrack() {
sendCommand('next');
}
export function previousTrack() {
sendCommand('previous');
}
let lastSentVolume = -1;
export function setVolume(volume) {
if (volume === lastSentVolume) return;
lastSentVolume = volume;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'volume', volume: volume }));
} else {
sendCommand('volume', { volume: volume });
}
}
export function toggleMute() {
sendCommand('mute');
}
export function seek(position) {
sendCommand('seek', { position: position });
}
// ============================================================
// MDI Icon System
// ============================================================
const mdiIconCache = (() => {
try {
return JSON.parse(localStorage.getItem('mdiIconCache') || '{}');
} catch { return {}; }
})();
function _persistMdiCache() {
try { localStorage.setItem('mdiIconCache', JSON.stringify(mdiIconCache)); } catch {}
}
export async function fetchMdiIcon(iconName) {
const name = iconName.replace(/^mdi:/, '');
if (mdiIconCache[name]) return mdiIconCache[name];
try {
const response = await fetch(`https://api.iconify.design/mdi/${name}.svg?width=16&height=16`);
if (response.ok) {
const svg = await response.text();
mdiIconCache[name] = svg;
_persistMdiCache();
return svg;
}
} 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>';
}
export async function resolveMdiIcons(container) {
const els = container.querySelectorAll('[data-mdi-icon]');
await Promise.all(Array.from(els).map(async (el) => {
const icon = el.dataset.mdiIcon;
if (icon) {
el.innerHTML = await fetchMdiIcon(icon);
}
}));
}
export function setupIconPreview(inputId, previewId) {
const input = document.getElementById(inputId);
const preview = document.getElementById(previewId);
if (!input || !preview) return;
let debounceTimer = null;
input.addEventListener('input', () => {
clearTimeout(debounceTimer);
const value = input.value.trim();
if (!value) {
preview.innerHTML = '';
return;
}
debounceTimer = setTimeout(async () => {
const svg = await fetchMdiIcon(value);
if (input.value.trim() === value) {
preview.innerHTML = svg;
}
}, 400);
});
}
+160
View File
@@ -0,0 +1,160 @@
// ============================================================
// IconSelect: visual icon-grid selector (replaces <select>)
// Ported from wled-screen-controller (TypeScript → vanilla JS)
//
// Trigger replaces the <select> inline. Popup is absolutely
// positioned inside a wrapper that sits next to the trigger.
// Works inside <dialog showModal()> — dialog must have
// overflow: visible.
// ============================================================
const POPUP_CLASS = 'icon-select-popup';
let _globalListenerAdded = false;
export function closeAllIconSelects() {
document.querySelectorAll(`.${POPUP_CLASS}.open`).forEach(p => {
p.classList.remove('open');
});
}
function _ensureGlobalListener() {
if (_globalListenerAdded) return;
_globalListenerAdded = true;
document.addEventListener('click', (e) => {
if (!e.target.closest(`.${POPUP_CLASS}`) && !e.target.closest('.icon-select-trigger')) {
closeAllIconSelects();
}
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeAllIconSelects();
});
}
export class IconSelect {
constructor({ target, items, onChange, columns = 2, placeholder = '', horizontal = false }) {
_ensureGlobalListener();
this._select = target;
this._items = items;
this._onChange = onChange;
this._columns = columns;
this._placeholder = placeholder;
this._horizontal = horizontal;
// Hide native select
this._select.style.display = 'none';
// Trigger button (replaces select visually)
this._trigger = document.createElement('button');
this._trigger.type = 'button';
this._trigger.className = 'icon-select-trigger';
this._trigger.addEventListener('click', (e) => {
e.stopPropagation();
this._toggle();
});
this._select.parentNode.insertBefore(this._trigger, this._select.nextSibling);
// Popup — absolutely positioned, appended to dialog (overflow:visible)
// or body, escaping any scrollable ancestors
this._popup = document.createElement('div');
this._popup.className = POPUP_CLASS;
this._popup.addEventListener('click', (e) => e.stopPropagation());
this._popup.innerHTML = this._buildGrid();
const portal = this._select.closest('dialog') || document.body;
portal.appendChild(this._popup);
this._bindCells();
this._syncTrigger();
}
_buildGrid() {
const cells = this._items.map(item =>
`<div class="icon-select-cell" data-value="${item.value}">
<span class="icon-select-cell-icon">${item.icon}</span>
<span class="icon-select-cell-label">${item.label}</span>
${item.desc ? `<span class="icon-select-cell-desc">${item.desc}</span>` : ''}
</div>`
).join('');
const cls = 'icon-select-grid' + (this._horizontal ? ' icon-select-grid--horizontal' : '');
return `<div class="${cls}" style="grid-template-columns:repeat(${this._columns},1fr)">${cells}</div>`;
}
_bindCells() {
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => {
cell.addEventListener('click', () => {
this.setValue(cell.dataset.value, true);
this._popup.classList.remove('open');
});
});
}
_syncTrigger() {
const val = this._select.value;
const item = this._items.find(i => i.value === val);
if (item) {
this._trigger.innerHTML =
`<span class="icon-select-trigger-icon">${item.icon}</span>` +
`<span class="icon-select-trigger-label">${item.label}</span>` +
`<span class="icon-select-trigger-arrow">&#x25BE;</span>`;
} else if (this._placeholder) {
this._trigger.innerHTML =
`<span class="icon-select-trigger-label">${this._placeholder}</span>` +
`<span class="icon-select-trigger-arrow">&#x25BE;</span>`;
}
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => {
cell.classList.toggle('active', cell.dataset.value === val);
});
}
_positionPopup() {
// Get trigger position relative to the popup's offset parent
// (the dialog or body). Use getBoundingClientRect for both and
// compute the offset.
const triggerRect = this._trigger.getBoundingClientRect();
const parentRect = this._popup.offsetParent
? this._popup.offsetParent.getBoundingClientRect()
: { left: 0, top: 0 };
const relTop = triggerRect.bottom - parentRect.top;
const relLeft = triggerRect.left - parentRect.left;
const popupW = Math.max(triggerRect.width, 200);
this._popup.style.left = relLeft + 'px';
this._popup.style.top = (relTop + 4) + 'px';
this._popup.style.width = popupW + 'px';
}
_toggle() {
const wasOpen = this._popup.classList.contains('open');
closeAllIconSelects();
if (!wasOpen) {
this._positionPopup();
this._popup.classList.add('open');
}
}
setValue(value, fireChange = false) {
this._select.value = value;
this._syncTrigger();
if (fireChange) {
this._select.dispatchEvent(new Event('change', { bubbles: true }));
if (this._onChange) this._onChange(value);
}
}
updateItems(items) {
this._items = items;
this._popup.innerHTML = this._buildGrid();
this._bindCells();
this._syncTrigger();
}
destroy() {
this._trigger.remove();
this._popup.remove();
this._select.style.display = '';
}
}
+31
View File
@@ -0,0 +1,31 @@
// ============================================================
// SVG icon library for icon-select grids
// Simple inline SVGs (24x24 viewBox, fill="currentColor")
// ============================================================
const _svg = (path) =>
`<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">${path}</svg>`;
// Parameter types
export const paramTypeIcons = {
string: _svg('<path d="M3 7V5h18v2H3zm0 12v-2h12v2H3zm0-6v-2h18v2H3z"/>'),
integer: _svg('<path d="M4 17V7h2v4h3V7h2v10h-2v-4H6v4H4zm10-1h2v1h2v-4h-2v1h-2V9h6v8h-6v-1z"/>'),
float: _svg('<path d="M5 17V7h2v4h3V7h2v10H9v-4H7v4H5zm9.5 0v-2a1 1 0 1 1 0-2h1v-2h-1a3 3 0 0 0 0 6h1v2h-1zm3-6v2h1a1 1 0 1 1 0 2h-1v2h1a3 3 0 0 0 0-6h-1z"/>'),
boolean: _svg('<path d="M17 7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h10c2.76 0 5-2.24 5-5s-2.24-5-5-5zm0 8c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3z"/>'),
select: _svg('<path d="M3 5h18v2H3V5zm4 6h10v2H7v-2zm-4 6h18v2H3v-2z"/><path d="M7 7l5 5 5-5"/>'),
};
// Callback events
export const callbackEventIcons = {
on_play: _svg('<path d="M8 5v14l11-7z"/>'),
on_pause: _svg('<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>'),
on_stop: _svg('<path d="M6 6h12v12H6z"/>'),
on_next: _svg('<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/>'),
on_previous: _svg('<path d="M6 6h2v12H6V6zm3.5 6l8.5 6V6l-8.5 6z"/>'),
on_volume: _svg('<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 8.5v7a4.5 4.5 0 0 0 2.5-3.5zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>'),
on_mute: _svg('<path d="M16.5 12A4.5 4.5 0 0 0 14 8.5v2.09l2.41 2.41c.06-.31.09-.65.09-1zM19 12c0 .94-.2 1.82-.54 2.64l1.51 1.51A8.796 8.796 0 0 0 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06a8.99 8.99 0 0 0 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>'),
on_seek: _svg('<path d="M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/>'),
on_turn_on: _svg('<path d="M13 3h-2v10h2V3zm4.83 2.17l-1.42 1.42A6.92 6.92 0 0 1 19 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.96 8.96 0 0 0 3 12c0 4.97 4.03 9 9 9s9-4.03 9-9a8.96 8.96 0 0 0-3.17-6.83z"/>'),
on_turn_off: _svg('<path d="M13 3h-2v10h2V3zm4.83 2.17l-1.42 1.42A6.92 6.92 0 0 1 19 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.96 8.96 0 0 0 3 12c0 4.97 4.03 9 9 9s9-4.03 9-9a8.96 8.96 0 0 0-3.17-6.83z"/>'),
on_toggle: _svg('<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 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>'),
};
+397
View File
@@ -0,0 +1,397 @@
// ============================================================
// Display Brightness & Power Control + Links Management
// ============================================================
import { t, showToast, escapeHtml, closeDialog, showConfirm, resolveMdiIcons, fetchMdiIcon, getAuthHeaders, hasCredentials } from './core.js';
let displayBrightnessTimers = {};
const DISPLAY_THROTTLE_MS = 50;
export async function loadDisplayMonitors() {
if (!hasCredentials()) return;
const container = document.getElementById('displayMonitors');
if (!container) return;
try {
const response = await fetch('/api/display/monitors?refresh=true', {
headers: getAuthHeaders()
});
if (!response.ok) {
container.innerHTML = `<div class="empty-state-illustration">
<svg viewBox="0 0 64 64"><rect x="8" y="10" width="48" height="32" rx="3" fill="none" stroke="currentColor" stroke-width="2"/><line x1="32" y1="42" x2="32" y2="50" stroke="currentColor" stroke-width="2"/><line x1="22" y1="50" x2="42" y2="50" stroke="currentColor" stroke-width="2"/></svg>
<p data-i18n="display.error">Failed to load monitors</p>
</div>`;
return;
}
const monitors = await response.json();
if (monitors.length === 0) {
container.innerHTML = `<div class="empty-state-illustration">
<svg viewBox="0 0 64 64"><rect x="8" y="10" width="48" height="32" rx="3" fill="none" stroke="currentColor" stroke-width="2"/><line x1="32" y1="42" x2="32" y2="50" stroke="currentColor" stroke-width="2"/><line x1="22" y1="50" x2="42" y2="50" stroke="currentColor" stroke-width="2"/></svg>
<p data-i18n="display.no_monitors">No monitors detected</p>
</div>`;
return;
}
container.innerHTML = '';
monitors.forEach(monitor => {
const card = document.createElement('div');
card.className = 'display-monitor-card';
card.id = `monitor-card-${monitor.id}`;
const brightnessValue = monitor.brightness !== null ? monitor.brightness : 0;
const brightnessDisabled = monitor.brightness === null ? 'disabled' : '';
let powerBtn = '';
if (monitor.power_supported) {
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')}">
<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>` : '';
card.innerHTML = `
<div class="display-monitor-header">
<svg class="display-monitor-icon" viewBox="0 0 24 24" width="20" height="20">
<path fill="currentColor" d="M20 3H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h6v2H8v2h8v-2h-2v-2h6c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 12H4V5h16v10z"/>
</svg>
<div class="display-monitor-info">
<span class="display-monitor-name">${monitor.name}${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">
<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}
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>`;
container.appendChild(card);
});
} catch (e) {
console.error('Failed to load display monitors:', e);
}
}
export function onDisplayBrightnessInput(monitorId, value) {
const label = document.getElementById(`brightness-val-${monitorId}`);
if (label) label.textContent = `${value}%`;
if (displayBrightnessTimers[monitorId]) clearTimeout(displayBrightnessTimers[monitorId]);
displayBrightnessTimers[monitorId] = setTimeout(() => {
sendDisplayBrightness(monitorId, parseInt(value));
displayBrightnessTimers[monitorId] = null;
}, DISPLAY_THROTTLE_MS);
}
export function onDisplayBrightnessChange(monitorId, value) {
if (displayBrightnessTimers[monitorId]) {
clearTimeout(displayBrightnessTimers[monitorId]);
displayBrightnessTimers[monitorId] = null;
}
sendDisplayBrightness(monitorId, parseInt(value));
}
async function sendDisplayBrightness(monitorId, brightness) {
try {
await fetch(`/api/display/brightness/${monitorId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
body: JSON.stringify({ brightness })
});
} catch (e) {
console.error('Failed to set brightness:', e);
}
}
export async function toggleDisplayPower(monitorId, monitorName) {
const btn = document.getElementById(`power-btn-${monitorId}`);
const isOn = btn && btn.classList.contains('on');
const newState = !isOn;
try {
const response = await fetch(`/api/display/power/${monitorId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
body: JSON.stringify({ on: newState })
});
const data = await response.json();
if (data.success) {
if (btn) {
btn.classList.toggle('on', newState);
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');
} else {
showToast('Failed to change monitor power', 'error');
}
} catch (e) {
console.error('Failed to set display power:', e);
showToast('Failed to change monitor power', 'error');
}
}
// ============================================================
// Header Quick Links
// ============================================================
export async function loadHeaderLinks() {
if (!hasCredentials()) return;
const container = document.getElementById('headerLinks');
if (!container) return;
try {
const response = await fetch('/api/links/list', {
headers: getAuthHeaders()
});
if (!response.ok) return;
const links = await response.json();
container.innerHTML = '';
for (const link of links) {
const a = document.createElement('a');
a.href = link.url;
a.target = '_blank';
a.rel = 'noopener noreferrer';
a.className = 'header-link';
a.title = link.label || link.url;
const iconSvg = await fetchMdiIcon(link.icon || 'mdi:link');
a.innerHTML = iconSvg;
container.appendChild(a);
}
} catch (e) {
console.warn('Failed to load header links:', e);
}
}
// ============================================================
// Links Management
// ============================================================
let _loadLinksPromise = null;
export let linkFormDirty = false;
export function setLinkFormDirty(value) { linkFormDirty = value; }
export async function loadLinksTable() {
if (_loadLinksPromise) return _loadLinksPromise;
_loadLinksPromise = _loadLinksTableImpl();
_loadLinksPromise.finally(() => { _loadLinksPromise = null; });
return _loadLinksPromise;
}
async function _loadLinksTableImpl() {
const tbody = document.getElementById('linksTableBody');
try {
const response = await fetch('/api/links/list', {
headers: getAuthHeaders()
});
if (!response.ok) {
throw new Error('Failed to fetch links');
}
const linksList = await response.json();
if (linksList.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="empty-state"><div class="empty-state-illustration"><svg viewBox="0 0 64 64"><path d="M26 20a10 10 0 010 14l-6 6a10 10 0 01-14-14l6-6a10 10 0 0114 0" fill="none" stroke="currentColor" stroke-width="2"/><path d="M38 44a10 10 0 010-14l6-6a10 10 0 0114 14l-6 6a10 10 0 01-14 0" fill="none" stroke="currentColor" stroke-width="2"/><path d="M24 40l16-16" stroke="currentColor" stroke-width="2"/></svg><p>' + t('links.empty') + '</p></div></td></tr>';
return;
}
tbody.innerHTML = linksList.map(link => `
<tr>
<td><span class="name-with-icon"><span class="table-icon" data-mdi-icon="${escapeHtml(link.icon || 'mdi:link')}"></span><code>${escapeHtml(link.name)}</code></span></td>
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"
title="${escapeHtml(link.url)}">${escapeHtml(link.url)}</td>
<td>${escapeHtml(link.label || '')}</td>
<td>
<div class="action-buttons">
<button class="action-btn" data-action="edit" data-link-name="${escapeHtml(link.name)}" title="${t('links.button.edit')}">
<svg viewBox="0 0 24 24"><path 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="action-btn delete" data-action="delete" data-link-name="${escapeHtml(link.name)}" title="${t('links.button.delete')}">
<svg viewBox="0 0 24 24"><path 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>
</div>
</td>
</tr>
`).join('');
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>';
}
}
export function showAddLinkDialog() {
const dialog = document.getElementById('linkDialog');
const form = document.getElementById('linkForm');
const title = document.getElementById('linkDialogTitle');
form.reset();
document.getElementById('linkOriginalName').value = '';
document.getElementById('linkIsEdit').value = 'false';
document.getElementById('linkName').disabled = false;
document.getElementById('linkIconPreview').innerHTML = '';
title.textContent = t('links.dialog.add');
linkFormDirty = false;
document.body.classList.add('dialog-open');
dialog.showModal();
}
export async function showEditLinkDialog(linkName) {
const dialog = document.getElementById('linkDialog');
const title = document.getElementById('linkDialogTitle');
try {
const response = await fetch('/api/links/list', {
headers: getAuthHeaders()
});
if (!response.ok) {
throw new Error('Failed to fetch link details');
}
const linksList = await response.json();
const link = linksList.find(l => l.name === linkName);
if (!link) {
showToast(t('links.msg.not_found'), 'error');
return;
}
document.getElementById('linkOriginalName').value = linkName;
document.getElementById('linkIsEdit').value = 'true';
document.getElementById('linkName').value = linkName;
document.getElementById('linkName').disabled = true;
document.getElementById('linkUrl').value = link.url;
document.getElementById('linkIcon').value = link.icon || '';
document.getElementById('linkLabel').value = link.label || '';
document.getElementById('linkDescription').value = link.description || '';
// Update icon preview
const preview = document.getElementById('linkIconPreview');
if (link.icon) {
fetchMdiIcon(link.icon).then(svg => { preview.innerHTML = svg; });
} else {
preview.innerHTML = '';
}
title.textContent = t('links.dialog.edit');
linkFormDirty = false;
document.body.classList.add('dialog-open');
dialog.showModal();
} catch (error) {
console.error('Error loading link for edit:', error);
showToast(t('links.msg.load_failed'), 'error');
}
}
export async function closeLinkDialog() {
if (linkFormDirty) {
if (!await showConfirm(t('links.confirm.unsaved'))) {
return;
}
}
const dialog = document.getElementById('linkDialog');
linkFormDirty = false;
closeDialog(dialog);
document.body.classList.remove('dialog-open');
}
export async function saveLink(event) {
event.preventDefault();
const submitBtn = event.target.querySelector('button[type="submit"]');
if (submitBtn) submitBtn.disabled = true;
const isEdit = document.getElementById('linkIsEdit').value === 'true';
const linkName = isEdit ?
document.getElementById('linkOriginalName').value :
document.getElementById('linkName').value;
const data = {
url: document.getElementById('linkUrl').value,
icon: document.getElementById('linkIcon').value || 'mdi:link',
label: document.getElementById('linkLabel').value || '',
description: document.getElementById('linkDescription').value || ''
};
const endpoint = isEdit ?
`/api/links/update/${linkName}` :
`/api/links/create/${linkName}`;
const method = isEdit ? 'PUT' : 'POST';
try {
const response = await fetch(endpoint, {
method,
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
body: JSON.stringify(data)
});
const result = await response.json();
if (response.ok && result.success) {
showToast(t(isEdit ? 'links.msg.updated' : 'links.msg.created'), 'success');
linkFormDirty = false;
closeLinkDialog();
} else {
showToast(result.detail || t(isEdit ? 'links.msg.update_failed' : 'links.msg.create_failed'), 'error');
}
} catch (error) {
console.error('Error saving link:', error);
showToast(t(isEdit ? 'links.msg.update_failed' : 'links.msg.create_failed'), 'error');
} finally {
if (submitBtn) submitBtn.disabled = false;
}
}
export async function deleteLinkConfirm(linkName) {
if (!await showConfirm(t('links.confirm.delete').replace('{name}', linkName))) {
return;
}
try {
const response = await fetch(`/api/links/delete/${linkName}`, {
method: 'DELETE',
headers: getAuthHeaders()
});
const result = await response.json();
if (response.ok && result.success) {
showToast(t('links.msg.deleted'), 'success');
} else {
showToast(result.detail || t('links.msg.delete_failed'), 'error');
}
} catch (error) {
console.error('Error deleting link:', error);
showToast(t('links.msg.delete_failed'), 'error');
}
}
+779
View File
@@ -0,0 +1,779 @@
// ============================================================
// Player: Tabs, theme, accent, vinyl, visualizer, UI updates
// ============================================================
import {
dom, t, formatTime, showToast, resolveMediaSource,
SVG_PLAY, SVG_PAUSE, SVG_STOP, SVG_IDLE, SVG_MUTED, SVG_UNMUTED,
ws, currentState, setCurrentState, currentDuration, setCurrentDuration,
currentPosition, setCurrentPosition, isUserAdjustingVolume,
lastStatus, setLastStatus, currentPlayState, setCurrentPlayState,
POSITION_INTERPOLATION_MS, seek,
getAuthHeaders, hasCredentials,
} from './core.js';
import { updateBackgroundColors } from './background.js';
import { loadDisplayMonitors } from './links.js';
import { IconSelect } from './icon-select.js';
// Tab management
export let activeTab = 'player';
export function setMiniPlayerVisible(visible) {
const miniPlayer = document.getElementById('mini-player');
if (visible) {
miniPlayer.classList.remove('hidden');
document.body.classList.add('mini-player-visible');
} else {
miniPlayer.classList.add('hidden');
document.body.classList.remove('mini-player-visible');
}
}
export function updateTabIndicator(btn, animate = true) {
const indicator = document.getElementById('tabIndicator');
if (!indicator || !btn) return;
const tabBar = document.getElementById('tabBar');
const barRect = tabBar.getBoundingClientRect();
const btnRect = btn.getBoundingClientRect();
const offset = btnRect.left - barRect.left - parseFloat(getComputedStyle(tabBar).paddingLeft || 0);
if (!animate) indicator.style.transition = 'none';
indicator.style.width = btnRect.width + 'px';
indicator.style.transform = `translateX(${offset}px)`;
if (!animate) {
indicator.offsetHeight;
indicator.style.transition = '';
}
}
export function switchTab(tabName) {
activeTab = tabName;
document.querySelectorAll('[data-tab-content]').forEach(el => {
el.classList.remove('active');
el.style.display = '';
});
const target = document.querySelector(`[data-tab-content="${tabName}"]`);
if (target) {
target.classList.add('active');
}
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.remove('active');
btn.setAttribute('aria-selected', 'false');
btn.setAttribute('tabindex', '-1');
});
const activeBtn = document.querySelector(`.tab-btn[data-tab="${tabName}"]`);
if (activeBtn) {
activeBtn.classList.add('active');
activeBtn.setAttribute('aria-selected', 'true');
activeBtn.setAttribute('tabindex', '0');
updateTabIndicator(activeBtn);
}
if (tabName === 'display') {
loadDisplayMonitors();
}
localStorage.setItem('activeTab', tabName);
if (tabName !== 'player') {
setMiniPlayerVisible(true);
} else {
const playerContainer = document.querySelector('.player-container');
const rect = playerContainer.getBoundingClientRect();
const inView = rect.top < window.innerHeight && rect.bottom > 0;
setMiniPlayerVisible(!inView);
}
}
// Theme management
export function initTheme() {
const savedTheme = localStorage.getItem('theme') || 'dark';
setTheme(savedTheme);
}
export function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
const sunIcon = document.getElementById('theme-icon-sun');
const moonIcon = document.getElementById('theme-icon-moon');
if (theme === 'light') {
sunIcon.style.display = 'none';
moonIcon.style.display = 'block';
} else {
sunIcon.style.display = 'block';
moonIcon.style.display = 'none';
}
const metaThemeColor = document.querySelector('meta[name="theme-color"]');
if (metaThemeColor) {
metaThemeColor.setAttribute('content', theme === 'light' ? '#ffffff' : '#121212');
}
updateBackgroundColors();
}
export function toggleTheme() {
const currentTheme = document.documentElement.getAttribute('data-theme') || 'dark';
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
setTheme(newTheme);
}
// Accent color management
export const accentPresets = [
{ name: 'Green', color: '#1db954', hover: '#1ed760' },
{ name: 'Blue', color: '#3b82f6', hover: '#60a5fa' },
{ name: 'Purple', color: '#8b5cf6', hover: '#a78bfa' },
{ name: 'Pink', color: '#ec4899', hover: '#f472b6' },
{ name: 'Orange', color: '#f97316', hover: '#fb923c' },
{ name: 'Red', color: '#ef4444', hover: '#f87171' },
{ name: 'Teal', color: '#14b8a6', hover: '#2dd4bf' },
{ name: 'Cyan', color: '#06b6d4', hover: '#22d3ee' },
{ name: 'Yellow', color: '#eab308', hover: '#facc15' },
];
export function lightenColor(hex, percent) {
const num = parseInt(hex.replace('#', ''), 16);
const r = Math.min(255, (num >> 16) + Math.round(255 * percent / 100));
const g = Math.min(255, ((num >> 8) & 0xff) + Math.round(255 * percent / 100));
const b = Math.min(255, (num & 0xff) + Math.round(255 * percent / 100));
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
}
export function initAccentColor() {
const saved = localStorage.getItem('accentColor');
if (saved) {
const preset = accentPresets.find(p => p.color === saved);
if (preset) {
applyAccentColor(preset.color, preset.hover);
} else {
applyAccentColor(saved, lightenColor(saved, 15));
}
}
renderAccentSwatches();
}
export function applyAccentColor(color, hover) {
document.documentElement.style.setProperty('--accent', color);
document.documentElement.style.setProperty('--accent-hover', hover);
localStorage.setItem('accentColor', color);
const dot = document.getElementById('accentDot');
if (dot) dot.style.background = color;
updateBackgroundColors();
}
export function renderAccentSwatches() {
const dropdown = document.getElementById('accentDropdown');
if (!dropdown) return;
const current = localStorage.getItem('accentColor') || '#1db954';
const isCustom = !accentPresets.some(p => p.color === current);
const swatches = accentPresets.map(p =>
`<div class="accent-swatch ${p.color === current ? 'active' : ''}"
style="background: ${p.color}"
onclick="selectAccentColor('${p.color}', '${p.hover}')"
title="${p.name}"></div>`
).join('');
const customRow = `
<div class="accent-custom-row ${isCustom ? 'active' : ''}" onclick="document.getElementById('accentCustomInput').click()">
<span class="accent-custom-swatch" style="background: ${isCustom ? current : '#888'}"></span>
<span class="accent-custom-label">${t('accent.custom')}</span>
<input type="color" id="accentCustomInput" value="${current}"
onclick="event.stopPropagation()"
onchange="selectAccentColor(this.value, lightenColor(this.value, 15))">
</div>`;
dropdown.innerHTML = swatches + customRow;
}
export function selectAccentColor(color, hover) {
applyAccentColor(color, hover);
renderAccentSwatches();
document.getElementById('accentDropdown').classList.remove('open');
}
export function toggleAccentPicker() {
document.getElementById('accentDropdown').classList.toggle('open');
}
document.addEventListener('click', (e) => {
if (!e.target.closest('.accent-picker')) {
document.getElementById('accentDropdown')?.classList.remove('open');
}
});
// Vinyl mode
let vinylMode = localStorage.getItem('vinylMode') === 'true';
function getVinylAngle() {
const art = document.getElementById('album-art');
if (!art) return 0;
const st = getComputedStyle(art);
const tr = st.transform;
if (!tr || tr === 'none') return 0;
const m = tr.match(/matrix\((.+)\)/);
if (!m) return 0;
const vals = m[1].split(',').map(Number);
const angle = Math.round(Math.atan2(vals[1], vals[0]) * (180 / Math.PI));
return ((angle % 360) + 360) % 360;
}
function saveVinylAngle() {
if (!vinylMode) return;
localStorage.setItem('vinylAngle', getVinylAngle());
}
function restoreVinylAngle() {
const saved = localStorage.getItem('vinylAngle');
if (saved) {
const art = document.getElementById('album-art');
if (art) art.style.setProperty('--vinyl-offset', `${saved}deg`);
}
}
setInterval(saveVinylAngle, 2000);
window.addEventListener('beforeunload', saveVinylAngle);
export function toggleVinylMode() {
if (vinylMode) saveVinylAngle();
vinylMode = !vinylMode;
localStorage.setItem('vinylMode', vinylMode);
applyVinylMode();
}
export function applyVinylMode() {
const container = document.querySelector('.album-art-container');
const btn = document.getElementById('vinylToggle');
if (!container) return;
if (vinylMode) {
container.classList.add('vinyl');
if (btn) btn.classList.add('active');
restoreVinylAngle();
updateVinylSpin();
} else {
saveVinylAngle();
container.classList.remove('vinyl', 'spinning', 'paused');
if (btn) btn.classList.remove('active');
}
}
function updateVinylSpin() {
const container = document.querySelector('.album-art-container');
if (!container || !vinylMode) return;
container.classList.remove('spinning', 'paused');
if (currentPlayState === 'playing') {
container.classList.add('spinning');
} else if (currentPlayState === 'paused') {
container.classList.add('paused');
}
}
// Audio Visualizer
export let visualizerEnabled = localStorage.getItem('visualizerEnabled') === 'true';
export let visualizerAvailable = false;
let visualizerCtx = null;
let visualizerAnimFrame = null;
export let frequencyData = null;
export function setFrequencyData(value) { frequencyData = value; }
let smoothedFrequencies = null;
const VISUALIZER_SMOOTHING = 0.15;
export async function checkVisualizerAvailability() {
try {
const resp = await fetch('/api/media/visualizer/status', {
headers: getAuthHeaders()
});
if (resp.ok) {
const data = await resp.json();
visualizerAvailable = data.available;
}
} catch (e) {
visualizerAvailable = false;
}
const btn = document.getElementById('visualizerToggle');
if (btn) btn.style.display = visualizerAvailable ? '' : 'none';
}
export function toggleVisualizer() {
visualizerEnabled = !visualizerEnabled;
localStorage.setItem('visualizerEnabled', visualizerEnabled);
applyVisualizerMode();
}
export function applyVisualizerMode() {
const container = document.querySelector('.album-art-container');
const btn = document.getElementById('visualizerToggle');
if (!container) return;
if (visualizerEnabled && visualizerAvailable) {
container.classList.add('visualizer-active');
if (btn) btn.classList.add('active');
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'enable_visualizer' }));
}
initVisualizerCanvas();
startVisualizerRender();
} else {
container.classList.remove('visualizer-active');
if (btn) btn.classList.remove('active');
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'disable_visualizer' }));
}
stopVisualizerRender();
}
// Sync the audio device status badge with the new capture state
updateAudioDeviceStatus({
running: visualizerEnabled && visualizerAvailable,
available: visualizerAvailable
});
}
function initVisualizerCanvas() {
const canvas = document.getElementById('spectrogram-canvas');
if (!canvas) return;
visualizerCtx = canvas.getContext('2d');
canvas.width = 300;
canvas.height = 64;
}
function startVisualizerRender() {
if (visualizerAnimFrame) return;
renderVisualizerFrame();
}
export function stopVisualizerRender() {
if (visualizerAnimFrame) {
cancelAnimationFrame(visualizerAnimFrame);
visualizerAnimFrame = null;
}
const canvas = document.getElementById('spectrogram-canvas');
if (visualizerCtx && canvas) {
visualizerCtx.clearRect(0, 0, canvas.width, canvas.height);
}
const art = document.getElementById('album-art');
if (art) {
art.style.transform = '';
art.style.removeProperty('--vinyl-scale');
}
const glow = document.getElementById('album-art-glow');
if (glow) glow.style.opacity = '';
frequencyData = null;
smoothedFrequencies = null;
}
function renderVisualizerFrame() {
visualizerAnimFrame = requestAnimationFrame(renderVisualizerFrame);
const canvas = document.getElementById('spectrogram-canvas');
if (!frequencyData || !visualizerCtx || !canvas) return;
const bins = frequencyData.frequencies;
const numBins = bins.length;
const w = canvas.width;
const h = canvas.height;
const gap = 2;
const barWidth = (w / numBins) - gap;
const accent = getComputedStyle(document.documentElement)
.getPropertyValue('--accent').trim();
if (!smoothedFrequencies || smoothedFrequencies.length !== numBins) {
smoothedFrequencies = new Array(numBins).fill(0);
}
for (let i = 0; i < numBins; i++) {
smoothedFrequencies[i] = smoothedFrequencies[i] * VISUALIZER_SMOOTHING
+ bins[i] * (1 - VISUALIZER_SMOOTHING);
}
visualizerCtx.clearRect(0, 0, w, h);
for (let i = 0; i < numBins; i++) {
const barHeight = Math.max(1, smoothedFrequencies[i] * h);
const x = i * (barWidth + gap) + gap / 2;
const y = h - barHeight;
const grad = visualizerCtx.createLinearGradient(x, y, x, h);
grad.addColorStop(0, accent);
grad.addColorStop(1, accent + '30');
visualizerCtx.fillStyle = grad;
visualizerCtx.beginPath();
visualizerCtx.roundRect(x, y, barWidth, barHeight, 1.5);
visualizerCtx.fill();
}
const bass = frequencyData.bass || 0;
const scale = 1 + bass * 0.04;
const art = document.getElementById('album-art');
if (art) {
if (vinylMode) {
art.style.setProperty('--vinyl-scale', scale);
} else {
art.style.transform = `scale(${scale})`;
}
}
const glow = document.getElementById('album-art-glow');
if (glow) {
glow.style.opacity = (0.4 + bass * 0.4).toFixed(2);
}
}
// Audio device selection
let _audioDeviceIconSelect = null;
export async function loadAudioDevices() {
const section = document.getElementById('audioDeviceSection');
const select = document.getElementById('audioDeviceSelect');
if (!section || !select) return;
try {
const [devicesResp, statusResp] = await Promise.all([
fetch('/api/media/visualizer/devices', {
headers: getAuthHeaders()
}),
fetch('/api/media/visualizer/status', {
headers: getAuthHeaders()
})
]);
if (!devicesResp.ok || !statusResp.ok) return;
const devices = await devicesResp.json();
const status = await statusResp.json();
if (!status.available && devices.length === 0) {
section.style.display = 'none';
return;
}
section.style.display = '';
while (select.options.length > 1) select.remove(1);
for (const dev of devices) {
const opt = document.createElement('option');
opt.value = dev.name;
opt.textContent = dev.name;
select.appendChild(opt);
}
if (status.current_device) {
for (let i = 0; i < select.options.length; i++) {
if (select.options[i].value === status.current_device) {
select.selectedIndex = i;
break;
}
}
}
// Enhance with icon grid
const audioSvg = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 8.5v7a4.5 4.5 0 0 0 2.5-3.5z"/></svg>';
const items = [
{ value: '', icon: audioSvg, label: t('settings.audio.auto') },
...devices.map(dev => ({ value: dev.name, icon: audioSvg, label: dev.name })),
];
if (_audioDeviceIconSelect) _audioDeviceIconSelect.destroy();
_audioDeviceIconSelect = new IconSelect({
target: select,
items,
columns: 1,
horizontal: true,
onChange: () => onAudioDeviceChanged(),
});
_audioDeviceIconSelect.setValue(select.value, false);
// Sync visualizerAvailable from the fetched status so that
// applyVisualizerMode() and the toggle button are consistent.
visualizerAvailable = status.available;
const btn = document.getElementById('visualizerToggle');
if (btn) btn.style.display = visualizerAvailable ? '' : 'none';
updateAudioDeviceStatus(status);
// Re-subscribe the WebSocket if the user had the visualizer enabled.
if (visualizerEnabled && visualizerAvailable) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'enable_visualizer' }));
}
}
} catch (e) {
section.style.display = 'none';
}
}
function updateAudioDeviceStatus(status) {
const el = document.getElementById('audioDeviceStatus');
if (!el) return;
// Badge reflects local visualizer state (capture is on-demand per subscriber)
if (visualizerEnabled && status.available) {
el.className = 'audio-device-status active';
el.textContent = t('settings.audio.status_active');
} else if (status.available) {
el.className = 'audio-device-status available';
el.textContent = t('settings.audio.status_available');
} else {
el.className = 'audio-device-status unavailable';
el.textContent = t('settings.audio.status_unavailable');
}
}
export async function onAudioDeviceChanged() {
const select = document.getElementById('audioDeviceSelect');
if (!select) return;
const deviceName = select.value || null;
try {
const resp = await fetch('/api/media/visualizer/device', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
body: JSON.stringify({ device_name: deviceName })
});
if (resp.ok) {
const result = await resp.json();
updateAudioDeviceStatus({ available: result.success, ...result });
await checkVisualizerAvailability();
if (visualizerEnabled) applyVisualizerMode();
showToast(t('settings.audio.device_changed'), 'success');
} else {
showToast(t('settings.audio.device_change_failed'), 'error');
}
} catch (e) {
showToast(t('settings.audio.device_change_failed'), 'error');
}
}
// ============================================================
// UI State Updates
// ============================================================
let lastArtworkKey = null;
let currentArtworkBlobUrl = null;
let lastPositionUpdate = 0;
let lastPositionValue = 0;
let interpolationInterval = null;
export function setupProgressDrag(bar, fill) {
let dragging = false;
function getPercent(clientX) {
const rect = bar.getBoundingClientRect();
return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
}
function updatePreview(percent) {
fill.style.width = (percent * 100) + '%';
}
function handleStart(clientX) {
if (currentDuration <= 0) return;
dragging = true;
bar.classList.add('dragging');
updatePreview(getPercent(clientX));
}
function handleMove(clientX) {
if (!dragging) return;
updatePreview(getPercent(clientX));
}
function handleEnd(clientX) {
if (!dragging) return;
dragging = false;
bar.classList.remove('dragging');
const percent = getPercent(clientX);
seek(percent * currentDuration);
}
bar.addEventListener('mousedown', (e) => { e.preventDefault(); handleStart(e.clientX); });
document.addEventListener('mousemove', (e) => { handleMove(e.clientX); });
document.addEventListener('mouseup', (e) => { handleEnd(e.clientX); });
bar.addEventListener('touchstart', (e) => { handleStart(e.touches[0].clientX); }, { passive: true });
document.addEventListener('touchmove', (e) => { if (dragging) handleMove(e.touches[0].clientX); });
document.addEventListener('touchend', (e) => {
if (dragging) {
const touch = e.changedTouches[0];
handleEnd(touch.clientX);
}
});
bar.addEventListener('click', (e) => {
if (currentDuration > 0) {
seek(getPercent(e.clientX) * currentDuration);
}
});
}
export function updateUI(status) {
setLastStatus(status);
const fallbackTitle = status.state === 'idle' ? t('player.no_media') : t('player.title_unavailable');
dom.trackTitle.textContent = status.title || fallbackTitle;
dom.artist.textContent = status.artist || '';
dom.album.textContent = status.album || '';
dom.miniTrackTitle.textContent = status.title || fallbackTitle;
dom.miniArtist.textContent = status.artist || '';
const previousState = currentState;
setCurrentState(status.state);
updatePlaybackState(status.state);
const altText = status.title && status.artist
? `${status.artist} ${status.title}`
: status.title || t('player.no_media');
dom.albumArt.alt = altText;
dom.miniAlbumArt.alt = altText;
const artworkSource = status.album_art_url || null;
const artworkKey = `${status.title || ''}|${status.artist || ''}|${artworkSource || ''}`;
if (artworkKey !== lastArtworkKey) {
lastArtworkKey = artworkKey;
const placeholderArt = "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";
const placeholderGlow = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Crect fill='%23282828' width='300' height='300'/%3E%3C/svg%3E";
if (artworkSource) {
fetch(`/api/media/artwork?_=${Date.now()}`, {
headers: getAuthHeaders()
})
.then(r => r.ok ? r.blob() : null)
.then(blob => {
if (!blob) return;
const oldBlobUrl = currentArtworkBlobUrl;
const url = URL.createObjectURL(blob);
currentArtworkBlobUrl = url;
dom.albumArt.src = url;
dom.miniAlbumArt.src = url;
if (dom.albumArtGlow) dom.albumArtGlow.src = url;
if (oldBlobUrl) setTimeout(() => URL.revokeObjectURL(oldBlobUrl), 1000);
})
.catch(err => console.error('Artwork fetch failed:', err));
} else {
if (currentArtworkBlobUrl) {
URL.revokeObjectURL(currentArtworkBlobUrl);
currentArtworkBlobUrl = null;
}
dom.albumArt.src = placeholderArt;
dom.miniAlbumArt.src = placeholderArt;
if (dom.albumArtGlow) dom.albumArtGlow.src = placeholderGlow;
}
}
if (status.duration && status.position !== null) {
setCurrentDuration(status.duration);
setCurrentPosition(status.position);
lastPositionUpdate = Date.now();
lastPositionValue = status.position;
updateProgress(status.position, status.duration);
}
if (!isUserAdjustingVolume) {
dom.volumeSlider.value = status.volume;
dom.volumeDisplay.textContent = `${status.volume}%`;
dom.miniVolumeSlider.value = status.volume;
dom.miniVolumeDisplay.textContent = `${status.volume}%`;
}
updateMuteIcon(status.muted);
const src = resolveMediaSource(status.source);
dom.source.textContent = src ? src.name : t('player.unknown_source');
dom.sourceIcon.innerHTML = src?.icon || '';
const hasMedia = status.state !== 'idle';
dom.btnPlayPause.disabled = !hasMedia;
dom.btnNext.disabled = !hasMedia;
dom.btnPrevious.disabled = !hasMedia;
dom.miniBtnPlayPause.disabled = !hasMedia;
if (status.state === 'playing' && previousState !== 'playing') {
startPositionInterpolation();
} else if (status.state !== 'playing' && previousState === 'playing') {
stopPositionInterpolation();
}
}
export function updatePlaybackState(state) {
setCurrentPlayState(state);
switch(state) {
case 'playing':
dom.playbackState.textContent = t('state.playing');
dom.stateIcon.innerHTML = SVG_PLAY;
dom.playPauseIcon.innerHTML = SVG_PAUSE;
dom.miniPlayPauseIcon.innerHTML = SVG_PAUSE;
break;
case 'paused':
dom.playbackState.textContent = t('state.paused');
dom.stateIcon.innerHTML = SVG_PAUSE;
dom.playPauseIcon.innerHTML = SVG_PLAY;
dom.miniPlayPauseIcon.innerHTML = SVG_PLAY;
break;
case 'stopped':
dom.playbackState.textContent = t('state.stopped');
dom.stateIcon.innerHTML = SVG_STOP;
dom.playPauseIcon.innerHTML = SVG_PLAY;
dom.miniPlayPauseIcon.innerHTML = SVG_PLAY;
break;
default:
dom.playbackState.textContent = t('state.idle');
dom.stateIcon.innerHTML = SVG_IDLE;
dom.playPauseIcon.innerHTML = SVG_PLAY;
dom.miniPlayPauseIcon.innerHTML = SVG_PLAY;
}
updateVinylSpin();
}
function updateProgress(position, duration) {
const percent = (position / duration) * 100;
const widthStr = `${percent}%`;
const currentStr = formatTime(position);
const totalStr = formatTime(duration);
const posRound = Math.round(position);
const durRound = Math.round(duration);
dom.progressFill.style.width = widthStr;
dom.currentTime.textContent = currentStr;
dom.totalTime.textContent = totalStr;
dom.progressBar.dataset.duration = duration;
dom.progressBar.setAttribute('aria-valuenow', posRound);
dom.progressBar.setAttribute('aria-valuemax', durRound);
dom.miniProgressFill.style.width = widthStr;
dom.miniCurrentTime.textContent = currentStr;
dom.miniTotalTime.textContent = totalStr;
if (dom.miniPlayer) dom.miniPlayer.style.setProperty('--mini-progress', widthStr);
const miniBar = document.getElementById('mini-progress-bar');
miniBar.setAttribute('aria-valuenow', posRound);
miniBar.setAttribute('aria-valuemax', durRound);
}
export function startPositionInterpolation() {
if (interpolationInterval) {
clearInterval(interpolationInterval);
}
interpolationInterval = setInterval(() => {
if (currentState === 'playing' && currentDuration > 0 && lastPositionUpdate > 0) {
const elapsed = (Date.now() - lastPositionUpdate) / 1000;
const interpolatedPosition = Math.min(lastPositionValue + elapsed, currentDuration);
updateProgress(interpolatedPosition, currentDuration);
}
}, POSITION_INTERPOLATION_MS);
}
export function stopPositionInterpolation() {
if (interpolationInterval) {
clearInterval(interpolationInterval);
interpolationInterval = null;
}
}
function updateMuteIcon(muted) {
const path = muted ? SVG_MUTED : SVG_UNMUTED;
dom.muteIcon.innerHTML = path;
dom.miniMuteIcon.innerHTML = path;
}
+855
View File
@@ -0,0 +1,855 @@
// ============================================================
// Scripts: CRUD, quick access, execution dialog
// ============================================================
import {
t, showToast, escapeHtml, closeDialog, showConfirm,
resolveMdiIcons, fetchMdiIcon,
scripts, setScripts,
getAuthHeaders, hasCredentials,
} from './core.js';
import { IconSelect } from './icon-select.js';
import { paramTypeIcons } from './icons.js';
export let scriptFormDirty = false;
export function setScriptFormDirty(value) { scriptFormDirty = value; }
export async function loadScripts() {
try {
const response = await fetch('/api/scripts/list', {
headers: getAuthHeaders()
});
if (response.ok) {
setScripts(await response.json());
displayQuickAccess();
}
} catch (error) {
console.error('Error loading scripts:', error);
}
}
let _quickAccessGen = 0;
export async function displayQuickAccess() {
const gen = ++_quickAccessGen;
const grid = document.getElementById('scripts-grid');
const fragment = document.createDocumentFragment();
const hasScripts = scripts.length > 0;
let hasLinks = false;
scripts.forEach(script => {
const button = document.createElement('button');
button.className = 'script-btn';
button.onclick = () => executeScript(script.name, button);
if (script.icon) {
const iconEl = document.createElement('div');
iconEl.className = 'script-icon';
iconEl.setAttribute('data-mdi-icon', script.icon);
button.appendChild(iconEl);
}
const label = document.createElement('div');
label.className = 'script-label';
label.textContent = script.label || script.name;
button.appendChild(label);
if (script.description) {
const description = document.createElement('div');
description.className = 'script-description';
description.textContent = script.description;
button.appendChild(description);
}
fragment.appendChild(button);
});
try {
if (hasCredentials()) {
const response = await fetch('/api/links/list', {
headers: getAuthHeaders()
});
if (gen !== _quickAccessGen) return;
if (response.ok) {
const links = await response.json();
hasLinks = links.length > 0;
links.forEach(link => {
const card = document.createElement('a');
card.className = 'script-btn link-card';
card.href = link.url;
card.target = '_blank';
card.rel = 'noopener noreferrer';
if (link.icon) {
const iconEl = document.createElement('div');
iconEl.className = 'script-icon';
iconEl.setAttribute('data-mdi-icon', link.icon);
card.appendChild(iconEl);
}
const label = document.createElement('div');
label.className = 'script-label';
label.textContent = link.label || link.name;
card.appendChild(label);
if (link.description) {
const desc = document.createElement('div');
desc.className = 'script-description';
desc.textContent = link.description;
card.appendChild(desc);
}
fragment.appendChild(card);
});
}
}
} catch (e) {
if (gen !== _quickAccessGen) return;
console.warn('Failed to load links for quick access:', e);
}
if (!hasScripts && !hasLinks) {
const empty = document.createElement('div');
empty.className = 'scripts-empty empty-state-illustration';
empty.innerHTML = `<svg viewBox="0 0 64 64"><path d="M20 8l-8 48"/><path d="M44 8l8 48"/><path d="M10 24h44"/><path d="M8 40h44"/></svg><p>${t('quick_access.no_items')}</p>`;
fragment.prepend(empty);
}
grid.innerHTML = '';
grid.appendChild(fragment);
resolveMdiIcons(grid);
}
function _getScriptParams(scriptName) {
const script = scripts.find(s => s.name === scriptName);
return (script && script.parameters) ? script.parameters : {};
}
async function executeScript(scriptName, buttonElement) {
const paramDefs = _getScriptParams(scriptName);
if (Object.keys(paramDefs).length > 0) {
_showParamsInputDialog(scriptName, paramDefs, async (params) => {
buttonElement.classList.add('executing');
try {
await _doExecuteScript(scriptName, params);
} finally {
buttonElement.classList.remove('executing');
}
});
return;
}
buttonElement.classList.add('executing');
try {
await _doExecuteScript(scriptName, {});
} finally {
buttonElement.classList.remove('executing');
}
}
async function _doExecuteScript(scriptName, params) {
try {
const response = await fetch(`/api/scripts/execute/${scriptName}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
body: JSON.stringify({ params })
});
const result = await response.json();
if (response.ok && result.success) {
showToast(t('scripts.msg.executed', { name: scriptName }), 'success');
} else {
showToast(result.detail || t('scripts.msg.execute_failed', { name: scriptName }), 'error');
}
} catch (error) {
console.error(`Error executing script ${scriptName}:`, error);
showToast(t('scripts.msg.execute_error', { name: scriptName }), 'error');
}
}
// ============================================================
// Script Parameters Input Dialog (execution-time)
// ============================================================
let _paramsCallback = null;
let _paramsScriptName = null;
let _paramsIconSelects = null;
function _showParamsInputDialog(scriptName, paramDefs, callback) {
_paramsCallback = callback;
_paramsScriptName = scriptName;
const dialog = document.getElementById('scriptParamsDialog');
const title = document.getElementById('scriptParamsDialogTitle');
const container = document.getElementById('scriptParamsInputs');
const script = scripts.find(s => s.name === scriptName);
title.textContent = script ? (script.label || scriptName) : scriptName;
container.innerHTML = '';
// Track IconSelect instances for cleanup
if (!_paramsIconSelects) _paramsIconSelects = [];
for (const [pname, pdef] of Object.entries(paramDefs)) {
const wrapper = document.createElement('label');
const labelText = document.createElement('span');
labelText.textContent = pname + (pdef.required ? ' *' : '');
wrapper.appendChild(labelText);
if (pdef.description) {
const hint = document.createElement('small');
hint.className = 'param-hint';
hint.textContent = pdef.description;
wrapper.appendChild(hint);
}
let input;
if (pdef.type === 'select' && pdef.options) {
input = document.createElement('select');
if (!pdef.required) {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = '—';
input.appendChild(opt);
}
for (const optVal of pdef.options) {
const opt = document.createElement('option');
opt.value = optVal;
opt.textContent = optVal;
if (pdef.default !== undefined && pdef.default !== null && String(pdef.default) === optVal) {
opt.selected = true;
}
input.appendChild(opt);
}
input.dataset.paramName = pname;
input.dataset.paramType = pdef.type;
if (pdef.required) input.required = true;
wrapper.appendChild(input);
// Enhance with icon grid if few options
if (pdef.options.length <= 10) {
const selItems = pdef.options.map(o => ({ value: o, icon: '', label: o }));
const cols = Math.min(pdef.options.length, 4);
const isel = new IconSelect({ target: input, items: selItems, columns: cols });
_paramsIconSelects.push(isel);
}
} else if (pdef.type === 'boolean') {
const boolSvgTrue = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>';
const boolSvgFalse = '<svg viewBox="0 0 24 24" fill="currentColor"><path 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>';
input = document.createElement('select');
const optTrue = document.createElement('option');
optTrue.value = 'true';
optTrue.textContent = 'true';
const optFalse = document.createElement('option');
optFalse.value = 'false';
optFalse.textContent = 'false';
input.appendChild(optTrue);
input.appendChild(optFalse);
if (pdef.default !== undefined && pdef.default !== null) {
input.value = String(pdef.default);
}
input.dataset.paramName = pname;
input.dataset.paramType = pdef.type;
if (pdef.required) input.required = true;
wrapper.appendChild(input);
// Enhance with icon grid
const isel = new IconSelect({
target: input,
items: [
{ value: 'true', icon: boolSvgTrue, label: 'True' },
{ value: 'false', icon: boolSvgFalse, label: 'False' },
],
columns: 2,
});
_paramsIconSelects.push(isel);
} else if (pdef.type === 'integer' || pdef.type === 'float') {
input = document.createElement('input');
input.type = 'number';
if (pdef.type === 'float') input.step = 'any';
if (pdef.min !== undefined && pdef.min !== null) input.min = pdef.min;
if (pdef.max !== undefined && pdef.max !== null) input.max = pdef.max;
if (pdef.default !== undefined && pdef.default !== null) input.value = pdef.default;
input.dataset.paramName = pname;
input.dataset.paramType = pdef.type;
if (pdef.required) input.required = true;
wrapper.appendChild(input);
} else {
input = document.createElement('input');
input.type = 'text';
if (pdef.default !== undefined && pdef.default !== null) input.value = pdef.default;
input.dataset.paramName = pname;
input.dataset.paramType = pdef.type;
if (pdef.required) input.required = true;
wrapper.appendChild(input);
}
container.appendChild(wrapper);
}
document.body.classList.add('dialog-open');
dialog.showModal();
}
export function closeScriptParamsDialog() {
const dialog = document.getElementById('scriptParamsDialog');
_paramsCallback = null;
_paramsScriptName = null;
// Destroy icon selects from execution dialog
if (_paramsIconSelects) {
_paramsIconSelects.forEach(isel => isel.destroy());
_paramsIconSelects = null;
}
closeDialog(dialog);
document.body.classList.remove('dialog-open');
}
export async function submitScriptWithParams(event) {
event.preventDefault();
const container = document.getElementById('scriptParamsInputs');
const inputs = container.querySelectorAll('[data-param-name]');
const params = {};
for (const input of inputs) {
const name = input.dataset.paramName;
const type = input.dataset.paramType;
let val = input.value;
if (val === '' && !input.required) continue;
if (val === '') continue;
if (type === 'integer') val = parseInt(val, 10);
else if (type === 'float') val = parseFloat(val);
else if (type === 'boolean') val = val === 'true';
params[name] = val;
}
const callback = _paramsCallback;
closeScriptParamsDialog();
if (callback) {
await callback(params);
}
}
// ============================================================
// Script Management CRUD
// ============================================================
let _loadScriptsPromise = null;
export async function loadScriptsTable() {
if (_loadScriptsPromise) return _loadScriptsPromise;
_loadScriptsPromise = _loadScriptsTableImpl();
_loadScriptsPromise.finally(() => { _loadScriptsPromise = null; });
return _loadScriptsPromise;
}
async function _loadScriptsTableImpl() {
const tbody = document.getElementById('scriptsTableBody');
try {
const response = await fetch('/api/scripts/list', {
headers: getAuthHeaders()
});
if (!response.ok) {
throw new Error('Failed to fetch scripts');
}
const scriptsList = await response.json();
if (scriptsList.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="empty-state"><div class="empty-state-illustration"><svg viewBox="0 0 64 64"><rect x="8" y="4" width="48" height="56" rx="4"/><path d="M20 20h24M20 32h16M20 44h20"/></svg><p>' + t('scripts.empty') + '</p></div></td></tr>';
return;
}
tbody.innerHTML = scriptsList.map(script => `
<tr>
<td><span class="name-with-icon">${script.icon ? `<span class="table-icon" data-mdi-icon="${escapeHtml(script.icon)}"></span>` : ''}<code>${escapeHtml(script.name)}</code></span></td>
<td>${escapeHtml(script.label || script.name)}</td>
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"
title="${escapeHtml(script.command || 'N/A')}">${escapeHtml(script.command || 'N/A')}</td>
<td>${script.timeout}s</td>
<td>
<div class="action-buttons">
<button class="action-btn execute" data-action="execute" data-script-name="${escapeHtml(script.name)}" title="Execute script">
<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
</button>
<button class="action-btn" data-action="edit" data-script-name="${escapeHtml(script.name)}" title="Edit script">
<svg viewBox="0 0 24 24"><path 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="action-btn delete" data-action="delete" data-script-name="${escapeHtml(script.name)}" title="Delete script">
<svg viewBox="0 0 24 24"><path 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>
</div>
</td>
</tr>
`).join('');
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>';
}
}
export function showAddScriptDialog() {
const dialog = document.getElementById('scriptDialog');
const form = document.getElementById('scriptForm');
const title = document.getElementById('dialogTitle');
form.reset();
document.getElementById('scriptOriginalName').value = '';
document.getElementById('scriptIsEdit').value = 'false';
document.getElementById('scriptName').disabled = false;
document.getElementById('scriptIconPreview').innerHTML = '';
document.getElementById('scriptParamsContainer').innerHTML = '';
title.textContent = t('scripts.dialog.add');
scriptFormDirty = false;
document.body.classList.add('dialog-open');
dialog.showModal();
}
export async function showEditScriptDialog(scriptName) {
const dialog = document.getElementById('scriptDialog');
const title = document.getElementById('dialogTitle');
try {
const response = await fetch('/api/scripts/list', {
headers: getAuthHeaders()
});
if (!response.ok) {
throw new Error('Failed to fetch script details');
}
const scriptsList = await response.json();
const script = scriptsList.find(s => s.name === scriptName);
if (!script) {
showToast('Script not found', 'error');
return;
}
document.getElementById('scriptOriginalName').value = scriptName;
document.getElementById('scriptIsEdit').value = 'true';
document.getElementById('scriptName').value = scriptName;
document.getElementById('scriptName').disabled = true;
document.getElementById('scriptLabel').value = script.label || '';
document.getElementById('scriptCommand').value = script.command || '';
document.getElementById('scriptDescription').value = script.description || '';
document.getElementById('scriptIcon').value = script.icon || '';
document.getElementById('scriptTimeout').value = script.timeout || 30;
const preview = document.getElementById('scriptIconPreview');
if (script.icon) {
fetchMdiIcon(script.icon).then(svg => { preview.innerHTML = svg; });
} else {
preview.innerHTML = '';
}
// Populate parameters
const paramsContainer = document.getElementById('scriptParamsContainer');
paramsContainer.innerHTML = '';
if (script.parameters) {
for (const [pname, pdef] of Object.entries(script.parameters)) {
_addParameterRowWithData(pname, pdef);
}
}
title.textContent = t('scripts.dialog.edit');
scriptFormDirty = false;
document.body.classList.add('dialog-open');
dialog.showModal();
} catch (error) {
console.error('Error loading script for edit:', error);
showToast('Failed to load script details', 'error');
}
}
export async function closeScriptDialog() {
if (scriptFormDirty) {
if (!await showConfirm(t('scripts.confirm.unsaved'))) {
return;
}
}
const dialog = document.getElementById('scriptDialog');
scriptFormDirty = false;
closeDialog(dialog);
document.body.classList.remove('dialog-open');
}
export async function saveScript(event) {
event.preventDefault();
const submitBtn = event.target.querySelector('button[type="submit"]');
if (submitBtn) submitBtn.disabled = true;
const isEdit = document.getElementById('scriptIsEdit').value === 'true';
const scriptName = isEdit ?
document.getElementById('scriptOriginalName').value :
document.getElementById('scriptName').value;
const data = {
command: document.getElementById('scriptCommand').value,
label: document.getElementById('scriptLabel').value || null,
description: document.getElementById('scriptDescription').value || '',
icon: document.getElementById('scriptIcon').value || null,
timeout: parseInt(document.getElementById('scriptTimeout').value) || 30,
shell: true,
parameters: _collectParameterDefinitions(),
};
const endpoint = isEdit ?
`/api/scripts/update/${scriptName}` :
`/api/scripts/create/${scriptName}`;
const method = isEdit ? 'PUT' : 'POST';
try {
const response = await fetch(endpoint, {
method,
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
body: JSON.stringify(data)
});
const result = await response.json();
if (response.ok && result.success) {
showToast(`Script ${isEdit ? 'updated' : 'created'} successfully`, 'success');
scriptFormDirty = false;
closeScriptDialog();
} else {
showToast(result.detail || `Failed to ${isEdit ? 'update' : 'create'} script`, 'error');
}
} catch (error) {
console.error('Error saving script:', error);
showToast(`Error ${isEdit ? 'updating' : 'creating'} script`, 'error');
} finally {
if (submitBtn) submitBtn.disabled = false;
}
}
export async function deleteScriptConfirm(scriptName) {
if (!await showConfirm(t('scripts.confirm.delete').replace('{name}', scriptName))) {
return;
}
try {
const response = await fetch(`/api/scripts/delete/${scriptName}`, {
method: 'DELETE',
headers: getAuthHeaders()
});
const result = await response.json();
if (response.ok && result.success) {
showToast('Script deleted successfully', 'success');
} else {
showToast(result.detail || 'Failed to delete script', 'error');
}
} catch (error) {
console.error('Error deleting script:', error);
showToast('Error deleting script', 'error');
}
}
// ============================================================
// Execution Result Dialog (shared by scripts and callbacks)
// ============================================================
export function closeExecutionDialog() {
const dialog = document.getElementById('executionDialog');
closeDialog(dialog);
document.body.classList.remove('dialog-open');
}
function showExecutionResult(name, result, type = 'script') {
const dialog = document.getElementById('executionDialog');
const title = document.getElementById('executionDialogTitle');
const statusDiv = document.getElementById('executionStatus');
const outputSection = document.getElementById('outputSection');
const errorSection = document.getElementById('errorSection');
const outputPre = document.getElementById('executionOutput');
const errorPre = document.getElementById('executionError');
title.textContent = `Execution Result: ${name}`;
const success = result.success && result.exit_code === 0;
const statusClass = success ? 'success' : 'error';
const statusText = success ? 'Success' : 'Failed';
statusDiv.innerHTML = `
<div class="status-item ${statusClass}">
<label>Status</label>
<value>${statusText}</value>
</div>
<div class="status-item">
<label>Exit Code</label>
<value>${result.exit_code !== undefined ? result.exit_code : 'N/A'}</value>
</div>
<div class="status-item">
<label>Duration</label>
<value>${result.execution_time !== undefined && result.execution_time !== null ? result.execution_time.toFixed(3) + 's' : 'N/A'}</value>
</div>
`;
outputSection.style.display = 'block';
if (result.stdout && result.stdout.trim()) {
outputPre.textContent = result.stdout;
} else {
outputPre.textContent = '(no output)';
outputPre.style.fontStyle = 'italic';
outputPre.style.color = 'var(--text-secondary)';
}
if (result.stderr && result.stderr.trim()) {
errorSection.style.display = 'block';
errorPre.textContent = result.stderr;
errorPre.style.fontStyle = 'normal';
errorPre.style.color = 'var(--error)';
} else if (!success && result.error) {
errorSection.style.display = 'block';
errorPre.textContent = result.error;
errorPre.style.fontStyle = 'normal';
errorPre.style.color = 'var(--error)';
} else {
errorSection.style.display = 'none';
}
dialog.showModal();
}
export async function executeScriptDebug(scriptName) {
const paramDefs = _getScriptParams(scriptName);
if (Object.keys(paramDefs).length > 0) {
_showParamsInputDialog(scriptName, paramDefs, (params) => _executeScriptDebugWithParams(scriptName, params));
return;
}
await _executeScriptDebugWithParams(scriptName, {});
}
async function _executeScriptDebugWithParams(scriptName, params) {
const dialog = document.getElementById('executionDialog');
const title = document.getElementById('executionDialogTitle');
const statusDiv = document.getElementById('executionStatus');
title.textContent = `Executing: ${scriptName}`;
statusDiv.innerHTML = `
<div class="status-item">
<label>Status</label>
<value><span class="loading-spinner"></span> Running...</value>
</div>
`;
document.getElementById('outputSection').style.display = 'none';
document.getElementById('errorSection').style.display = 'none';
document.body.classList.add('dialog-open');
dialog.showModal();
try {
const response = await fetch(`/api/scripts/execute/${scriptName}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
body: JSON.stringify({ params })
});
const result = await response.json();
if (response.ok) {
showExecutionResult(scriptName, result, 'script');
} else {
showExecutionResult(scriptName, {
success: false,
exit_code: -1,
error: result.detail || 'Execution failed',
stderr: result.detail || 'Unknown error'
}, 'script');
}
} catch (error) {
console.error(`Error executing script ${scriptName}:`, error);
showExecutionResult(scriptName, {
success: false,
exit_code: -1,
error: error.message,
stderr: `Network error: ${error.message}`
}, 'script');
}
}
// ============================================================
// Parameter Definition Editor (CRUD dialog)
// ============================================================
const PARAM_TYPES = ['string', 'integer', 'float', 'boolean', 'select'];
export function addParameterRow() {
_addParameterRowWithData('', {});
scriptFormDirty = true;
}
const _paramTypeItems = PARAM_TYPES.map(pt => ({
value: pt,
icon: paramTypeIcons[pt] || '',
label: pt.charAt(0).toUpperCase() + pt.slice(1),
}));
function _addParameterRowWithData(name, def) {
const container = document.getElementById('scriptParamsContainer');
const row = document.createElement('div');
row.className = 'param-row';
row.innerHTML = `
<div class="param-row-header">
<input type="text" class="param-name" value="${escapeHtml(name)}"
placeholder="${t('scripts.params.name_placeholder')}" pattern="[a-zA-Z][a-zA-Z0-9_]*">
<select class="param-type">
${PARAM_TYPES.map(pt => `<option value="${pt}" ${def.type === pt ? 'selected' : ''}>${pt}</option>`).join('')}
</select>
<label class="param-required-label" title="${t('scripts.params.required')}">
<input type="checkbox" class="param-required" ${def.required ? 'checked' : ''}>
<span>*</span>
</label>
<button type="button" class="param-remove-btn" title="${t('scripts.params.remove')}">&times;</button>
</div>
<div class="param-row-details">
<input type="text" class="param-description" value="${escapeHtml(def.description || '')}"
placeholder="${t('scripts.params.description_placeholder')}">
<div class="param-row-extra">
<input type="text" class="param-default" value="${def.default !== undefined && def.default !== null ? escapeHtml(String(def.default)) : ''}"
placeholder="${t('scripts.params.default_placeholder')}">
<input type="text" class="param-min" value="${def.min !== undefined && def.min !== null ? def.min : ''}"
placeholder="Min" style="display:${def.type === 'integer' || def.type === 'float' ? '' : 'none'}">
<input type="text" class="param-max" value="${def.max !== undefined && def.max !== null ? def.max : ''}"
placeholder="Max" style="display:${def.type === 'integer' || def.type === 'float' ? '' : 'none'}">
<input type="text" class="param-options" value="${def.options ? def.options.join(', ') : ''}"
placeholder="${t('scripts.params.options_placeholder')}" style="display:${def.type === 'select' ? '' : 'none'}">
</div>
</div>
`;
// Enhance the type <select> with icon grid
const typeSelect = row.querySelector('.param-type');
const iconSelect = new IconSelect({
target: typeSelect,
items: _paramTypeItems,
columns: 5,
onChange: () => {
const isNumeric = typeSelect.value === 'integer' || typeSelect.value === 'float';
const isSelect = typeSelect.value === 'select';
row.querySelector('.param-min').style.display = isNumeric ? '' : 'none';
row.querySelector('.param-max').style.display = isNumeric ? '' : 'none';
row.querySelector('.param-options').style.display = isSelect ? '' : 'none';
scriptFormDirty = true;
},
});
row.querySelector('.param-remove-btn').addEventListener('click', () => {
iconSelect.destroy();
row.remove();
scriptFormDirty = true;
});
// Mark dirty on any input change
row.querySelectorAll('input').forEach(el => {
el.addEventListener('input', () => { scriptFormDirty = true; });
});
container.appendChild(row);
}
function _collectParameterDefinitions() {
const container = document.getElementById('scriptParamsContainer');
const rows = container.querySelectorAll('.param-row');
const params = {};
for (const row of rows) {
const name = row.querySelector('.param-name').value.trim();
if (!name) continue;
const type = row.querySelector('.param-type').value;
const def = { type };
const description = row.querySelector('.param-description').value.trim();
if (description) def.description = description;
if (row.querySelector('.param-required').checked) def.required = true;
const defaultVal = row.querySelector('.param-default').value.trim();
if (defaultVal !== '') {
if (type === 'integer') def.default = parseInt(defaultVal, 10);
else if (type === 'float') def.default = parseFloat(defaultVal);
else if (type === 'boolean') def.default = defaultVal.toLowerCase() === 'true';
else def.default = defaultVal;
}
if (type === 'integer' || type === 'float') {
const minVal = row.querySelector('.param-min').value.trim();
const maxVal = row.querySelector('.param-max').value.trim();
if (minVal !== '') def.min = parseFloat(minVal);
if (maxVal !== '') def.max = parseFloat(maxVal);
}
if (type === 'select') {
const optStr = row.querySelector('.param-options').value.trim();
if (optStr) def.options = optStr.split(',').map(s => s.trim()).filter(Boolean);
}
params[name] = def;
}
return params;
}
export async function executeCallbackDebug(callbackName) {
const dialog = document.getElementById('executionDialog');
const title = document.getElementById('executionDialogTitle');
const statusDiv = document.getElementById('executionStatus');
title.textContent = `Executing: ${callbackName}`;
statusDiv.innerHTML = `
<div class="status-item">
<label>Status</label>
<value><span class="loading-spinner"></span> Running...</value>
</div>
`;
document.getElementById('outputSection').style.display = 'none';
document.getElementById('errorSection').style.display = 'none';
document.body.classList.add('dialog-open');
dialog.showModal();
try {
const response = await fetch(`/api/callbacks/execute/${callbackName}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }
});
const result = await response.json();
if (response.ok) {
showExecutionResult(callbackName, result, 'callback');
} else {
showExecutionResult(callbackName, {
success: false,
exit_code: -1,
error: result.detail || 'Execution failed',
stderr: result.detail || 'Unknown error'
}, 'callback');
}
} catch (error) {
console.error(`Error executing callback ${callbackName}:`, error);
showExecutionResult(callbackName, {
success: false,
exit_code: -1,
error: error.message,
stderr: `Network error: ${error.message}`
}, 'callback');
}
}
+184
View File
@@ -0,0 +1,184 @@
// ============================================================
// WebSocket: Connection, reconnection, authentication
// ============================================================
import {
dom, t, showToast, setWs,
WS_BACKOFF_BASE_MS, WS_BACKOFF_MAX_MS,
WS_MAX_RECONNECT_ATTEMPTS, WS_PING_INTERVAL_MS,
authRequired, showUpdateBanner,
} from './core.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;
export function showAuthForm(errorMessage = '') {
const overlay = document.getElementById('auth-overlay');
overlay.classList.remove('hidden');
const errorEl = document.getElementById('auth-error');
if (errorMessage) {
errorEl.textContent = errorMessage;
errorEl.classList.add('visible');
} else {
errorEl.classList.remove('visible');
}
}
function hideAuthForm() {
document.getElementById('auth-overlay').classList.add('hidden');
}
export function authenticate() {
const token = document.getElementById('token-input').value.trim();
if (!token) {
showAuthForm(t('auth.required'));
return;
}
localStorage.setItem('media_server_token', token);
connectWebSocket(token);
}
export function clearToken() {
localStorage.removeItem('media_server_token');
// Access ws via import
import('./core.js').then(core => {
if (core.ws) {
core.ws.close();
}
});
showAuthForm(t('auth.cleared'));
}
export function connectWebSocket(token) {
if (pingInterval) {
clearInterval(pingInterval);
pingInterval = null;
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsBase = `${protocol}//${window.location.host}/api/media/ws`;
const wsUrl = token ? `${wsBase}?token=${encodeURIComponent(token)}` : wsBase;
const newWs = new WebSocket(wsUrl);
setWs(newWs);
newWs.onopen = () => {
console.log('WebSocket connected');
wsReconnectAttempts = 0;
updateConnectionStatus(true);
hideConnectionBanner();
hideAuthForm();
loadScripts();
loadScriptsTable();
loadCallbacksTable();
loadLinksTable();
loadHeaderLinks();
loadAudioDevices();
};
newWs.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'status' || msg.type === 'status_update') {
updateUI(msg.data);
} else if (msg.type === 'scripts_changed') {
console.log('Scripts changed, reloading...');
loadScripts();
loadScriptsTable();
} else if (msg.type === 'links_changed') {
console.log('Links changed, reloading...');
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') {
console.error('WebSocket error:', msg.message);
}
};
newWs.onerror = (error) => {
console.error('WebSocket error:', error);
updateConnectionStatus(false);
};
newWs.onclose = (event) => {
console.log('WebSocket closed:', event.code);
updateConnectionStatus(false);
stopPositionInterpolation();
if (event.code === 4001) {
localStorage.removeItem('media_server_token');
showAuthForm(t('auth.invalid'));
} else if (event.code !== 1000) {
wsReconnectAttempts++;
if (wsReconnectAttempts <= WS_MAX_RECONNECT_ATTEMPTS) {
const delay = Math.min(
WS_BACKOFF_BASE_MS * Math.pow(1.5, wsReconnectAttempts - 1),
WS_BACKOFF_MAX_MS
);
console.log(`Reconnecting in ${Math.round(delay / 1000)}s (attempt ${wsReconnectAttempts}/${WS_MAX_RECONNECT_ATTEMPTS})...`);
if (wsReconnectAttempts >= 3) {
showConnectionBanner(t('connection.reconnecting').replace('{attempt}', wsReconnectAttempts), false);
}
reconnectTimeout = setTimeout(() => {
const savedToken = localStorage.getItem('media_server_token');
if (savedToken || !authRequired) {
connectWebSocket(savedToken || '');
}
}, delay);
} else {
showConnectionBanner(t('connection.lost'), true);
}
}
};
pingInterval = setInterval(() => {
if (newWs && newWs.readyState === WebSocket.OPEN) {
newWs.send(JSON.stringify({ type: 'ping' }));
}
}, WS_PING_INTERVAL_MS);
}
export function updateConnectionStatus(connected) {
if (connected) {
dom.statusDot.classList.add('connected');
} else {
dom.statusDot.classList.remove('connected');
}
}
function showConnectionBanner(message, showButton) {
const banner = document.getElementById('connectionBanner');
const text = document.getElementById('connectionBannerText');
const btn = document.getElementById('connectionBannerBtn');
text.textContent = message;
btn.style.display = showButton ? '' : 'none';
banner.classList.remove('hidden');
}
function hideConnectionBanner() {
const banner = document.getElementById('connectionBanner');
banner.classList.add('hidden');
}
export function manualReconnect() {
const savedToken = localStorage.getItem('media_server_token');
if (savedToken || !authRequired) {
wsReconnectAttempts = 0;
hideConnectionBanner();
connectWebSocket(savedToken || '');
}
}
+49 -2
View File
@@ -23,6 +23,8 @@
"player.source": "Source:",
"player.unknown_source": "Unknown",
"player.vinyl": "Vinyl mode",
"player.visualizer": "Audio visualizer",
"player.background": "Dynamic background",
"state.playing": "Playing",
"state.paused": "Paused",
"state.stopped": "Stopped",
@@ -72,6 +74,15 @@
"scripts.execution.error_output": "Error Output",
"scripts.execution.close": "Close",
"scripts.confirm.unsaved": "You have unsaved changes. Are you sure you want to discard them?",
"scripts.field.parameters": "Parameters",
"scripts.params.add": "+ Add",
"scripts.params.remove": "Remove parameter",
"scripts.params.required": "Required",
"scripts.params.name_placeholder": "param_name",
"scripts.params.description_placeholder": "Parameter description",
"scripts.params.default_placeholder": "Default",
"scripts.params.options_placeholder": "option1, option2, ...",
"scripts.params.execute": "Execute",
"callbacks.management": "Callback Management",
"callbacks.description": "Callbacks are scripts triggered automatically by media control events (play, pause, stop, etc.)",
"callbacks.add": "Add",
@@ -123,6 +134,15 @@
"settings.section.scripts": "Scripts",
"settings.section.callbacks": "Callbacks",
"settings.section.links": "Links",
"settings.section.audio": "Audio",
"settings.audio.description": "Select which audio output device to capture for the visualizer.",
"settings.audio.device": "Loopback Device",
"settings.audio.auto": "Auto-detect",
"settings.audio.status_active": "Capturing audio",
"settings.audio.status_available": "Available, not capturing",
"settings.audio.status_unavailable": "Unavailable",
"settings.audio.device_changed": "Audio device changed",
"settings.audio.device_change_failed": "Failed to change audio device",
"quick_access.no_items": "No quick actions or links configured",
"display.loading": "Loading monitors...",
"display.error": "Failed to load monitors",
@@ -153,7 +173,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 *",
@@ -165,6 +205,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.",
@@ -204,5 +249,7 @@
"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"
"footer.source_code": "Source Code",
"update.available": "Update available: v{version}",
"update.view_release": "View Release"
}
+49 -2
View File
@@ -23,6 +23,8 @@
"player.source": "Источник:",
"player.unknown_source": "Неизвестно",
"player.vinyl": "Режим винила",
"player.visualizer": "Аудио визуализатор",
"player.background": "Динамический фон",
"state.playing": "Воспроизведение",
"state.paused": "Пауза",
"state.stopped": "Остановлено",
@@ -72,6 +74,15 @@
"scripts.execution.error_output": "Вывод ошибок",
"scripts.execution.close": "Закрыть",
"scripts.confirm.unsaved": "У вас есть несохраненные изменения. Вы уверены, что хотите отменить их?",
"scripts.field.parameters": "Параметры",
"scripts.params.add": "+ Добавить",
"scripts.params.remove": "Удалить параметр",
"scripts.params.required": "Обязательный",
"scripts.params.name_placeholder": "имя_параметра",
"scripts.params.description_placeholder": "Описание параметра",
"scripts.params.default_placeholder": "По умолчанию",
"scripts.params.options_placeholder": "вариант1, вариант2, ...",
"scripts.params.execute": "Выполнить",
"callbacks.management": "Управление Обратными Вызовами",
"callbacks.description": "Обратные вызовы - это скрипты, автоматически запускаемые при событиях управления медиа (воспроизведение, пауза, остановка и т.д.)",
"callbacks.add": "Добавить",
@@ -123,6 +134,15 @@
"settings.section.scripts": "Скрипты",
"settings.section.callbacks": "Колбэки",
"settings.section.links": "Ссылки",
"settings.section.audio": "Аудио",
"settings.audio.description": "Выберите аудиоустройство для захвата звука визуализатора.",
"settings.audio.device": "Устройство захвата",
"settings.audio.auto": "Автоопределение",
"settings.audio.status_active": "Захват аудио",
"settings.audio.status_available": "Доступно, не захватывает",
"settings.audio.status_unavailable": "Недоступно",
"settings.audio.device_changed": "Аудиоустройство изменено",
"settings.audio.device_change_failed": "Не удалось изменить аудиоустройство",
"quick_access.no_items": "Быстрые действия и ссылки не настроены",
"display.loading": "Загрузка мониторов...",
"display.error": "Не удалось загрузить мониторы",
@@ -153,7 +173,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 папки *",
@@ -165,6 +205,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": "Соединение потеряно. Сервер может быть недоступен.",
@@ -204,5 +249,7 @@
"links.confirm.delete": "Вы уверены, что хотите удалить ссылку \"{name}\"?",
"links.confirm.unsaved": "У вас есть несохраненные изменения. Вы уверены, что хотите отменить их?",
"footer.created_by": "Создано",
"footer.source_code": "Исходный код"
"footer.source_code": "Исходный код",
"update.available": "Доступно обновление: v{version}",
"update.view_release": "Перейти к релизу"
}
+23
View File
@@ -0,0 +1,23 @@
{
"name": "Media Server",
"short_name": "Media",
"description": "Remote media player control and file browser",
"start_url": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#121212",
"theme_color": "#121212",
"icons": [
{
"src": "/static/icons/icon.svg",
"sizes": "any",
"type": "image/svg+xml"
},
{
"src": "/static/icons/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "maskable"
}
]
}
+15
View File
@@ -0,0 +1,15 @@
// Minimal service worker for PWA installability.
// This app requires a live WebSocket connection, so offline caching is not useful.
// All fetch requests are passed through to the network.
self.addEventListener('install', () => {
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim());
});
self.addEventListener('fetch', (event) => {
event.respondWith(fetch(event.request));
});
+147
View File
@@ -0,0 +1,147 @@
"""System tray icon for Media Server."""
import ctypes
import io
import logging
import webbrowser
from pathlib import Path
from typing import Callable
from PIL import Image, ImageDraw
logger = logging.getLogger(__name__)
# pystray is optional — tray silently disabled when missing
try:
import pystray
PYSTRAY_AVAILABLE = True
except ImportError:
pystray = None
PYSTRAY_AVAILABLE = False
# Windows-native confirmation (no tkinter needed)
_MB_YESNO = 0x04
_MB_ICONQUESTION = 0x20
_MB_TOPMOST = 0x40000
_MB_SETFOREGROUND = 0x10000
_IDYES = 6
def _confirm(title: str, message: str) -> bool:
"""Show a Yes/No dialog using native Windows MessageBox."""
result = ctypes.windll.user32.MessageBoxW(
0, message, title, _MB_YESNO | _MB_ICONQUESTION | _MB_TOPMOST | _MB_SETFOREGROUND
)
return result == _IDYES
def _create_icon_image(size: int = 64) -> Image.Image:
"""Create a tray icon: green circle with white play triangle."""
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
# Green circle background
padding = 2
draw.ellipse(
[padding, padding, size - padding, size - padding],
fill=(29, 185, 84, 255),
)
# White play triangle
cx, cy = size // 2, size // 2
r = size * 0.28
triangle = [
(cx - r * 0.6, cy - r),
(cx - r * 0.6, cy + r),
(cx + r * 0.9, cy),
]
draw.polygon(triangle, fill=(255, 255, 255, 255))
return img
def _load_icon_image() -> Image.Image:
"""Load the ICO/SVG app icon, falling back to a generated image."""
icons_dir = Path(__file__).parent / "static" / "icons"
# Try .ico first (best for Windows tray)
ico_path = icons_dir / "icon.ico"
if ico_path.exists():
try:
return Image.open(ico_path)
except Exception:
pass
# Try SVG via cairosvg
try:
import cairosvg
svg_path = icons_dir / "icon.svg"
if svg_path.exists():
png_data = cairosvg.svg2png(url=str(svg_path), output_width=64, output_height=64)
return Image.open(io.BytesIO(png_data))
except Exception:
pass
return _create_icon_image()
class TrayManager:
"""Manages the system tray icon and its context menu.
Call ``run()`` on the **main thread** it blocks until ``stop()``
is called (from any thread) or the user picks *Shutdown* from the menu.
"""
def __init__(self, port: int, on_exit: Callable[[], None]) -> None:
if not PYSTRAY_AVAILABLE:
raise ImportError("pystray is required for system tray support")
self._port = port
self._on_exit = on_exit
menu = pystray.Menu(
pystray.MenuItem("Show UI", self._show_ui, default=True),
pystray.Menu.SEPARATOR,
pystray.MenuItem("Restart", self._restart),
pystray.MenuItem("Shutdown", self._shutdown),
)
self._icon = pystray.Icon(
name="media-server",
icon=_load_icon_image(),
title="Media Server",
menu=menu,
)
def _show_ui(self, icon: "pystray.Icon", item: "pystray.MenuItem") -> None:
webbrowser.open(f"http://localhost:{self._port}")
def _restart(self, icon: "pystray.Icon", item: "pystray.MenuItem") -> None:
if not _confirm("Media Server", "Restart the server?"):
return
logger.info("Restart requested from tray")
self._restart_requested = True
self._on_exit()
self._icon.stop()
@property
def restart_requested(self) -> bool:
return getattr(self, "_restart_requested", False)
def _shutdown(self, icon: "pystray.Icon", item: "pystray.MenuItem") -> None:
if not _confirm("Media Server", "Shut down the server?"):
return
logger.info("Shutdown requested from tray")
self._on_exit()
self._icon.stop()
def run(self) -> None:
"""Block the calling thread running the tray message loop."""
self._icon.run()
def stop(self) -> None:
"""Stop the tray icon from any thread."""
self._icon.stop()
+690
View File
@@ -0,0 +1,690 @@
{
"name": "media-server-frontend",
"version": "0.1.8",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "media-server-frontend",
"version": "0.1.8",
"devDependencies": {
"esbuild": "^0.27.4"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
"cpu": [
"loong64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
"cpu": [
"mips64el"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
"cpu": [
"riscv64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
"cpu": [
"s390x"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/esbuild": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
"dev": true,
"hasInstallScript": true,
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.4",
"@esbuild/android-arm": "0.27.4",
"@esbuild/android-arm64": "0.27.4",
"@esbuild/android-x64": "0.27.4",
"@esbuild/darwin-arm64": "0.27.4",
"@esbuild/darwin-x64": "0.27.4",
"@esbuild/freebsd-arm64": "0.27.4",
"@esbuild/freebsd-x64": "0.27.4",
"@esbuild/linux-arm": "0.27.4",
"@esbuild/linux-arm64": "0.27.4",
"@esbuild/linux-ia32": "0.27.4",
"@esbuild/linux-loong64": "0.27.4",
"@esbuild/linux-mips64el": "0.27.4",
"@esbuild/linux-ppc64": "0.27.4",
"@esbuild/linux-riscv64": "0.27.4",
"@esbuild/linux-s390x": "0.27.4",
"@esbuild/linux-x64": "0.27.4",
"@esbuild/netbsd-arm64": "0.27.4",
"@esbuild/netbsd-x64": "0.27.4",
"@esbuild/openbsd-arm64": "0.27.4",
"@esbuild/openbsd-x64": "0.27.4",
"@esbuild/openharmony-arm64": "0.27.4",
"@esbuild/sunos-x64": "0.27.4",
"@esbuild/win32-arm64": "0.27.4",
"@esbuild/win32-ia32": "0.27.4",
"@esbuild/win32-x64": "0.27.4"
}
}
},
"dependencies": {
"@esbuild/aix-ppc64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
"dev": true,
"optional": true
},
"@esbuild/android-arm": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
"dev": true,
"optional": true
},
"@esbuild/android-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
"dev": true,
"optional": true
},
"@esbuild/android-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
"dev": true,
"optional": true
},
"@esbuild/darwin-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
"dev": true,
"optional": true
},
"@esbuild/darwin-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
"dev": true,
"optional": true
},
"@esbuild/freebsd-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
"dev": true,
"optional": true
},
"@esbuild/freebsd-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
"dev": true,
"optional": true
},
"@esbuild/linux-arm": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
"dev": true,
"optional": true
},
"@esbuild/linux-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
"dev": true,
"optional": true
},
"@esbuild/linux-ia32": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
"dev": true,
"optional": true
},
"@esbuild/linux-loong64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
"dev": true,
"optional": true
},
"@esbuild/linux-mips64el": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
"dev": true,
"optional": true
},
"@esbuild/linux-ppc64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
"dev": true,
"optional": true
},
"@esbuild/linux-riscv64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
"dev": true,
"optional": true
},
"@esbuild/linux-s390x": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
"dev": true,
"optional": true
},
"@esbuild/linux-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
"dev": true,
"optional": true
},
"@esbuild/netbsd-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
"dev": true,
"optional": true
},
"@esbuild/netbsd-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
"dev": true,
"optional": true
},
"@esbuild/openbsd-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
"dev": true,
"optional": true
},
"@esbuild/openbsd-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
"dev": true,
"optional": true
},
"@esbuild/openharmony-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
"dev": true,
"optional": true
},
"@esbuild/sunos-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
"dev": true,
"optional": true
},
"@esbuild/win32-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
"dev": true,
"optional": true
},
"@esbuild/win32-ia32": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
"dev": true,
"optional": true
},
"@esbuild/win32-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
"dev": true,
"optional": true
},
"esbuild": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
"dev": true,
"requires": {
"@esbuild/aix-ppc64": "0.27.4",
"@esbuild/android-arm": "0.27.4",
"@esbuild/android-arm64": "0.27.4",
"@esbuild/android-x64": "0.27.4",
"@esbuild/darwin-arm64": "0.27.4",
"@esbuild/darwin-x64": "0.27.4",
"@esbuild/freebsd-arm64": "0.27.4",
"@esbuild/freebsd-x64": "0.27.4",
"@esbuild/linux-arm": "0.27.4",
"@esbuild/linux-arm64": "0.27.4",
"@esbuild/linux-ia32": "0.27.4",
"@esbuild/linux-loong64": "0.27.4",
"@esbuild/linux-mips64el": "0.27.4",
"@esbuild/linux-ppc64": "0.27.4",
"@esbuild/linux-riscv64": "0.27.4",
"@esbuild/linux-s390x": "0.27.4",
"@esbuild/linux-x64": "0.27.4",
"@esbuild/netbsd-arm64": "0.27.4",
"@esbuild/netbsd-x64": "0.27.4",
"@esbuild/openbsd-arm64": "0.27.4",
"@esbuild/openbsd-x64": "0.27.4",
"@esbuild/openharmony-arm64": "0.27.4",
"@esbuild/sunos-x64": "0.27.4",
"@esbuild/win32-arm64": "0.27.4",
"@esbuild/win32-ia32": "0.27.4",
"@esbuild/win32-x64": "0.27.4"
}
}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "media-server-frontend",
"version": "0.1.8",
"private": true,
"description": "Frontend build tooling for media server WebUI",
"scripts": {
"build": "node esbuild.mjs",
"watch": "node esbuild.mjs --watch"
},
"devDependencies": {
"esbuild": "^0.27.4"
}
}
+21 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "media-server"
version = "1.0.0"
version = "0.1.8"
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,12 +43,15 @@ 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",
]
dev = [
"pytest>=7.0",
"pytest-asyncio>=0.21",
"httpx>=0.24",
"ruff>=0.4.0",
]
[project.urls]
@@ -63,3 +68,18 @@ build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
include = ["media_server*"]
[tool.ruff]
target-version = "py310"
line-length = 120
[tool.ruff.lint]
select = ["E", "F", "I", "W"]
[tool.ruff.lint.per-file-ignores]
# AppleScript string literals contain long lines that cannot be broken
"media_server/services/macos_media.py" = ["E501"]
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
+10 -4
View File
@@ -1,7 +1,13 @@
Set fso = CreateObject("Scripting.FileSystemObject")
Set WshShell = CreateObject("WScript.Shell")
' Get the directory of this script (scripts\), then go up to media-server root
scriptDir = CreateObject("Scripting.FileSystemObject").GetParentFolderName(WScript.ScriptFullName)
serverRoot = CreateObject("Scripting.FileSystemObject").GetParentFolderName(scriptDir)
scriptDir = fso.GetParentFolderName(WScript.ScriptFullName)
serverRoot = fso.GetParentFolderName(scriptDir)
WshShell.CurrentDirectory = serverRoot
' Run python completely hidden (0 = hidden, False = don't wait)
WshShell.Run "python -m media_server.main", 0, False
' Use embedded Python if present (installed distribution), otherwise system Python
embeddedPython = serverRoot & "\python\python.exe"
If fso.FileExists(embeddedPython) Then
WshShell.Run """" & embeddedPython & """ -m media_server.main", 0, False
Else
WshShell.Run "python -m media_server.main", 0, False
End If
-7
View File
@@ -1,7 +0,0 @@
Set WshShell = CreateObject("WScript.Shell")
Set FSO = CreateObject("Scripting.FileSystemObject")
' Get parent folder of scripts folder (media-server root)
WshShell.CurrentDirectory = FSO.GetParentFolderName(FSO.GetParentFolderName(WScript.ScriptFullName))
WshShell.Run "python -m media_server.main", 0, False
Set FSO = Nothing
Set WshShell = Nothing
-15
View File
@@ -1,15 +0,0 @@
@echo off
REM Media Server Startup Script
REM This script starts the media server
echo Starting Media Server...
echo.
REM Change to the media-server directory (parent of scripts folder)
cd /d "%~dp0\.."
REM Start the media server
python -m media_server.main
REM If the server exits, pause to show any error messages
pause
-19
View File
@@ -1,19 +0,0 @@
@echo off
REM Media Server Stop Script
REM This script stops the running media server
echo Stopping Media Server...
echo.
REM Find and kill Python processes running media_server.main
for /f "tokens=2" %%i in ('tasklist /FI "IMAGENAME eq python.exe" /FO LIST ^| findstr /B "PID:"') do (
wmic process where "ProcessId=%%i" get CommandLine 2>nul | findstr /C:"media_server.main" >nul
if not errorlevel 1 (
taskkill /PID %%i /F
echo Media server process (PID %%i) terminated.
)
)
echo.
echo Done! Media server stopped.
pause