Compare commits
71 Commits
1410a8d2cb
...
v0.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 46af2bb8cc | |||
| 25a492d5dd | |||
| f4be2bdb89 | |||
| 51ec1503f4 | |||
| 08c3c80df4 | |||
| 62eeca1b9e | |||
| 4c93bfb8c1 | |||
| 59840a1190 | |||
| 2a474ea52c | |||
| f85ce77f14 | |||
| b09569f390 | |||
| f2c82164e8 | |||
| 588a303c44 | |||
| 2049850180 | |||
| 9b84fdd0e5 | |||
| 3de2b4496e | |||
| d7f488ac70 | |||
| 968eb156bc | |||
| a0f74dfc39 | |||
| 6066b4a2c5 | |||
| 153424eff8 | |||
| 336d596b66 | |||
| d937c1590c | |||
| d157388a94 | |||
| e9e4165927 | |||
| 77b39e5684 | |||
| d9d4672ca3 | |||
| 265b001b99 | |||
| 14e9f2294e | |||
| 8110c152b0 | |||
| 21adeb1070 | |||
| 68614c982d | |||
| a2a258e898 | |||
| 456eb3a881 | |||
| c586b1b518 | |||
| ee5184920d | |||
| af556e0bff | |||
| 26b4672a99 | |||
| 2e3bebfeb8 | |||
| 34eb7c7b19 | |||
| 972ee54b91 | |||
| d09a0b90e4 | |||
| c3cb7a4da9 | |||
| e3889fef29 | |||
| 84500401e7 | |||
| 28293c6340 | |||
| 39b3aed5f3 | |||
| ba90dffa18 | |||
| 69df9b6b95 | |||
| 760c3df90c | |||
| 60f287bb40 | |||
| f52af51a20 | |||
| f2d569a1b0 | |||
| db777fa64b | |||
| 2961f8eaec | |||
| c50a8f472c | |||
| cad6e8a1fe | |||
| c9ee41ad35 | |||
| 0256be816e | |||
| 5219263388 | |||
| 98163ea5a9 | |||
| 5e5e5036c0 | |||
| 4f9e99e10b | |||
| 81d5b0a402 | |||
| d67e61ae39 | |||
| e795d224a8 | |||
| d0830cbbe5 | |||
| 4ef11c8f00 | |||
| fb56e6cdc0 | |||
| ff6712620e | |||
| 795a15cb8b |
@@ -0,0 +1,16 @@
|
||||
# Normalise text files to LF in the repo so Windows checkouts stop
|
||||
# nagging "LF will be replaced by CRLF" on every git status.
|
||||
* text=auto eol=lf
|
||||
|
||||
# Binary assets — never touch.
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.svg text
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.exe binary
|
||||
*.dll binary
|
||||
*.zip binary
|
||||
@@ -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
|
||||
@@ -13,10 +13,16 @@ jobs:
|
||||
release_id: ${{ steps.create.outputs.release_id }}
|
||||
version: ${{ steps.create.outputs.version }}
|
||||
steps:
|
||||
- name: Fetch RELEASE_NOTES.md only
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: RELEASE_NOTES.md
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: Create Gitea release
|
||||
id: create
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
|
||||
run: |
|
||||
TAG="${{ gitea.ref_name }}"
|
||||
VERSION="${TAG#v}"
|
||||
@@ -27,21 +33,40 @@ jobs:
|
||||
IS_PRE="true"
|
||||
fi
|
||||
|
||||
if [ -f RELEASE_NOTES.md ]; then
|
||||
export RELEASE_NOTES=$(cat RELEASE_NOTES.md)
|
||||
echo "Found RELEASE_NOTES.md"
|
||||
else
|
||||
export RELEASE_NOTES=""
|
||||
echo "No RELEASE_NOTES.md found"
|
||||
fi
|
||||
|
||||
BODY_JSON=$(python3 -c "
|
||||
import json, textwrap
|
||||
import json, os, textwrap
|
||||
|
||||
tag = '$TAG'
|
||||
body = f'''## Downloads
|
||||
release_notes = os.environ.get('RELEASE_NOTES', '')
|
||||
|
||||
sections = []
|
||||
|
||||
if release_notes.strip():
|
||||
sections.append(release_notes.strip())
|
||||
|
||||
sections.append(textwrap.dedent(f'''
|
||||
## Downloads
|
||||
|
||||
| Platform | File |
|
||||
|----------|------|
|
||||
| Windows (installer) | \`MediaServer-{tag}-setup.exe\` |
|
||||
| Windows (portable) | \`MediaServer-{tag}-win-x64.zip\` |
|
||||
| Linux | \`MediaServer-{tag}-linux-x64.tar.gz\` |
|
||||
'''
|
||||
print(json.dumps(textwrap.dedent(body).strip()))
|
||||
''').strip())
|
||||
|
||||
print(json.dumps('\n\n'.join(sections)))
|
||||
")
|
||||
|
||||
RELEASE=$(curl -s -X POST "$BASE_URL/releases" \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Authorization: token $DEPLOY_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"tag_name\": \"$TAG\",
|
||||
@@ -62,9 +87,9 @@ jobs:
|
||||
print(json.dumps(data, indent=2), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
" 2>&1) || {
|
||||
echo "Create failed, fetching existing release for tag $TAG..."
|
||||
echo "::warning::Release already exists for tag $TAG — reusing existing release"
|
||||
RELEASE=$(curl -s "$BASE_URL/releases/tags/$TAG" \
|
||||
-H "Authorization: token $GITEA_TOKEN")
|
||||
-H "Authorization: token $DEPLOY_TOKEN")
|
||||
RELEASE_ID=$(echo "$RELEASE" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
||||
}
|
||||
echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT"
|
||||
@@ -103,19 +128,45 @@ jobs:
|
||||
|
||||
- name: Upload assets to release
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
|
||||
run: |
|
||||
RELEASE_ID="${{ needs.create-release.outputs.release_id }}"
|
||||
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
|
||||
|
||||
upload_asset() {
|
||||
local file="$1"
|
||||
local name
|
||||
name=$(basename "$file")
|
||||
|
||||
# Delete existing asset with the same name (idempotent re-runs)
|
||||
EXISTING=$(curl -s "$BASE_URL/releases/$RELEASE_ID/assets" \
|
||||
-H "Authorization: token $DEPLOY_TOKEN")
|
||||
ASSET_ID=$(echo "$EXISTING" | python3 -c "
|
||||
import sys, json
|
||||
assets = json.load(sys.stdin)
|
||||
for a in assets:
|
||||
if a['name'] == '$name':
|
||||
print(a['id'])
|
||||
break
|
||||
" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$ASSET_ID" ]; then
|
||||
echo "Replacing existing asset: $name (id=$ASSET_ID)"
|
||||
curl -s -X DELETE "$BASE_URL/releases/$RELEASE_ID/assets/$ASSET_ID" \
|
||||
-H "Authorization: token $DEPLOY_TOKEN"
|
||||
fi
|
||||
|
||||
echo "Uploading $name..."
|
||||
curl -s -X POST \
|
||||
"$BASE_URL/releases/$RELEASE_ID/assets?name=$name" \
|
||||
-H "Authorization: token $DEPLOY_TOKEN" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@$file"
|
||||
}
|
||||
|
||||
for FILE in build/MediaServer-*.zip build/MediaServer-*-setup.exe; do
|
||||
[ -f "$FILE" ] || continue
|
||||
echo "Uploading $(basename "$FILE")..."
|
||||
curl -s -X POST \
|
||||
"$BASE_URL/releases/$RELEASE_ID/assets?name=$(basename "$FILE")" \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@$FILE"
|
||||
upload_asset "$FILE"
|
||||
done
|
||||
|
||||
# --- Build Linux tarball ---
|
||||
@@ -143,15 +194,35 @@ jobs:
|
||||
|
||||
- name: Upload assets to release
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
|
||||
run: |
|
||||
RELEASE_ID="${{ needs.create-release.outputs.release_id }}"
|
||||
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
|
||||
|
||||
FILE=$(ls build/MediaServer-*-linux-x64.tar.gz | head -1)
|
||||
echo "Uploading $(basename "$FILE")..."
|
||||
NAME=$(basename "$FILE")
|
||||
|
||||
# Delete existing asset with the same name (idempotent re-runs)
|
||||
EXISTING=$(curl -s "$BASE_URL/releases/$RELEASE_ID/assets" \
|
||||
-H "Authorization: token $DEPLOY_TOKEN")
|
||||
ASSET_ID=$(echo "$EXISTING" | python3 -c "
|
||||
import sys, json
|
||||
assets = json.load(sys.stdin)
|
||||
for a in assets:
|
||||
if a['name'] == '$NAME':
|
||||
print(a['id'])
|
||||
break
|
||||
" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$ASSET_ID" ]; then
|
||||
echo "Replacing existing asset: $NAME (id=$ASSET_ID)"
|
||||
curl -s -X DELETE "$BASE_URL/releases/$RELEASE_ID/assets/$ASSET_ID" \
|
||||
-H "Authorization: token $DEPLOY_TOKEN"
|
||||
fi
|
||||
|
||||
echo "Uploading $NAME..."
|
||||
curl -s -X POST \
|
||||
"$BASE_URL/releases/$RELEASE_ID/assets?name=$(basename "$FILE")" \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
"$BASE_URL/releases/$RELEASE_ID/assets?name=$NAME" \
|
||||
-H "Authorization: token $DEPLOY_TOKEN" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@$FILE"
|
||||
|
||||
@@ -8,6 +8,7 @@ on:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
if: "!startsWith(github.event.head_commit.message, 'chore: release')"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -41,10 +41,20 @@ Unregister-ScheduledTask -TaskName "MediaServer" -Confirm:$false
|
||||
|
||||
**When restart is NOT needed:**
|
||||
|
||||
- Static file changes (`*.html`, `*.css`, `*.js`, `*.json`) - browser refresh is enough
|
||||
- Static file changes (`*.html`, `*.css`, `*.json`) - browser refresh is enough
|
||||
- README or documentation updates
|
||||
- Changes to install/service scripts (only affects new installations)
|
||||
|
||||
### Frontend Rebuild After JS Changes
|
||||
|
||||
**CRITICAL:** The frontend is bundled via esbuild into `static/dist/app.bundle.js`. After modifying ANY JavaScript file in `media_server/static/js/`, you **MUST** run:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Raw JS file edits have **NO effect** until the bundle is rebuilt. After rebuilding, a browser hard-refresh (Ctrl+Shift+R) is sufficient — no server restart needed.
|
||||
|
||||
**How to restart during development:**
|
||||
|
||||
1. Find the running server process:
|
||||
@@ -124,12 +134,17 @@ To add support for a new language:
|
||||
|
||||
## Versioning
|
||||
|
||||
Version is tracked in two files that must be kept in sync:
|
||||
**`pyproject.toml`** is the single source of truth for the version string.
|
||||
|
||||
- `pyproject.toml` - `[project].version`
|
||||
- `media_server/__init__.py` - `__version__`
|
||||
At runtime, `media_server/__init__.py` reads the version via `importlib.metadata.version()` — no manual syncing needed.
|
||||
|
||||
When releasing a new version, update both files with the same version string.
|
||||
Version flow:
|
||||
1. `git tag v0.3.0` → CI reads the tag
|
||||
2. Build scripts stamp `pyproject.toml` with the clean version via `sed`
|
||||
3. `pip install` bakes the version into package metadata
|
||||
4. `importlib.metadata.version("media-server")` reads it at runtime
|
||||
|
||||
When bumping the version for a new release, only `pyproject.toml` needs to be updated.
|
||||
|
||||
**Important:** After making any changes, always ask the user if the version needs to be incremented.
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
## v0.2.1 (2026-04-25)
|
||||
|
||||
A small polish release on top of the Studio Reference redesign — accent picker fix,
|
||||
visualizer performance work, and a couple of layout refinements for tablet and
|
||||
small-desktop widths.
|
||||
|
||||
### Bug Fixes
|
||||
- **Accent picker** now wired to the editorial copper palette + visual polish ([f4be2bd](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/f4be2bd))
|
||||
|
||||
### Performance
|
||||
- **Visualizer** — significant CPU cuts on spectrum rendering and track switches ([51ec150](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/51ec150))
|
||||
|
||||
### UI Improvements
|
||||
- Meaningful caps for tablet / small-desktop range + tighter footer ([25a492d](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/25a492d))
|
||||
|
||||
---
|
||||
|
||||
### Development / Internal
|
||||
|
||||
#### CI/Build
|
||||
- Skip test workflow on release commits ([08c3c80](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/08c3c80))
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary>All Commits</summary>
|
||||
|
||||
| Hash | Message | Author |
|
||||
|------|---------|--------|
|
||||
| [25a492d](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/25a492d) | ui(player): meaningful caps for tablet/small-desktop range + tighter footer | alexei.dolgolyov |
|
||||
| [f4be2bd](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/f4be2bd) | fix(player): wire accent picker to editorial copper palette + visual polish | alexei.dolgolyov |
|
||||
| [51ec150](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/51ec150) | perf(visualizer): cut spectrum + track-switch CPU significantly | alexei.dolgolyov |
|
||||
| [08c3c80](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/08c3c80) | ci: skip test workflow on release commits | alexei.dolgolyov |
|
||||
|
||||
</details>
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env bash
|
||||
# build-common.sh — shared functions for platform build scripts
|
||||
# Source this file, do not execute directly.
|
||||
|
||||
# --- Version detection ---
|
||||
# Fallback chain: CLI arg → git tag → CI env var → pyproject.toml
|
||||
detect_version() {
|
||||
local arg="${1:-}"
|
||||
VERSION="${arg}"
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION=$(git describe --tags --exact-match 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION="${GITEA_REF_NAME:-${GITHUB_REF_NAME:-}}"
|
||||
fi
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION=$(grep -oP '^version\s*=\s*"\K[^"]+' \
|
||||
pyproject.toml 2>/dev/null || echo "0.0.0")
|
||||
fi
|
||||
|
||||
VERSION_CLEAN="${VERSION#v}"
|
||||
|
||||
# Normalize non-PEP440 labels (e.g. "dev", "nightly", "snapshot") to a
|
||||
# valid PEP440 dev release. Without this, pip/setuptools rejects
|
||||
# pyproject.toml with: `project.version` must be pep440.
|
||||
#
|
||||
# Valid forms: 1.2.3, 1.2.3a1, 1.2.3rc2, 1.2.3.dev0, 1.2.3.post1, +local
|
||||
# Invalid forms: dev, vdev, nightly, snapshot-2024
|
||||
if ! [[ "$VERSION_CLEAN" =~ ^[0-9]+(\.[0-9]+)*((a|b|rc|\.dev|\.post)[0-9]+)*(\+[a-zA-Z0-9.]+)?$ ]]; then
|
||||
echo " Warning: '$VERSION_CLEAN' is not PEP440-compliant, using 0.0.0.dev0"
|
||||
VERSION_CLEAN="0.0.0.dev0"
|
||||
fi
|
||||
|
||||
# Stamp version into pyproject.toml (single source of truth)
|
||||
sed -i "s/^version = .*/version = \"${VERSION_CLEAN}\"/" pyproject.toml
|
||||
}
|
||||
|
||||
# --- Clean dist/build directories ---
|
||||
clean_dist() {
|
||||
rm -rf dist build
|
||||
mkdir -p "$@"
|
||||
}
|
||||
|
||||
# --- Verify frontend bundle exists ---
|
||||
verify_frontend() {
|
||||
if [ ! -f "media_server/static/dist/app.bundle.js" ]; then
|
||||
echo "ERROR: Frontend bundle not found. Run 'npm ci && npm run build' first."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Copy application files into dist ---
|
||||
# Args: $1 = DIST_DIR
|
||||
copy_app_files() {
|
||||
local dist_dir="$1"
|
||||
|
||||
echo "Copying application files..."
|
||||
mkdir -p "${dist_dir}/app"
|
||||
cp -r media_server "${dist_dir}/app/"
|
||||
|
||||
# Remove source JS (bundle is in dist/)
|
||||
rm -rf "${dist_dir}/app/media_server/static/js"
|
||||
# Remove source maps from release
|
||||
rm -f "${dist_dir}/app/media_server/static/dist/"*.map
|
||||
|
||||
# Copy config example
|
||||
cp config.example.yaml "${dist_dir}/"
|
||||
|
||||
# Write version file
|
||||
echo "$VERSION_CLEAN" > "${dist_dir}/VERSION"
|
||||
}
|
||||
|
||||
# --- Clean up site-packages for smaller distribution ---
|
||||
# Args: $1 = site-packages path, $2 = ext suffix (pyd|so), $3 = lib suffix (dll|so)
|
||||
# Windows: cleanup_site_packages "$SP" "pyd" "dll"
|
||||
# Linux: cleanup_site_packages "$SP" "so" "so"
|
||||
cleanup_site_packages() {
|
||||
local sp_dir="$1"
|
||||
local ext_suffix="${2:-so}"
|
||||
local lib_suffix="${3:-so}"
|
||||
|
||||
echo "Optimizing size..."
|
||||
|
||||
# Generic cleanup
|
||||
find "$sp_dir" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$sp_dir" -type d -name tests -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$sp_dir" -type d -name "*.dist-info" -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$sp_dir" -name "*.pyi" -delete 2>/dev/null || true
|
||||
rm -rf "$sp_dir"/{pip,setuptools,pkg_resources,_distutils_hack}* 2>/dev/null || true
|
||||
|
||||
# Trim numpy if present.
|
||||
# Keep only modules that numpy/__init__.py does NOT import unconditionally —
|
||||
# lib, linalg, ma, polynomial, fft, ctypeslib, matrixlib are all required for
|
||||
# `import numpy` to succeed, so they MUST stay.
|
||||
for mod in distutils f2py typing _pyinstaller; do
|
||||
rm -rf "$sp_dir/numpy/$mod" 2>/dev/null || true
|
||||
done
|
||||
|
||||
# Trim OpenCV if present
|
||||
rm -f "$sp_dir"/cv2/opencv_videoio_ffmpeg*."$lib_suffix" 2>/dev/null || true
|
||||
rm -rf "$sp_dir"/cv2/{data,gapi,misc,utils,typing_stubs,typing} 2>/dev/null || true
|
||||
|
||||
# Trim Pillow unused plugins if present
|
||||
rm -rf "$sp_dir"/PIL/{FpxImagePlugin,MicImagePlugin,McIdasImagePlugin}* 2>/dev/null || true
|
||||
|
||||
# Trim zeroconf service DB if present
|
||||
rm -rf "$sp_dir"/zeroconf/_services 2>/dev/null || true
|
||||
|
||||
# Strip debug symbols from native extensions
|
||||
find "$sp_dir" -name "*.$ext_suffix" -exec strip --strip-debug {} \; 2>/dev/null || true
|
||||
|
||||
# NOTE: do NOT strip .py source files. A previous version of this function
|
||||
# ran `find ... -name "*.py" ! -name "__init__.py" -delete` with a comment
|
||||
# claiming "keep .pyc only" — but no compileall step exists, so the dist
|
||||
# shipped with __init__.py + .pyd only, missing every submodule (Image.py,
|
||||
# ImageDraw.py, _version.py, ...). Fresh installs would fail with
|
||||
# ModuleNotFoundError; in-place upgrades over an older install produced a
|
||||
# half-old/half-new site-packages where PIL/__init__.py was new but
|
||||
# PIL/_version.py was stale, yielding the runtime "_imaging extension was
|
||||
# built for another version of Pillow" import error.
|
||||
}
|
||||
+9
-39
@@ -4,44 +4,24 @@ set -euo pipefail
|
||||
# Build Linux distribution (self-contained venv + tarball)
|
||||
# Usage: ./build-dist-linux.sh [VERSION]
|
||||
|
||||
# --- Version detection ---
|
||||
VERSION="${1:-}"
|
||||
source "$(dirname "$0")/build-common.sh"
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION=$(git describe --tags --exact-match 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION="${GITEA_REF_NAME:-${GITHUB_REF_NAME:-}}"
|
||||
fi
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION=$(grep -oP '__version__\s*=\s*"\K[^"]+' \
|
||||
media_server/__init__.py 2>/dev/null || echo "0.0.0")
|
||||
fi
|
||||
|
||||
VERSION_CLEAN="${VERSION#v}"
|
||||
detect_version "${1:-}"
|
||||
echo "Building Media Server v${VERSION_CLEAN} for Linux"
|
||||
|
||||
# --- Configuration ---
|
||||
DIST_DIR="dist/media-server"
|
||||
BUILD_OUTPUT="build/MediaServer-v${VERSION_CLEAN}-linux-x64"
|
||||
|
||||
rm -rf dist build
|
||||
mkdir -p "${DIST_DIR}" build
|
||||
|
||||
# --- Verify frontend bundle ---
|
||||
if [ ! -f "media_server/static/dist/app.bundle.js" ]; then
|
||||
echo "ERROR: Frontend bundle not found. Run 'npm ci && npm run build' first."
|
||||
exit 1
|
||||
fi
|
||||
clean_dist "${DIST_DIR}" build
|
||||
verify_frontend
|
||||
|
||||
# --- Create self-contained virtualenv ---
|
||||
echo "Creating virtualenv..."
|
||||
python3 -m venv "${DIST_DIR}/venv"
|
||||
source "${DIST_DIR}/venv/bin/activate"
|
||||
pip install --quiet --upgrade pip
|
||||
pip install --quiet ".[visualizer]"
|
||||
pip install --quiet "."
|
||||
|
||||
# Remove the installed package (app source is on PYTHONPATH via launcher)
|
||||
rm -rf "${DIST_DIR}"/venv/lib/python*/site-packages/media_server*
|
||||
@@ -49,21 +29,11 @@ rm -rf "${DIST_DIR}"/venv/lib/python*/site-packages/media_server*.dist-info
|
||||
|
||||
deactivate
|
||||
|
||||
# --- Copy application ---
|
||||
echo "Copying application files..."
|
||||
mkdir -p "${DIST_DIR}/app"
|
||||
cp -r media_server "${DIST_DIR}/app/"
|
||||
# Trim venv site-packages
|
||||
LINUX_SP=$(echo "${DIST_DIR}"/venv/lib/python*/site-packages)
|
||||
cleanup_site_packages "$LINUX_SP" "so" "so"
|
||||
|
||||
# Remove source JS (bundle is in dist/)
|
||||
rm -rf "${DIST_DIR}/app/media_server/static/js"
|
||||
# Remove source maps from release
|
||||
rm -f "${DIST_DIR}/app/media_server/static/dist/"*.map
|
||||
|
||||
# Copy config example
|
||||
cp config.example.yaml "${DIST_DIR}/"
|
||||
|
||||
# --- Write version ---
|
||||
echo "$VERSION_CLEAN" > "${DIST_DIR}/VERSION"
|
||||
copy_app_files "$DIST_DIR"
|
||||
|
||||
# --- Create launcher ---
|
||||
cat > "${DIST_DIR}/media-server.sh" << 'LAUNCHER'
|
||||
|
||||
+64
-58
@@ -4,23 +4,9 @@ set -euo pipefail
|
||||
# Cross-build Windows distribution on Linux
|
||||
# Usage: ./build-dist-windows.sh [VERSION]
|
||||
|
||||
# --- Version detection ---
|
||||
VERSION="${1:-}"
|
||||
source "$(dirname "$0")/build-common.sh"
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION=$(git describe --tags --exact-match 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION="${GITEA_REF_NAME:-${GITHUB_REF_NAME:-}}"
|
||||
fi
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION=$(grep -oP '__version__\s*=\s*"\K[^"]+' \
|
||||
media_server/__init__.py 2>/dev/null || echo "0.0.0")
|
||||
fi
|
||||
|
||||
VERSION_CLEAN="${VERSION#v}"
|
||||
detect_version "${1:-}"
|
||||
echo "Building Media Server v${VERSION_CLEAN} for Windows"
|
||||
|
||||
# --- Configuration ---
|
||||
@@ -31,13 +17,17 @@ WHEEL_DIR="build/win-wheels"
|
||||
SITE_PACKAGES="${DIST_DIR}/python/Lib/site-packages"
|
||||
BUILD_OUTPUT="build/MediaServer-v${VERSION_CLEAN}-win-x64"
|
||||
|
||||
rm -rf dist build
|
||||
mkdir -p "${DIST_DIR}" "${WHEEL_DIR}" "${SITE_PACKAGES}"
|
||||
clean_dist "${DIST_DIR}" "${WHEEL_DIR}" "${SITE_PACKAGES}"
|
||||
|
||||
# --- Download embedded Python ---
|
||||
echo "Downloading embedded Python ${PYTHON_VERSION}..."
|
||||
curl -sL "https://www.python.org/ftp/python/${PYTHON_VERSION}/python-${PYTHON_VERSION}-embed-amd64.zip" \
|
||||
-o build/python-embed.zip
|
||||
# --- Download embedded Python (cache-friendly) ---
|
||||
mkdir -p build
|
||||
if [ ! -f build/python-embed.zip ]; then
|
||||
echo "Downloading embedded Python ${PYTHON_VERSION}..."
|
||||
curl -sL "https://www.python.org/ftp/python/${PYTHON_VERSION}/python-${PYTHON_VERSION}-embed-amd64.zip" \
|
||||
-o build/python-embed.zip
|
||||
else
|
||||
echo "Using cached embedded Python ${PYTHON_VERSION}"
|
||||
fi
|
||||
unzip -qo build/python-embed.zip -d "${DIST_DIR}/python"
|
||||
|
||||
# Patch ._pth to enable site-packages and app source
|
||||
@@ -50,9 +40,18 @@ echo '..\app' >> "$PTH_FILE"
|
||||
echo "Downloading Windows wheels..."
|
||||
|
||||
# Core dependencies
|
||||
# NOTE: uvicorn[standard] pulls in uvloop via `sys_platform != 'win32'` marker.
|
||||
# pip evaluates env markers against the HOST (Linux in CI), so uvloop is
|
||||
# requested, but `--platform win_amd64 --only-binary :all:` cannot find a
|
||||
# Windows wheel for uvloop (none exist). Result: pip backtracks across every
|
||||
# uvicorn[standard] version and ResolutionImpossible. Fix: use plain uvicorn
|
||||
# and list only the Windows-compatible standard extras we actually need.
|
||||
CORE_DEPS=(
|
||||
"fastapi>=0.109.0"
|
||||
"uvicorn[standard]>=0.27.0"
|
||||
"uvicorn>=0.27.0"
|
||||
"httptools>=0.5.0"
|
||||
"websockets>=10.4"
|
||||
"python-dotenv>=0.13"
|
||||
"pydantic>=2.0"
|
||||
"pydantic-settings>=2.0"
|
||||
"pyyaml>=6.0"
|
||||
@@ -61,30 +60,50 @@ CORE_DEPS=(
|
||||
)
|
||||
|
||||
# Windows-specific dependencies
|
||||
# NOTE: wmi is a transitive dep of screen-brightness-control gated on
|
||||
# `platform_system == "Windows"`. pip evaluates env markers against the HOST
|
||||
# (Linux in CI), so it gets skipped during cross-build. Listed explicitly here
|
||||
# so the wheel actually lands in the Windows bundle. Same gotcha as the
|
||||
# uvicorn[standard]/uvloop case documented above.
|
||||
WIN_DEPS=(
|
||||
"winsdk>=1.0.0b10"
|
||||
"pywin32>=306"
|
||||
"comtypes>=1.2.0"
|
||||
"pycaw>=20230407"
|
||||
"screen-brightness-control>=0.20.0"
|
||||
"wmi>=1.5.1"
|
||||
"monitorcontrol>=3.0.0"
|
||||
"pystray>=0.19.0"
|
||||
)
|
||||
|
||||
# Visualizer dependencies
|
||||
VIS_DEPS=(
|
||||
"soundcard>=0.4.0"
|
||||
"numpy>=1.24.0,<2.0"
|
||||
# pystray lives here (not WIN_DEPS) so its transitive Pillow resolves in the
|
||||
# same pass as CORE_DEPS. Keeping it in the per-dep WIN_DEPS loop downloaded
|
||||
# a second Pillow version that clobbered the core one on unzip, producing
|
||||
# "_imaging extension was built for another version of Pillow" at runtime.
|
||||
"pystray>=0.19.0"
|
||||
)
|
||||
|
||||
ALL_DEPS=("${CORE_DEPS[@]}" "${WIN_DEPS[@]}" "${VIS_DEPS[@]}")
|
||||
# Resolve core + visualizer deps in a SINGLE call so pip picks compatible
|
||||
# transitive versions (notably pydantic/pydantic-core must match).
|
||||
# Per-dep loops resolve each dep independently and can leave mismatched
|
||||
# transitive versions that overwrite each other in the site-packages unzip.
|
||||
CROSS_DEPS=("${CORE_DEPS[@]}" "${VIS_DEPS[@]}")
|
||||
pip download --quiet --no-cache-dir --dest "$WHEEL_DIR" \
|
||||
--platform win_amd64 --python-version "${PYTHON_SHORT}" \
|
||||
--implementation cp --only-binary :all: \
|
||||
"${CROSS_DEPS[@]}"
|
||||
|
||||
for dep in "${ALL_DEPS[@]}"; do
|
||||
pip download --quiet --no-cache-dir --dest "$WHEEL_DIR" \
|
||||
# Windows-only deps in a loop with --pre: winsdk only ships beta wheels
|
||||
# (1.0.0bNN) and each dep needs its own platform/non-platform fallback.
|
||||
for dep in "${WIN_DEPS[@]}"; do
|
||||
pip download --pre --quiet --no-cache-dir --dest "$WHEEL_DIR" \
|
||||
--platform win_amd64 --python-version "${PYTHON_SHORT}" \
|
||||
--implementation cp --only-binary :all: \
|
||||
"$dep" 2>/dev/null || \
|
||||
pip download --quiet --no-cache-dir --dest "$WHEEL_DIR" \
|
||||
pip download --pre --quiet --no-cache-dir --dest "$WHEEL_DIR" \
|
||||
--only-binary :all: \
|
||||
"$dep"
|
||||
done
|
||||
@@ -100,43 +119,30 @@ for whl in "$WHEEL_DIR"/*.whl; do
|
||||
unzip -qo "$whl" -d "$SITE_PACKAGES"
|
||||
done
|
||||
|
||||
# --- Size optimization ---
|
||||
echo "Optimizing size..."
|
||||
find "$SITE_PACKAGES" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$SITE_PACKAGES" -type d -name tests -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$SITE_PACKAGES" -type d -name "*.dist-info" -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$SITE_PACKAGES" -name "*.pyi" -delete 2>/dev/null || true
|
||||
rm -rf "$SITE_PACKAGES"/{pip,setuptools,pkg_resources}* 2>/dev/null || true
|
||||
|
||||
# Trim numpy if present
|
||||
rm -rf "$SITE_PACKAGES"/numpy/{tests,f2py,typing} 2>/dev/null || true
|
||||
|
||||
# --- Verify frontend bundle ---
|
||||
if [ ! -f "media_server/static/dist/app.bundle.js" ]; then
|
||||
echo "ERROR: Frontend bundle not found. Run 'npm ci && npm run build' first."
|
||||
exit 1
|
||||
# numpy wheels from PyPI don't include _distributor_init_local.py unless
|
||||
# patched by delvewheel. In embedded Python, os.add_dll_directory() is never
|
||||
# called, so libopenblas can't be found and numpy fails to import.
|
||||
# Generate the missing loader here instead.
|
||||
if [ -d "${SITE_PACKAGES}/numpy" ]; then
|
||||
cat > "${SITE_PACKAGES}/numpy/_distributor_init_local.py" << 'EOF'
|
||||
import os
|
||||
import sys
|
||||
if sys.platform == 'win32':
|
||||
_libs = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'numpy.libs'))
|
||||
if os.path.isdir(_libs):
|
||||
os.add_dll_directory(_libs)
|
||||
EOF
|
||||
echo "Generated numpy/_distributor_init_local.py"
|
||||
fi
|
||||
|
||||
# --- Copy application ---
|
||||
echo "Copying application files..."
|
||||
mkdir -p "${DIST_DIR}/app"
|
||||
cp -r media_server "${DIST_DIR}/app/"
|
||||
|
||||
# Remove source JS (bundle is in dist/)
|
||||
rm -rf "${DIST_DIR}/app/media_server/static/js"
|
||||
# Remove source maps from release
|
||||
rm -f "${DIST_DIR}/app/media_server/static/dist/"*.map
|
||||
|
||||
# Copy config example
|
||||
cp config.example.yaml "${DIST_DIR}/"
|
||||
cleanup_site_packages "$SITE_PACKAGES" "pyd" "dll"
|
||||
verify_frontend
|
||||
copy_app_files "$DIST_DIR"
|
||||
|
||||
# Copy scripts needed for auto-start
|
||||
mkdir -p "${DIST_DIR}/scripts"
|
||||
cp scripts/start-hidden.vbs "${DIST_DIR}/scripts/"
|
||||
|
||||
# --- Write version ---
|
||||
echo "$VERSION_CLEAN" > "${DIST_DIR}/VERSION"
|
||||
|
||||
# --- Create launcher ---
|
||||
cat > "${DIST_DIR}/media-server.bat" << 'LAUNCHER'
|
||||
@echo off
|
||||
|
||||
@@ -56,6 +56,11 @@ scripts:
|
||||
timeout: 10
|
||||
shell: true
|
||||
|
||||
# Media folder management from Web UI (default: true)
|
||||
# When enabled, media folders can be added, edited, and deleted from the Settings tab.
|
||||
# Set to false to disable folder management from the UI.
|
||||
# media_folders_management: false
|
||||
|
||||
# Callback scripts (executed after media actions)
|
||||
# All callbacks are optional - if not defined, the action runs without callback
|
||||
callbacks:
|
||||
|
||||
+17
-4
@@ -70,6 +70,19 @@ Section "!Core (required)" SecCore
|
||||
|
||||
SetOutPath "$INSTDIR"
|
||||
|
||||
; Wipe previous payload before extracting so stale files from an older
|
||||
; version cannot survive an upgrade. Without this, in-place upgrades
|
||||
; produce a half-old/half-new site-packages — e.g. an old PIL/_version.py
|
||||
; alongside a new PIL/_imaging.pyd, which raises "_imaging extension was
|
||||
; built for another version of Pillow" at runtime. config.yaml lives at
|
||||
; $INSTDIR root and is preserved.
|
||||
RMDir /r "$INSTDIR\python"
|
||||
RMDir /r "$INSTDIR\app"
|
||||
RMDir /r "$INSTDIR\scripts"
|
||||
Delete "$INSTDIR\${EXENAME}"
|
||||
Delete "$INSTDIR\VERSION"
|
||||
Delete "$INSTDIR\config.example.yaml"
|
||||
|
||||
; Copy entire distribution
|
||||
File /r "dist\media-server\*.*"
|
||||
|
||||
@@ -84,10 +97,10 @@ Section "!Core (required)" SecCore
|
||||
CreateDirectory "$SMPROGRAMS\${APPNAME}"
|
||||
CreateShortcut "$SMPROGRAMS\${APPNAME}\${APPNAME}.lnk" \
|
||||
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
|
||||
"$INSTDIR\python\python.exe" 0
|
||||
"$INSTDIR\app\media_server\static\icons\icon.ico" 0
|
||||
CreateShortcut "$SMPROGRAMS\${APPNAME}\${APPNAME} (Console).lnk" \
|
||||
"$INSTDIR\${EXENAME}" "" \
|
||||
"$INSTDIR\python\python.exe" 0
|
||||
"$INSTDIR\app\media_server\static\icons\icon.ico" 0
|
||||
CreateShortcut "$SMPROGRAMS\${APPNAME}\Uninstall.lnk" \
|
||||
"$INSTDIR\uninstall.exe"
|
||||
|
||||
@@ -117,14 +130,14 @@ SectionEnd
|
||||
Section "Desktop shortcut" SecDesktop
|
||||
CreateShortcut "$DESKTOP\${APPNAME}.lnk" \
|
||||
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
|
||||
"$INSTDIR\python\python.exe" 0
|
||||
"$INSTDIR\app\media_server\static\icons\icon.ico" 0
|
||||
SectionEnd
|
||||
|
||||
Section "Start with Windows" SecAutostart
|
||||
; Create Startup folder shortcut (runs hidden via VBS)
|
||||
CreateShortcut "$SMSTARTUP\${APPNAME}.lnk" \
|
||||
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
|
||||
"$INSTDIR\python\python.exe" 0
|
||||
"$INSTDIR\app\media_server\static\icons\icon.ico" 0
|
||||
SectionEnd
|
||||
|
||||
; --- Section descriptions ---
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -124,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(
|
||||
@@ -159,6 +163,17 @@ class Settings(BaseSettings):
|
||||
description="Loopback audio device name for visualizer (None = auto-detect)",
|
||||
)
|
||||
|
||||
# Update checker
|
||||
update_check_enabled: bool = Field(
|
||||
default=True,
|
||||
description="Check for new versions on startup and periodically",
|
||||
)
|
||||
update_check_interval: int = Field(
|
||||
default=21600,
|
||||
description="Update check interval in seconds (default: 6 hours)",
|
||||
ge=600,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def load_from_yaml(cls, path: Optional[Path] = None) -> "Settings":
|
||||
"""Load settings from a YAML configuration file."""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import socket
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
@@ -74,6 +75,18 @@ async def lifespan(app: FastAPI):
|
||||
await ws_manager.start_status_monitor(controller.get_status)
|
||||
logger.info("WebSocket status monitor started")
|
||||
|
||||
# Start update checker
|
||||
update_checker = None
|
||||
if settings.update_check_enabled:
|
||||
from .services.gitea_release_provider import GiteaReleaseProvider
|
||||
from .services.update_checker import UpdateChecker
|
||||
|
||||
provider = GiteaReleaseProvider()
|
||||
update_checker = UpdateChecker(provider, __version__)
|
||||
await update_checker.start(settings.update_check_interval)
|
||||
# Store globally so health endpoint can access cached result
|
||||
app.state.update_checker = update_checker
|
||||
|
||||
# Register audio visualizer (capture starts on-demand when clients subscribe)
|
||||
analyzer = None
|
||||
if settings.visualizer_enabled:
|
||||
@@ -92,6 +105,10 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
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:
|
||||
@@ -243,6 +260,19 @@ def main():
|
||||
print("\nAuthentication is DISABLED (no tokens configured)")
|
||||
return
|
||||
|
||||
# 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
|
||||
|
||||
@@ -24,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.
|
||||
|
||||
@@ -83,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")
|
||||
@@ -112,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():
|
||||
@@ -169,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)
|
||||
@@ -217,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)
|
||||
|
||||
|
||||
@@ -3,23 +3,33 @@
|
||||
import platform
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
from .. import __version__
|
||||
from ..auth import auth_enabled
|
||||
from ..config import settings
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["health"])
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health_check() -> dict[str, Any]:
|
||||
async def health_check(request: Request) -> dict[str, Any]:
|
||||
"""Health check endpoint - no authentication required.
|
||||
|
||||
Returns:
|
||||
Health status and server information
|
||||
"""
|
||||
return {
|
||||
result: dict[str, Any] = {
|
||||
"status": "healthy",
|
||||
"platform": platform.system(),
|
||||
"version": "1.0.0",
|
||||
"version": __version__,
|
||||
"auth_required": auth_enabled(),
|
||||
"media_folders_management": settings.media_folders_management,
|
||||
}
|
||||
|
||||
# Include cached update info if available
|
||||
checker = getattr(request.app.state, "update_checker", None)
|
||||
if checker is not None and checker.cached_update is not None:
|
||||
result["update_available"] = checker.cached_update
|
||||
|
||||
return result
|
||||
|
||||
@@ -307,6 +307,12 @@ async def set_visualizer_device(
|
||||
# set_device() handles stop/start internally if capture was running
|
||||
success = analyzer.set_device(device_name)
|
||||
|
||||
# Persist selection to config.yaml so it survives server restarts
|
||||
if success:
|
||||
from ..config_manager import config_manager
|
||||
|
||||
config_manager.set_setting("visualizer_device", device_name)
|
||||
|
||||
return {
|
||||
"success": success,
|
||||
"current_device": analyzer.current_device,
|
||||
@@ -317,7 +323,7 @@ async def set_visualizer_device(
|
||||
@router.websocket("/ws")
|
||||
async def websocket_endpoint(
|
||||
websocket: WebSocket,
|
||||
token: str = Query(..., description="API authentication token"),
|
||||
token: str | None = Query(None, description="API authentication token"),
|
||||
) -> None:
|
||||
"""WebSocket endpoint for real-time media status updates.
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Audio spectrum analyzer service using system loopback capture."""
|
||||
|
||||
import logging
|
||||
import math
|
||||
import platform
|
||||
import threading
|
||||
import time
|
||||
@@ -15,10 +16,25 @@ def _load_numpy():
|
||||
global _np
|
||||
if _np is None:
|
||||
try:
|
||||
import os
|
||||
import sys
|
||||
if sys.platform == 'win32':
|
||||
# Embedded Python doesn't auto-load DLLs from numpy.libs;
|
||||
# add the directory explicitly so libopenblas can be found.
|
||||
try:
|
||||
import importlib.util
|
||||
spec = importlib.util.find_spec('numpy')
|
||||
if spec and spec.submodule_search_locations:
|
||||
numpy_dir = list(spec.submodule_search_locations)[0]
|
||||
libs_dir = os.path.join(os.path.dirname(numpy_dir), 'numpy.libs')
|
||||
if os.path.isdir(libs_dir):
|
||||
os.add_dll_directory(libs_dir)
|
||||
except Exception:
|
||||
pass
|
||||
import numpy as np
|
||||
_np = np
|
||||
except ImportError:
|
||||
logger.info("numpy not installed - audio visualizer unavailable")
|
||||
except Exception as e:
|
||||
logger.warning("numpy unavailable - audio visualizer disabled: %s", e)
|
||||
return _np
|
||||
|
||||
|
||||
@@ -28,8 +44,8 @@ def _load_soundcard():
|
||||
try:
|
||||
import soundcard as sc
|
||||
_sc = sc
|
||||
except ImportError:
|
||||
logger.info("soundcard not installed - audio visualizer unavailable")
|
||||
except Exception as e:
|
||||
logger.warning("soundcard unavailable - audio visualizer disabled: %s", e)
|
||||
return _sc
|
||||
|
||||
|
||||
@@ -56,6 +72,19 @@ class AudioAnalyzer:
|
||||
self._lifecycle_lock = threading.Lock()
|
||||
self._data: dict | None = None
|
||||
self._current_device_name: str | None = None
|
||||
# Generation counter — bumped each time _data is refreshed.
|
||||
# Lets the broadcast loop dedupe without comparing dict identity
|
||||
# (which is fragile because we always allocate a new dict).
|
||||
self._data_seq = 0
|
||||
# Threading.Event signaled when new frame data is available.
|
||||
# The broadcast loop awaits this instead of polling on a timer,
|
||||
# so it wakes up exactly once per produced frame.
|
||||
self._data_event = threading.Event()
|
||||
# Slow AGC envelope so the spectrum reflects real dynamics
|
||||
# instead of being renormalized to peak=1.0 every frame.
|
||||
# A loud transient (e.g. notification beep) lifts the reference
|
||||
# for a few seconds afterwards; this is the price of real loudness.
|
||||
self._spectrum_ref = 0.01
|
||||
|
||||
# Pre-compute logarithmic bin edges
|
||||
self._bin_edges = self._compute_bin_edges()
|
||||
@@ -95,6 +124,10 @@ class AudioAnalyzer:
|
||||
if not self.available:
|
||||
return False
|
||||
|
||||
# Reset AGC envelope so a long silent gap between sessions
|
||||
# doesn't make the first new transients clip at the ceiling.
|
||||
self._spectrum_ref = 0.01
|
||||
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._capture_loop, daemon=True)
|
||||
self._thread.start()
|
||||
@@ -104,17 +137,30 @@ class AudioAnalyzer:
|
||||
"""Stop audio capture and cleanup."""
|
||||
with self._lifecycle_lock:
|
||||
self._running = False
|
||||
# Wake any waiter so it can observe _running and exit cleanly.
|
||||
self._data_event.set()
|
||||
if self._thread:
|
||||
self._thread.join(timeout=3.0)
|
||||
self._thread = None
|
||||
with self._lock:
|
||||
self._data = None
|
||||
self._data_event.clear()
|
||||
|
||||
def get_frequency_data(self) -> dict | None:
|
||||
"""Return latest frequency data (thread-safe). None if not running."""
|
||||
with self._lock:
|
||||
return self._data
|
||||
|
||||
def get_frequency_data_versioned(self) -> tuple[dict | None, int]:
|
||||
"""Return (data, seq) so callers can dedupe without identity tricks."""
|
||||
with self._lock:
|
||||
return self._data, self._data_seq
|
||||
|
||||
@property
|
||||
def data_event(self) -> threading.Event:
|
||||
"""Event signaled when a fresh frame is ready. Caller must clear()."""
|
||||
return self._data_event
|
||||
|
||||
@staticmethod
|
||||
def list_loopback_devices() -> list[dict[str, str]]:
|
||||
"""List all available loopback audio devices."""
|
||||
@@ -226,12 +272,24 @@ class AudioAnalyzer:
|
||||
return
|
||||
|
||||
interval = 1.0 / self.target_fps
|
||||
window = np.hanning(self.chunk_size)
|
||||
# Float32 window — matches soundcard's typical buffer dtype and
|
||||
# halves FFT memory traffic vs. the default float64.
|
||||
window = np.hanning(self.chunk_size).astype(np.float32)
|
||||
|
||||
# Pre-compute bin edge pairs for vectorized grouping
|
||||
edges = self._bin_edges
|
||||
bin_starts = np.array([edges[i] for i in range(self.num_bins)], dtype=np.intp)
|
||||
bin_ends = np.array([max(edges[i + 1], edges[i] + 1) for i in range(self.num_bins)], dtype=np.intp)
|
||||
# Counts are constant — compute once.
|
||||
bin_counts = (bin_ends - bin_starts).astype(np.float32)
|
||||
|
||||
# Pre-allocate working buffers so the per-frame allocator churn
|
||||
# on the capture thread (which runs at target_fps Hz, hours on
|
||||
# end) drops to zero copies for these arrays.
|
||||
fft_size = self.chunk_size // 2 + 1
|
||||
windowed = np.empty(self.chunk_size, dtype=np.float32)
|
||||
cumsum = np.empty(fft_size + 1, dtype=np.float32)
|
||||
cumsum[0] = 0.0
|
||||
|
||||
try:
|
||||
with device.recorder(
|
||||
@@ -260,29 +318,65 @@ class AudioAnalyzer:
|
||||
time.sleep(interval)
|
||||
continue
|
||||
|
||||
# Apply window and compute FFT
|
||||
windowed = mono[:self.chunk_size] * window
|
||||
# Apply window in-place into the pre-allocated buffer.
|
||||
np.multiply(mono[:self.chunk_size], window, out=windowed)
|
||||
fft_mag = np.abs(np.fft.rfft(windowed))
|
||||
|
||||
# Group into logarithmic bins (vectorized via cumsum)
|
||||
cumsum = np.concatenate(([0.0], np.cumsum(fft_mag)))
|
||||
counts = bin_ends - bin_starts
|
||||
bins = (cumsum[bin_ends] - cumsum[bin_starts]) / counts
|
||||
# Group into logarithmic bins (vectorized via cumsum).
|
||||
# Write into the pre-allocated [1:] slice so cumsum[0]
|
||||
# stays 0.0 and we never allocate a new array.
|
||||
np.cumsum(fft_mag, out=cumsum[1:])
|
||||
bins = (cumsum[bin_ends] - cumsum[bin_starts]) / bin_counts
|
||||
|
||||
# Normalize to 0-1
|
||||
max_val = bins.max()
|
||||
if max_val > 0:
|
||||
bins *= (1.0 / max_val)
|
||||
# True loudness from time-domain RMS via single BLAS
|
||||
# dot — avoids astype() and ** allocations.
|
||||
mono32 = mono if mono.dtype == np.float32 else mono.astype(np.float32, copy=False)
|
||||
energy = float(np.dot(mono32, mono32))
|
||||
if energy > 1e-12:
|
||||
rms = (energy / mono32.size) ** 0.5
|
||||
db = 20.0 * math.log10(rms)
|
||||
# Map -60 dB..-6 dB to 0..1 (typical music range)
|
||||
level = max(0.0, min(1.0, (db + 60.0) / 54.0))
|
||||
else:
|
||||
level = 0.0
|
||||
|
||||
# Slow auto-gain: envelope follower with fast attack,
|
||||
# slow release. Quiet music yields small bars; loud
|
||||
# passages reach the top; the reference adapts over
|
||||
# seconds instead of resetting every frame.
|
||||
current_peak = float(bins.max())
|
||||
if current_peak > self._spectrum_ref:
|
||||
self._spectrum_ref += (current_peak - self._spectrum_ref) * 0.05
|
||||
else:
|
||||
self._spectrum_ref += (current_peak - self._spectrum_ref) * 0.005
|
||||
ref = max(self._spectrum_ref, 1e-4)
|
||||
np.divide(bins, ref, out=bins)
|
||||
np.clip(bins, 0.0, 1.5, out=bins)
|
||||
|
||||
# Bass energy: average of first 4 bins (~20-200Hz)
|
||||
bass = float(bins[:4].mean()) if self.num_bins >= 4 else 0.0
|
||||
|
||||
# Round for compact JSON
|
||||
frequencies = np.round(bins, 3).tolist()
|
||||
bass = round(bass, 3)
|
||||
# Quantize to 0..1000 ints — same wire fidelity as
|
||||
# 3-decimal floats but smaller GC churn on both ends
|
||||
# (frontend smooths anyway, so quantization is
|
||||
# invisible). JSON encodes ints faster than floats.
|
||||
frequencies = (bins * 1000.0).astype(np.int16).tolist()
|
||||
bass_i = int(bass * 1000.0)
|
||||
level_i = int(level * 1000.0)
|
||||
|
||||
new_data = {
|
||||
"frequencies": frequencies,
|
||||
"bass": bass_i,
|
||||
"level": level_i,
|
||||
# Wire-format flag: clients that see this know
|
||||
# values are 0..1000 ints, not 0..1 floats.
|
||||
"scale": 1000,
|
||||
}
|
||||
with self._lock:
|
||||
self._data = {"frequencies": frequencies, "bass": bass}
|
||||
self._data = new_data
|
||||
self._data_seq += 1
|
||||
# Wake any broadcast loop waiting on fresh data.
|
||||
self._data_event.set()
|
||||
|
||||
# Throttle to target FPS
|
||||
elapsed = time.monotonic() - t0
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
"""
|
||||
...
|
||||
@@ -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
|
||||
@@ -19,6 +19,7 @@ class ConnectionManager:
|
||||
self._active_connections: set[WebSocket] = set()
|
||||
self._lock = asyncio.Lock()
|
||||
self._last_status: dict[str, Any] | None = None
|
||||
self._get_status_func: Callable[[], Coroutine[Any, Any, Any]] | None = None
|
||||
self._broadcast_task: asyncio.Task | None = None
|
||||
self._poll_interval: float = 0.5 # Internal poll interval for change detection
|
||||
self._position_broadcast_interval: float = 5.0 # Send position updates every 5s during playback
|
||||
@@ -39,9 +40,17 @@ class ConnectionManager:
|
||||
)
|
||||
|
||||
# Send current status immediately upon connection
|
||||
if self._last_status:
|
||||
status = self._last_status
|
||||
if not status and self._get_status_func:
|
||||
try:
|
||||
await websocket.send_json({"type": "status", "data": self._last_status})
|
||||
result = await self._get_status_func()
|
||||
status = result.model_dump()
|
||||
self._last_status = status
|
||||
except Exception as e:
|
||||
logger.debug("Failed to fetch initial status: %s", e)
|
||||
if status:
|
||||
try:
|
||||
await websocket.send_json({"type": "status", "data": status})
|
||||
except Exception as e:
|
||||
logger.debug("Failed to send initial status: %s", e)
|
||||
|
||||
@@ -152,26 +161,48 @@ class ConnectionManager:
|
||||
self._audio_task = None
|
||||
|
||||
async def _audio_broadcast_loop(self) -> None:
|
||||
"""Background loop: read frequency data from analyzer and broadcast to subscribers."""
|
||||
from ..config import settings
|
||||
interval = 1.0 / settings.visualizer_fps
|
||||
"""Background loop: read frequency data from analyzer and broadcast to subscribers.
|
||||
|
||||
_last_data = None
|
||||
Event-driven: blocks on the analyzer's data_event so it wakes up
|
||||
exactly once per produced frame, instead of polling on a timer.
|
||||
Backstop sleep applies when capture is idle / has no subscribers.
|
||||
"""
|
||||
from ..config import settings
|
||||
idle_interval = 1.0 / max(1, settings.visualizer_fps)
|
||||
# Bounded wait so we still notice subscribe/unsubscribe transitions.
|
||||
wake_timeout = max(0.05, idle_interval)
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
last_seq = -1
|
||||
|
||||
while True:
|
||||
try:
|
||||
async with self._lock:
|
||||
subscribers = list(self._visualizer_subscribers)
|
||||
|
||||
if not subscribers or not self._audio_analyzer or not self._audio_analyzer.running:
|
||||
await asyncio.sleep(interval)
|
||||
analyzer = self._audio_analyzer
|
||||
if not subscribers or not analyzer or not analyzer.running:
|
||||
await asyncio.sleep(idle_interval)
|
||||
continue
|
||||
|
||||
data = self._audio_analyzer.get_frequency_data()
|
||||
if data is None or data is _last_data:
|
||||
await asyncio.sleep(interval)
|
||||
# Wait off-loop for a fresh frame. The capture thread sets
|
||||
# data_event after each FFT update; we clear it before the
|
||||
# next wait so we never burn a wake on stale data.
|
||||
ev = analyzer.data_event
|
||||
|
||||
def _wait() -> bool:
|
||||
return ev.wait(wake_timeout)
|
||||
|
||||
got = await loop.run_in_executor(None, _wait)
|
||||
if not got:
|
||||
# Timeout — loop around to re-check subscriber state.
|
||||
continue
|
||||
_last_data = data
|
||||
ev.clear()
|
||||
|
||||
data, seq = analyzer.get_frequency_data_versioned()
|
||||
if data is None or seq == last_seq:
|
||||
continue
|
||||
last_seq = seq
|
||||
|
||||
# Pre-serialize once for all subscribers (avoids per-client JSON encoding)
|
||||
text = json.dumps({"type": "audio_data", "data": data}, separators=(',', ':'))
|
||||
@@ -189,13 +220,11 @@ class ConnectionManager:
|
||||
for ws in failed:
|
||||
await self.disconnect(ws)
|
||||
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error("Error in audio broadcast: %s", e)
|
||||
await asyncio.sleep(interval)
|
||||
await asyncio.sleep(idle_interval)
|
||||
|
||||
def status_changed(
|
||||
self, old: dict[str, Any] | None, new: dict[str, Any]
|
||||
@@ -251,6 +280,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)
|
||||
|
||||
+5835
-262
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+189
-68
@@ -6,6 +6,7 @@
|
||||
<title>Media Server</title>
|
||||
<meta name="description" content="Remote media player control and file browser">
|
||||
<meta name="theme-color" content="#121212">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="Media Server">
|
||||
@@ -75,11 +76,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Folio marks at page corners -->
|
||||
<span class="folio tl"><span class="status-dot" id="status-dot" aria-live="polite"></span><span data-i18n="header.connected">Connected</span> · <span id="folio-host">Local 8765</span></span>
|
||||
<span class="folio tr"><span data-i18n="header.volume">Vol. I</span> — <span data-i18n="header.edition">Studio Reference</span> · <span id="version-label"></span></span>
|
||||
|
||||
<div class="container">
|
||||
<header>
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<span class="status-dot" id="status-dot" aria-live="polite"></span>
|
||||
<span class="version-label" id="version-label"></span>
|
||||
<div class="brand">
|
||||
<span class="brand-name" data-i18n="app.title">Media Server</span>
|
||||
<span class="brand-sub" data-i18n="header.edition_sub">Studio Reference Edition</span>
|
||||
</div>
|
||||
<div class="header-toolbar">
|
||||
<div id="headerLinks" class="header-links"></div>
|
||||
@@ -95,6 +100,10 @@
|
||||
<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="togglePlayerFullscreen()" data-i18n-title="player.fullscreen" title="Fullscreen player" aria-label="Fullscreen player" id="fullscreenToggle">
|
||||
<svg id="fullscreen-icon-enter" viewBox="0 0 24 24"><path fill="currentColor" d="M5 5h5v2H7v3H5V5zm9 0h5v5h-2V7h-3V5zm0 14v-2h3v-3h2v5h-5zM5 14h2v3h3v2H5v-5z"/></svg>
|
||||
<svg id="fullscreen-icon-exit" viewBox="0 0 24 24" style="display:none"><path fill="currentColor" d="M8 8H5v2H3V6a1 1 0 0 1 1-1h4v3zm8 0h3v2h2V6a1 1 0 0 0-1-1h-4v3zm0 8h3v-2h2v4a1 1 0 0 1-1 1h-4v-3zM8 16H5v-2H3v4a1 1 0 0 0 1 1h4v-3z"/></svg>
|
||||
</button>
|
||||
<button class="header-btn" 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"/>
|
||||
@@ -114,109 +123,187 @@
|
||||
</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">×</button>
|
||||
</div>
|
||||
|
||||
<!-- Connection Banner -->
|
||||
<div class="connection-banner hidden" id="connectionBanner">
|
||||
<span id="connectionBannerText"></span>
|
||||
<button class="connection-banner-btn" id="connectionBannerBtn" onclick="manualReconnect()" style="display: none;" data-i18n="connection.reconnect">Reconnect</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Bar -->
|
||||
<!-- Tab Bar (editorial: numbered, italic active) -->
|
||||
<div class="tab-bar" id="tabBar" role="tablist">
|
||||
<div class="tab-indicator" id="tabIndicator"></div>
|
||||
<button class="tab-btn active" data-tab="player" onclick="switchTab('player')" role="tab" aria-selected="true" aria-controls="panel-player" tabindex="0">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/></svg>
|
||||
<span data-i18n="tab.player">Player</span>
|
||||
<span class="tab-num">01</span>
|
||||
<span data-i18n="tab.player">Now Spinning</span>
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="display" onclick="switchTab('display')" role="tab" aria-selected="false" aria-controls="panel-display" tabindex="-1">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M20 3H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h6v2H8v2h8v-2h-2v-2h6c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 12H4V5h16v10z"/></svg>
|
||||
<span class="tab-num">02</span>
|
||||
<span data-i18n="tab.display">Display</span>
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="browser" onclick="switchTab('browser')" role="tab" aria-selected="false" aria-controls="panel-browser" tabindex="-1">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>
|
||||
<span data-i18n="tab.browser">Browser</span>
|
||||
<span class="tab-num">03</span>
|
||||
<span data-i18n="tab.browser">Library</span>
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="quick-actions" onclick="switchTab('quick-actions')" role="tab" aria-selected="false" aria-controls="panel-quick-actions" tabindex="-1">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M7 2v11h3v9l7-12h-4l4-8z"/></svg>
|
||||
<span class="tab-num">04</span>
|
||||
<span data-i18n="tab.quick_access">Quick Access</span>
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="settings" onclick="switchTab('settings')" role="tab" aria-selected="false" aria-controls="panel-settings" tabindex="-1">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.488.488 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>
|
||||
<span class="tab-num">05</span>
|
||||
<span data-i18n="tab.settings">Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="player-container" data-tab-content="player" role="tabpanel" id="panel-player">
|
||||
<div class="player-layout">
|
||||
<div class="album-art-container">
|
||||
<!-- Fullscreen-only chrome: floating top strip with kicker + exit. Auto-hides on idle. -->
|
||||
<div class="fs-chrome" id="fsChrome" aria-hidden="true">
|
||||
<div class="fs-chrome-mark">
|
||||
<span class="fs-chrome-edition" data-i18n="header.edition">Studio Reference</span>
|
||||
<span class="fs-chrome-sep">·</span>
|
||||
<span class="fs-chrome-kicker" data-i18n="player.kicker">Now Playing</span>
|
||||
</div>
|
||||
<button class="fs-chrome-exit" onclick="togglePlayerFullscreen()" data-i18n-title="player.fullscreen.exit" title="Exit fullscreen" aria-label="Exit fullscreen">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M8 8H5v2H3V6a1 1 0 0 1 1-1h4v3zm8 0h3v2h2V6a1 1 0 0 0-1-1h-4v3zm0 8h3v-2h2v4a1 1 0 0 1-1 1h-4v-3zM8 16H5v-2H3v4a1 1 0 0 0 1 1h4v-3z"/></svg>
|
||||
<span data-i18n="player.fullscreen.exit_short">Exit</span>
|
||||
<kbd class="fs-chrome-kbd">ESC</kbd>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Ambient album-art bloom: paints the room in the record's color while in fullscreen -->
|
||||
<div class="fs-bloom" id="fsBloom" aria-hidden="true">
|
||||
<img id="fs-bloom-art" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Crect fill='%23282828' width='300' height='300'/%3E%3C/svg%3E" alt="">
|
||||
</div>
|
||||
|
||||
<section class="now-playing player-layout">
|
||||
|
||||
<!-- Vinyl stage: cardstock sleeve + disc peeking out, plus tonearm -->
|
||||
<div class="vinyl-stage album-art-container">
|
||||
<img id="album-art-glow" class="album-art-glow" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Crect fill='%23282828' width='300' height='300'/%3E%3C/svg%3E" alt="" aria-hidden="true">
|
||||
<img id="album-art" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Crect fill='%23282828' width='300' height='300'/%3E%3Cpath fill='%236a6a6a' d='M150 80c-38.66 0-70 31.34-70 70s31.34 70 70 70 70-31.34 70-70-31.34-70-70-70zm0 20c27.614 0 50 22.386 50 50s-22.386 50-50 50-50-22.386-50-50 22.386-50 50-50zm0 30a20 20 0 100 40 20 20 0 000-40z'/%3E%3C/svg%3E" alt="Album Art">
|
||||
<div class="sleeve">
|
||||
<img id="album-art" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Cpath fill='%236a6a6a' opacity='0.35' d='M150 80c-38.66 0-70 31.34-70 70s31.34 70 70 70 70-31.34 70-70-31.34-70-70-70zm0 20c27.614 0 50 22.386 50 50s-22.386 50-50 50-50-22.386-50-50 22.386-50 50-50zm0 30a20 20 0 100 40 20 20 0 000-40z'/%3E%3C/svg%3E" alt="Album Art">
|
||||
<div class="sleeve-grain" aria-hidden="true"></div>
|
||||
<div class="sleeve-corner" aria-hidden="true"></div>
|
||||
</div>
|
||||
<div class="vinyl-wrap">
|
||||
<div class="vinyl">
|
||||
<div class="vinyl-label">
|
||||
<!-- Stylised record-label catalogue mark, not user-facing
|
||||
copy — intentionally not in the i18n bundle. -->
|
||||
<span class="vinyl-label-text">REF · 24</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<svg class="tonearm" viewBox="0 0 200 200" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="armGrad" x1="0" x2="1">
|
||||
<stop offset="0" stop-color="#3a3528"/>
|
||||
<stop offset="0.5" stop-color="#9C937F"/>
|
||||
<stop offset="1" stop-color="#5C5447"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="176" cy="24" r="14" fill="#1a1611" stroke="#3A3528" stroke-width="1"/>
|
||||
<circle cx="176" cy="24" r="6" fill="#3A3528"/>
|
||||
<circle cx="176" cy="24" r="2" fill="#E08038"/>
|
||||
<line x1="176" y1="24" x2="64" y2="136" stroke="url(#armGrad)" stroke-width="3.5" stroke-linecap="round"/>
|
||||
<rect x="180" y="14" width="14" height="20" fill="#26211A" stroke="#3A3528"/>
|
||||
<rect x="56" y="128" width="22" height="18" rx="2" fill="#26211A" stroke="#3A3528" transform="rotate(-45 67 137)"/>
|
||||
<circle cx="62" cy="138" r="3" fill="#E08038" opacity="0.8"/>
|
||||
<circle cx="62" cy="138" r="6" fill="none" stroke="#E08038" stroke-width="0.5" opacity="0.4"/>
|
||||
</svg>
|
||||
<canvas id="spectrogram-canvas" class="spectrogram-canvas" width="300" height="64"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="player-details">
|
||||
<div class="track-info">
|
||||
<div id="track-title" data-i18n="player.no_media">No media playing</div>
|
||||
<div id="artist"></div>
|
||||
<div id="album"></div>
|
||||
<div class="playback-state">
|
||||
<svg class="state-icon" id="state-icon" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"/>
|
||||
</svg>
|
||||
<span id="playback-state" data-i18n="state.idle">Idle</span>
|
||||
<!-- Track masthead -->
|
||||
<div class="track-masthead player-details">
|
||||
|
||||
<div class="kicker"><span data-i18n="player.kicker">Now Playing</span></div>
|
||||
|
||||
<h1 class="track-title" id="track-title" data-i18n="player.no_media">No media playing</h1>
|
||||
<div class="track-byline" id="artist"></div>
|
||||
<div class="track-album" id="album"></div>
|
||||
|
||||
<!-- 2-cell metadata grid -->
|
||||
<div class="meta-grid meta-grid-2">
|
||||
<div class="meta-cell">
|
||||
<div class="label" data-i18n="meta.state">State</div>
|
||||
<div class="value">
|
||||
<svg class="state-icon" id="state-icon" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"/>
|
||||
</svg>
|
||||
<span id="playback-state" data-i18n="state.idle">Idle</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="meta-cell">
|
||||
<div class="label" data-i18n="meta.source">Source</div>
|
||||
<div class="value source-value">
|
||||
<span class="source-icon" id="sourceIcon"></span>
|
||||
<span id="source" data-i18n="player.unknown_source">Unknown</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-container">
|
||||
<div class="time-display">
|
||||
<span id="current-time">0:00</span>
|
||||
<span id="total-time">0:00</span>
|
||||
<!-- Spectrum bars — driven by real audio when visualizer is active,
|
||||
CSS-animated synthetic motion otherwise. JS injects the spans. -->
|
||||
<div class="spectrum" id="player-spectrum" aria-hidden="true"></div>
|
||||
|
||||
<!-- Transport -->
|
||||
<div class="transport">
|
||||
<div class="progress-row">
|
||||
<span class="timecode elapsed" id="current-time">0:00</span>
|
||||
<div class="progress-track progress-bar" id="progress-bar" data-duration="0" role="slider" aria-label="Playback position" aria-valuemin="0" aria-valuemax="0" aria-valuenow="0">
|
||||
<div class="progress-fill" id="progress-fill"></div>
|
||||
</div>
|
||||
<span class="timecode" id="total-time">0:00</span>
|
||||
</div>
|
||||
<div class="progress-bar" id="progress-bar" data-duration="0" role="slider" aria-label="Playback position" aria-valuemin="0" aria-valuemax="0" aria-valuenow="0">
|
||||
<div class="progress-fill" id="progress-fill"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button onclick="previousTrack()" data-i18n-title="player.previous" title="Previous" id="btn-previous">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="primary" onclick="togglePlayPause()" data-i18n-title="player.play" title="Play/Pause" id="btn-play-pause">
|
||||
<svg viewBox="0 0 24 24" id="play-pause-icon">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button onclick="nextTrack()" data-i18n-title="player.next" title="Next" id="btn-next">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="volume-container">
|
||||
<button class="mute-btn" onclick="toggleMute()" data-i18n-title="player.mute" title="Mute" id="btn-mute">
|
||||
<svg viewBox="0 0 24 24" id="mute-icon">
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<input type="range" id="volume-slider" min="0" max="100" value="50" aria-label="Volume">
|
||||
<div class="volume-display" id="volume-display">50%</div>
|
||||
</div>
|
||||
|
||||
<div class="source-info">
|
||||
<span class="source-label"><span class="source-icon" id="sourceIcon"></span><span id="source" data-i18n="player.unknown_source">Unknown</span></span>
|
||||
<div class="player-toggles">
|
||||
<button class="vinyl-toggle-btn" onclick="toggleVinylMode()" id="vinylToggle" data-i18n-title="player.vinyl" title="Vinyl mode">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="12" cy="12" r="3" fill="currentColor"/><circle cx="12" cy="12" r="6.5" fill="none" stroke="currentColor" stroke-width="0.5" opacity="0.5"/></svg>
|
||||
<div class="controls">
|
||||
<button class="btn-trans" onclick="previousTrack()" data-i18n-title="player.previous" title="Previous" id="btn-previous">
|
||||
<svg viewBox="0 0 24 24"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
|
||||
</button>
|
||||
<button class="vinyl-toggle-btn" onclick="toggleVisualizer()" id="visualizerToggle" data-i18n-title="player.visualizer" title="Audio visualizer" style="display:none">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M3 18h2v-8H3v8zm4 0h2V6H7v12zm4 0h2V2h-2v16zm4 0h2v-6h-2v6zm4 0h2V9h-2v9z"/></svg>
|
||||
<button class="btn-trans primary" onclick="togglePlayPause()" data-i18n-title="player.play" title="Play/Pause" id="btn-play-pause">
|
||||
<svg viewBox="0 0 24 24" id="play-pause-icon"><path d="M8 5v14l11-7z"/></svg>
|
||||
</button>
|
||||
<button class="btn-trans" onclick="nextTrack()" data-i18n-title="player.next" title="Next" id="btn-next">
|
||||
<svg viewBox="0 0 24 24"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
|
||||
</button>
|
||||
|
||||
<div class="vu-cluster">
|
||||
<div class="vu-meter" aria-hidden="true">
|
||||
<div class="vu-needle" id="vuNeedle"></div>
|
||||
</div>
|
||||
<div class="vu-readout">
|
||||
<span>OUT <strong id="vu-out">SYS</strong></span>
|
||||
<span>VOL <strong id="vu-vol">50%</strong></span>
|
||||
</div>
|
||||
<!-- Volume control: mute + slim slider, integrated -->
|
||||
<div class="vu-volume">
|
||||
<button class="mute-btn" onclick="toggleMute()" data-i18n-title="player.mute" title="Mute" id="btn-mute">
|
||||
<svg viewBox="0 0 24 24" id="mute-icon">
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<input type="range" id="volume-slider" min="0" max="100" value="50" aria-label="Volume">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden but functional: legacy display + visualizer toggle. -->
|
||||
<div class="visually-hidden">
|
||||
<div id="volume-display">50%</div>
|
||||
<button onclick="toggleVisualizer()" id="visualizerToggle" data-i18n-title="player.visualizer" title="Audio visualizer" style="display:none">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M3 18h2v-8H3v8zm4 0h2V6H7v12zm4 0h2V2h-2v16zm4 0h2v-6h-2v6zm4 0h2V9h-2v9z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Media Browser Section -->
|
||||
@@ -283,6 +370,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>
|
||||
|
||||
@@ -316,6 +404,39 @@
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="settings-section" open id="mediaFoldersSection" style="display: none;">
|
||||
<summary data-i18n="settings.section.media_folders">Media Folders</summary>
|
||||
<div class="settings-section-content">
|
||||
<p class="settings-section-description" data-i18n="browser.folders_description">
|
||||
Media folders available for browsing. Folders on network shares show availability status.
|
||||
</p>
|
||||
<table class="scripts-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="browser.folders_table.id">ID</th>
|
||||
<th data-i18n="browser.folders_table.label">Label</th>
|
||||
<th data-i18n="browser.folders_table.path">Path</th>
|
||||
<th data-i18n="browser.folders_table.status">Status</th>
|
||||
<th data-i18n="browser.folders_table.actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="foldersTableBody">
|
||||
<tr>
|
||||
<td colspan="5" class="empty-state">
|
||||
<div class="empty-state-illustration">
|
||||
<svg viewBox="0 0 64 64"><path d="M8 16h20l4-6h20a4 4 0 014 4v36a4 4 0 01-4 4H8a4 4 0 01-4-4V20a4 4 0 014-4z"/><path d="M4 24h56" stroke-dasharray="4 3" opacity="0.4"/></svg>
|
||||
<p data-i18n="browser.folders_empty">No media folders configured. Click "+" to add one.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="add-card" 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">
|
||||
|
||||
@@ -21,11 +21,11 @@ import {
|
||||
activeTab, switchTab, updateTabIndicator, setMiniPlayerVisible,
|
||||
initTheme, toggleTheme, initAccentColor, applyAccentColor,
|
||||
renderAccentSwatches, selectAccentColor, toggleAccentPicker, lightenColor,
|
||||
toggleVinylMode, applyVinylMode,
|
||||
visualizerEnabled, visualizerAvailable,
|
||||
visualizerEnabled, visualizerAvailable, setVisualizerEnabled,
|
||||
checkVisualizerAvailability, toggleVisualizer, applyVisualizerMode,
|
||||
loadAudioDevices, onAudioDeviceChanged,
|
||||
setupProgressDrag, updateUI, updatePlaybackState, stopPositionInterpolation,
|
||||
togglePlayerFullscreen, initPlayerFullscreen,
|
||||
} from './player.js';
|
||||
|
||||
// Layer 2: WebSocket
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
onBrowserSearch, clearBrowserSearch, onItemsPerPageChanged,
|
||||
downloadFile, closeFolderDialog, saveFolder,
|
||||
showManageFoldersDialog,
|
||||
showAddFolderDialog, showEditFolderDialog, deleteFolderConfirm,
|
||||
} from './browser.js';
|
||||
|
||||
import {
|
||||
@@ -95,10 +96,12 @@ Object.assign(window, {
|
||||
switchTab,
|
||||
// Theme & accent
|
||||
toggleTheme, toggleAccentPicker, selectAccentColor, lightenColor,
|
||||
// Vinyl & visualizer
|
||||
toggleVinylMode, toggleVisualizer,
|
||||
// Visualizer (vinyl spin is structural CSS — no toggle)
|
||||
toggleVisualizer,
|
||||
// Background
|
||||
toggleDynamicBackground,
|
||||
// Fullscreen
|
||||
togglePlayerFullscreen,
|
||||
// Auth
|
||||
authenticate, clearToken, manualReconnect,
|
||||
// Locale
|
||||
@@ -117,6 +120,7 @@ Object.assign(window, {
|
||||
onBrowserSearch, clearBrowserSearch, onItemsPerPageChanged,
|
||||
downloadFile, closeFolderDialog, saveFolder,
|
||||
showManageFoldersDialog,
|
||||
showAddFolderDialog, showEditFolderDialog, deleteFolderConfirm,
|
||||
// Links
|
||||
showAddLinkDialog, showEditLinkDialog, closeLinkDialog,
|
||||
saveLink, deleteLinkConfirm,
|
||||
@@ -131,6 +135,23 @@ Object.assign(window, {
|
||||
// Initialization (DOMContentLoaded)
|
||||
// ============================================================
|
||||
|
||||
// Prevent <dialog>.showModal() from auto-focusing the first input field.
|
||||
// On touch devices this pops up the on-screen keyboard, which is confusing
|
||||
// when the user just opened a dialog. Force focus onto the dialog itself.
|
||||
const _origShowModal = HTMLDialogElement.prototype.showModal;
|
||||
HTMLDialogElement.prototype.showModal = function (...args) {
|
||||
if (!this.hasAttribute('tabindex')) {
|
||||
this.setAttribute('tabindex', '-1');
|
||||
}
|
||||
const result = _origShowModal.apply(this, args);
|
||||
const active = document.activeElement;
|
||||
if (active && active !== this && this.contains(active)) {
|
||||
active.blur();
|
||||
this.focus({ preventScroll: true });
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
window.addEventListener('DOMContentLoaded', async () => {
|
||||
// Cache DOM references
|
||||
cacheDom();
|
||||
@@ -138,18 +159,47 @@ window.addEventListener('DOMContentLoaded', async () => {
|
||||
// Initialize theme and accent color
|
||||
initTheme();
|
||||
initAccentColor();
|
||||
initPlayerFullscreen();
|
||||
|
||||
// Register service worker for PWA installability
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
}
|
||||
|
||||
// Initialize vinyl mode
|
||||
applyVinylMode();
|
||||
// Build the editorial spectrum bars. Fewer, fatter bars read better
|
||||
// than many thin ones at this column width. JS-managed so we can
|
||||
// drive heights from real audio data when available.
|
||||
const spectrumRoot = document.getElementById('player-spectrum');
|
||||
if (spectrumRoot && !spectrumRoot.children.length) {
|
||||
const SPECTRUM_BARS = 40;
|
||||
// CSS repeat() doesn't accept a var() for its count — set the
|
||||
// grid column template from JS so it always matches the bar
|
||||
// count and stretches each bar to claim 1fr of the row.
|
||||
spectrumRoot.style.gridTemplateColumns =
|
||||
`repeat(${SPECTRUM_BARS}, minmax(0, 1fr))`;
|
||||
const frag = document.createDocumentFragment();
|
||||
for (let i = 0; i < SPECTRUM_BARS; i++) {
|
||||
const s = document.createElement('span');
|
||||
// Pseudo-random initial scaleY for the synthetic CSS-only
|
||||
// animation (used while no real audio is flowing).
|
||||
const scale = (0.25 + Math.abs(Math.sin(i * 0.7)) * 0.70).toFixed(2);
|
||||
s.style.setProperty('--bar-h-scale', scale);
|
||||
s.style.setProperty('--bar-delay', (-Math.random() * 1.1).toFixed(2) + 's');
|
||||
frag.appendChild(s);
|
||||
}
|
||||
spectrumRoot.appendChild(frag);
|
||||
}
|
||||
|
||||
// Initialize audio visualizer
|
||||
// Initialize audio visualizer — auto-enable when supported so the
|
||||
// spectrum shows real audio out of the box.
|
||||
checkVisualizerAvailability().then(() => {
|
||||
if (visualizerEnabled && visualizerAvailable) {
|
||||
if (!visualizerAvailable) return;
|
||||
// First install: opt the user in by default since the spectrum
|
||||
// is the centerpiece of the player view.
|
||||
const stored = localStorage.getItem('visualizerEnabled');
|
||||
const shouldEnable = stored === null ? true : stored === 'true';
|
||||
if (shouldEnable) {
|
||||
setVisualizerEnabled(true); // updates the let in player.js
|
||||
applyVisualizerMode();
|
||||
}
|
||||
});
|
||||
@@ -323,6 +373,24 @@ window.addEventListener('DOMContentLoaded', async () => {
|
||||
else if (action === 'delete') deleteCallbackConfirm(name);
|
||||
});
|
||||
|
||||
// Folder dialog backdrop click to close
|
||||
const folderDialog = document.getElementById('folderDialog');
|
||||
folderDialog.addEventListener('click', (e) => {
|
||||
if (e.target === folderDialog) {
|
||||
closeFolderDialog();
|
||||
}
|
||||
});
|
||||
|
||||
// Delegated click handlers for folder table actions
|
||||
document.getElementById('foldersTableBody').addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('[data-action]');
|
||||
if (!btn) return;
|
||||
const action = btn.dataset.action;
|
||||
const folderId = btn.dataset.folderId;
|
||||
if (action === 'edit') showEditFolderDialog(folderId);
|
||||
else if (action === 'delete') deleteFolderConfirm(folderId);
|
||||
});
|
||||
|
||||
// Link dialog backdrop click to close
|
||||
const linkDialog = document.getElementById('linkDialog');
|
||||
linkDialog.addEventListener('click', (e) => {
|
||||
@@ -352,7 +420,7 @@ window.addEventListener('DOMContentLoaded', async () => {
|
||||
|
||||
// Initialize browser toolbar and load folders
|
||||
initBrowserToolbar();
|
||||
if (token) {
|
||||
if (!authReq || token) {
|
||||
loadMediaFolders();
|
||||
}
|
||||
|
||||
|
||||
@@ -236,27 +236,54 @@ export function updateBackgroundColors() {
|
||||
|
||||
// ---- Render loop ----
|
||||
|
||||
// Cached step into the bins array; recomputed only when bins.length
|
||||
// changes (which happens at most once after the first audio frame
|
||||
// arrives or when num_bins is reconfigured).
|
||||
let bgBinsLength = -1;
|
||||
let bgBinsStep = 1;
|
||||
// Last applied resolution — drawing with stale viewport is harmless,
|
||||
// but we still need to refresh the uniform after the resize listener
|
||||
// has updated the canvas.
|
||||
let bgLastResW = -1;
|
||||
let bgLastResH = -1;
|
||||
|
||||
function renderBackgroundFrame() {
|
||||
bgAnimFrame = requestAnimationFrame(renderBackgroundFrame);
|
||||
|
||||
const gl = bgGL;
|
||||
if (!gl || !bgUniforms) return;
|
||||
|
||||
resizeBackgroundCanvas();
|
||||
gl.viewport(0, 0, bgCanvas.width, bgCanvas.height);
|
||||
// Resize listener already keeps canvas dimensions in sync — only
|
||||
// touch the viewport when the canvas actually changed size, so the
|
||||
// per-frame path doesn't read window.innerWidth (a layout-flushing
|
||||
// property).
|
||||
if (bgCanvas.width !== bgLastResW || bgCanvas.height !== bgLastResH) {
|
||||
bgLastResW = bgCanvas.width;
|
||||
bgLastResH = bgCanvas.height;
|
||||
gl.viewport(0, 0, bgLastResW, bgLastResH);
|
||||
gl.uniform2f(bgUniforms.resolution, bgLastResW, bgLastResH);
|
||||
}
|
||||
|
||||
const time = performance.now() / 1000 - bgStartTime;
|
||||
|
||||
// Smooth audio data from the imported frequencyData (shared with visualizer)
|
||||
// Smooth audio data from the imported frequencyData (shared with visualizer).
|
||||
// Backend may send float bins (legacy) or int×1000 (new); .scale tells us which.
|
||||
if (frequencyData && frequencyData.frequencies) {
|
||||
const bins = frequencyData.frequencies;
|
||||
const step = Math.max(1, Math.floor(bins.length / BG_BAND_COUNT));
|
||||
const scale = frequencyData.scale && frequencyData.scale > 0
|
||||
? 1.0 / frequencyData.scale : 1.0;
|
||||
if (bins.length !== bgBinsLength) {
|
||||
bgBinsLength = bins.length;
|
||||
bgBinsStep = Math.max(1, Math.floor(bgBinsLength / BG_BAND_COUNT));
|
||||
}
|
||||
const step = bgBinsStep;
|
||||
for (let i = 0; i < BG_BAND_COUNT; i++) {
|
||||
const idx = Math.min(i * step, bins.length - 1);
|
||||
const target = bins[idx] || 0;
|
||||
let idx = i * step;
|
||||
if (idx >= bgBinsLength) idx = bgBinsLength - 1;
|
||||
const target = (bins[idx] || 0) * scale;
|
||||
bgSmoothedBands[i] += (target - bgSmoothedBands[i]) * (1 - BG_SMOOTHING);
|
||||
}
|
||||
const targetBass = frequencyData.bass || 0;
|
||||
const targetBass = (frequencyData.bass || 0) * scale;
|
||||
bgSmoothedBass += (targetBass - bgSmoothedBass) * (1 - BG_SMOOTHING);
|
||||
} else {
|
||||
// Gentle decay when no audio
|
||||
@@ -267,7 +294,6 @@ function renderBackgroundFrame() {
|
||||
}
|
||||
|
||||
// Set uniforms (locations cached at init, colors cached on change)
|
||||
gl.uniform2f(bgUniforms.resolution, bgCanvas.width, bgCanvas.height);
|
||||
gl.uniform1f(bgUniforms.time, time);
|
||||
gl.uniform1f(bgUniforms.bass, bgSmoothedBass);
|
||||
gl.uniform1fv(bgUniforms.bands, bgSmoothedBands);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// ============================================================
|
||||
|
||||
import {
|
||||
t, showToast, escapeHtml, closeDialog,
|
||||
t, showToast, showConfirm, escapeHtml, closeDialog,
|
||||
SEARCH_DEBOUNCE_MS, EMPTY_SVG_FILE, EMPTY_SVG_FOLDER, emptyStateHtml,
|
||||
getAuthHeaders, hasCredentials,
|
||||
} from './core.js';
|
||||
@@ -15,6 +15,7 @@ let currentOffset = 0;
|
||||
let itemsPerPage = parseInt(localStorage.getItem('mediaBrowser.itemsPerPage')) || 100;
|
||||
let totalItems = 0;
|
||||
let mediaFolders = {};
|
||||
let managementEnabled = false;
|
||||
let viewMode = localStorage.getItem('mediaBrowser.viewMode') || 'grid';
|
||||
let cachedItems = null;
|
||||
let browserSearchTerm = '';
|
||||
@@ -33,7 +34,20 @@ export async function loadMediaFolders() {
|
||||
|
||||
if (!response.ok) throw new Error('Failed to load folders');
|
||||
|
||||
mediaFolders = await response.json();
|
||||
const data = await response.json();
|
||||
mediaFolders = data.folders || {};
|
||||
managementEnabled = data.management_enabled || false;
|
||||
|
||||
// Show/hide the media folders settings section
|
||||
const section = document.getElementById('mediaFoldersSection');
|
||||
if (section) {
|
||||
section.style.display = managementEnabled ? '' : 'none';
|
||||
}
|
||||
|
||||
// Render folders table in settings if management is enabled
|
||||
if (managementEnabled) {
|
||||
loadFoldersTable();
|
||||
}
|
||||
|
||||
// Load last browsed path or show root folder list
|
||||
loadLastBrowserPath();
|
||||
@@ -69,41 +83,48 @@ function showRootFolders() {
|
||||
revokeBlobUrls(container);
|
||||
if (viewMode === 'list') {
|
||||
container.className = 'browser-list';
|
||||
} else if (viewMode === 'compact') {
|
||||
container.className = 'browser-grid browser-grid-compact';
|
||||
} else {
|
||||
container.className = 'browser-grid';
|
||||
container.className = 'browser-grid browser-root-grid';
|
||||
}
|
||||
container.innerHTML = '';
|
||||
|
||||
const folderSvg = '<svg viewBox="0 0 24 24" width="24" height="24"><path fill="currentColor" d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>';
|
||||
|
||||
Object.entries(mediaFolders).forEach(([id, folder]) => {
|
||||
if (!folder.enabled) return;
|
||||
const unavailable = folder.available === false;
|
||||
const unavailableClass = unavailable ? ' unavailable' : '';
|
||||
|
||||
if (viewMode === 'list') {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'browser-list-item';
|
||||
row.onclick = () => {
|
||||
currentFolderId = id;
|
||||
browsePath(id, '');
|
||||
};
|
||||
row.className = 'browser-list-item' + unavailableClass;
|
||||
if (!unavailable) {
|
||||
row.onclick = () => {
|
||||
currentFolderId = id;
|
||||
browsePath(id, '');
|
||||
};
|
||||
}
|
||||
row.innerHTML = `
|
||||
<div class="browser-list-icon">\u{1F4C1}</div>
|
||||
<div class="browser-list-name">${folder.label}</div>
|
||||
<div class="browser-list-icon" style="color: var(--accent)">${folderSvg}</div>
|
||||
<div class="browser-list-name">${escapeHtml(folder.label)}${unavailable ? ' <span class="folder-unavailable-badge">' + t('browser.unavailable') + '</span>' : ''}</div>
|
||||
`;
|
||||
container.appendChild(row);
|
||||
} else {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'browser-item';
|
||||
card.onclick = () => {
|
||||
currentFolderId = id;
|
||||
browsePath(id, '');
|
||||
};
|
||||
card.className = 'browser-item browser-root-folder' + unavailableClass;
|
||||
if (!unavailable) {
|
||||
card.onclick = () => {
|
||||
currentFolderId = id;
|
||||
browsePath(id, '');
|
||||
};
|
||||
}
|
||||
card.innerHTML = `
|
||||
<div class="browser-thumb-wrapper">
|
||||
<div class="browser-icon">\u{1F4C1}</div>
|
||||
<div class="browser-icon" style="color: var(--accent)">${folderSvg}</div>
|
||||
</div>
|
||||
<div class="browser-item-info">
|
||||
<div class="browser-item-name">${folder.label}</div>
|
||||
<div class="browser-item-name">${escapeHtml(folder.label)}</div>
|
||||
${unavailable ? '<div class="browser-item-meta folder-unavailable-badge">' + t('browser.unavailable') + '</div>' : ''}
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(card);
|
||||
@@ -248,6 +269,19 @@ function renderBrowserList(items, container) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Column header row
|
||||
const header = document.createElement('div');
|
||||
header.className = 'browser-list-header';
|
||||
header.innerHTML = `
|
||||
<span></span>
|
||||
<span>${t('browser.list_header.name')}</span>
|
||||
<span>${t('browser.list_header.bitrate')}</span>
|
||||
<span>${t('browser.list_header.duration')}</span>
|
||||
<span>${t('browser.list_header.size')}</span>
|
||||
<span></span>
|
||||
`;
|
||||
container.appendChild(header);
|
||||
|
||||
items.forEach((item, idx) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'browser-list-item';
|
||||
@@ -662,6 +696,7 @@ function renderPagination() {
|
||||
const nextBtn = document.getElementById('nextPage');
|
||||
const pageInput = document.getElementById('pageInput');
|
||||
const pageTotal = document.getElementById('pageTotal');
|
||||
const showingEl = document.getElementById('paginationShowing');
|
||||
|
||||
const totalPages = Math.ceil(totalItems / itemsPerPage);
|
||||
const currentPage = Math.floor(currentOffset / itemsPerPage) + 1;
|
||||
@@ -676,6 +711,13 @@ function renderPagination() {
|
||||
pageInput.max = totalPages;
|
||||
pageTotal.textContent = `/ ${totalPages}`;
|
||||
|
||||
// "Showing X-Y of Z"
|
||||
if (showingEl) {
|
||||
const from = currentOffset + 1;
|
||||
const to = Math.min(currentOffset + itemsPerPage, totalItems);
|
||||
showingEl.textContent = t('browser.showing_items', { from, to, total: totalItems });
|
||||
}
|
||||
|
||||
prevBtn.disabled = currentPage === 1;
|
||||
nextBtn.disabled = currentPage === totalPages;
|
||||
}
|
||||
@@ -845,10 +887,72 @@ function loadLastBrowserPath() {
|
||||
}
|
||||
}
|
||||
|
||||
// Folder Management
|
||||
export function showManageFoldersDialog() {
|
||||
// TODO: Implement folder management UI
|
||||
showToast(t('browser.manage_folders_hint'), 'info');
|
||||
// Folder Management — Settings table
|
||||
|
||||
export function loadFoldersTable() {
|
||||
const tbody = document.getElementById('foldersTableBody');
|
||||
if (!tbody) return;
|
||||
|
||||
const entries = Object.entries(mediaFolders);
|
||||
if (entries.length === 0) {
|
||||
tbody.innerHTML = `<tr><td colspan="5" class="empty-state">
|
||||
<div class="empty-state-illustration">
|
||||
<svg viewBox="0 0 64 64"><path d="M8 16h20l4-6h20a4 4 0 014 4v36a4 4 0 01-4 4H8a4 4 0 01-4-4V20a4 4 0 014-4z"/><path d="M4 24h56" stroke-dasharray="4 3" opacity="0.4"/></svg>
|
||||
<p data-i18n="browser.folders_empty">${t('browser.folders_empty')}</p>
|
||||
</div></td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = entries.map(([id, folder]) => {
|
||||
const available = folder.available !== false;
|
||||
const statusIcon = available
|
||||
? '<span class="status-dot status-online">' + t('browser.folder_available') + '</span>'
|
||||
: '<span class="status-dot status-offline">' + t('browser.folder_unavailable') + '</span>';
|
||||
const enabledBadge = folder.enabled
|
||||
? ''
|
||||
: ' <span class="folder-disabled-badge">' + t('browser.folder_disabled') + '</span>';
|
||||
return `<tr>
|
||||
<td>${escapeHtml(id)}${enabledBadge}</td>
|
||||
<td>${escapeHtml(folder.label)}</td>
|
||||
<td class="path-cell" title="${escapeHtml(folder.path)}">${escapeHtml(folder.path)}</td>
|
||||
<td>${statusIcon}</td>
|
||||
<td class="actions-cell">
|
||||
<button class="btn-icon" data-action="edit" data-folder-id="${escapeHtml(id)}" title="${t('browser.folder_edit')}">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
|
||||
</button>
|
||||
<button class="btn-icon btn-danger-icon" data-action="delete" data-folder-id="${escapeHtml(id)}" title="${t('browser.folder_delete')}">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
export function showAddFolderDialog() {
|
||||
document.getElementById('folderDialogTitle').textContent = t('browser.folder_dialog.title_add');
|
||||
document.getElementById('folderIsEdit').value = '';
|
||||
document.getElementById('folderOriginalId').value = '';
|
||||
document.getElementById('folderId').value = '';
|
||||
document.getElementById('folderId').disabled = false;
|
||||
document.getElementById('folderLabel').value = '';
|
||||
document.getElementById('folderPath').value = '';
|
||||
document.getElementById('folderEnabled').checked = true;
|
||||
document.getElementById('folderDialog').showModal();
|
||||
}
|
||||
|
||||
export function showEditFolderDialog(folderId) {
|
||||
const folder = mediaFolders[folderId];
|
||||
if (!folder) return;
|
||||
|
||||
document.getElementById('folderDialogTitle').textContent = t('browser.folder_dialog.title_edit');
|
||||
document.getElementById('folderIsEdit').value = '1';
|
||||
document.getElementById('folderOriginalId').value = folderId;
|
||||
document.getElementById('folderId').value = folderId;
|
||||
document.getElementById('folderId').disabled = true;
|
||||
document.getElementById('folderLabel').value = folder.label;
|
||||
document.getElementById('folderPath').value = folder.path;
|
||||
document.getElementById('folderEnabled').checked = folder.enabled;
|
||||
document.getElementById('folderDialog').showModal();
|
||||
}
|
||||
|
||||
export function closeFolderDialog() {
|
||||
@@ -857,5 +961,90 @@ export function closeFolderDialog() {
|
||||
|
||||
export async function saveFolder(event) {
|
||||
event.preventDefault();
|
||||
closeFolderDialog();
|
||||
|
||||
const isEdit = document.getElementById('folderIsEdit').value === '1';
|
||||
const folderId = isEdit
|
||||
? document.getElementById('folderOriginalId').value
|
||||
: document.getElementById('folderId').value.trim();
|
||||
const label = document.getElementById('folderLabel').value.trim();
|
||||
const path = document.getElementById('folderPath').value.trim();
|
||||
const enabled = document.getElementById('folderEnabled').checked;
|
||||
|
||||
if (!folderId || !label || !path) return;
|
||||
|
||||
const submitBtn = document.querySelector('#folderForm button[type="submit"]');
|
||||
if (submitBtn) submitBtn.disabled = true;
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (isEdit) {
|
||||
response = await fetch(`/api/browser/folders/update/${encodeURIComponent(folderId)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
||||
body: JSON.stringify({ label, path, enabled }),
|
||||
});
|
||||
} else {
|
||||
response = await fetch('/api/browser/folders/create', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
||||
body: JSON.stringify({ folder_id: folderId, label, path, enabled }),
|
||||
});
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
closeFolderDialog();
|
||||
showToast(t(isEdit ? 'browser.folder_updated' : 'browser.folder_created'), 'success');
|
||||
await loadMediaFolders();
|
||||
} else {
|
||||
const result = await response.json().catch(() => ({}));
|
||||
showToast(result.detail || t('browser.folder_save_error'), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving folder:', error);
|
||||
showToast(t('browser.folder_save_error'), 'error');
|
||||
} finally {
|
||||
if (submitBtn) submitBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteFolderConfirm(folderId) {
|
||||
if (!await showConfirm(t('browser.folder_confirm_delete', { name: folderId }))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/browser/folders/delete/${encodeURIComponent(folderId)}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showToast(t('browser.folder_deleted'), 'success');
|
||||
await loadMediaFolders();
|
||||
} else {
|
||||
const result = await response.json().catch(() => ({}));
|
||||
showToast(result.detail || t('browser.folder_delete_error'), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting folder:', error);
|
||||
showToast(t('browser.folder_delete_error'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy stub — now handled via settings table
|
||||
export function showManageFoldersDialog() {
|
||||
if (managementEnabled) {
|
||||
// Switch to settings tab and scroll to the folders section
|
||||
const switchTabFn = window.switchTab;
|
||||
if (switchTabFn) switchTabFn('settings');
|
||||
setTimeout(() => {
|
||||
const section = document.getElementById('mediaFoldersSection');
|
||||
if (section) {
|
||||
section.setAttribute('open', '');
|
||||
section.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, 100);
|
||||
} else {
|
||||
showToast(t('browser.manage_folders_hint'), 'info');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +116,8 @@ export function cacheDom() {
|
||||
dom.progressFill = document.getElementById('progress-fill');
|
||||
dom.currentTime = document.getElementById('current-time');
|
||||
dom.totalTime = document.getElementById('total-time');
|
||||
dom.metaElapsed = document.getElementById('meta-elapsed');
|
||||
dom.metaLength = document.getElementById('meta-length');
|
||||
dom.progressBar = document.getElementById('progress-bar');
|
||||
dom.miniProgressFill = document.getElementById('mini-progress-fill');
|
||||
dom.miniCurrentTime = document.getElementById('mini-current-time');
|
||||
@@ -138,7 +140,10 @@ export function cacheDom() {
|
||||
|
||||
// Timing constants
|
||||
export const VOLUME_THROTTLE_MS = 16;
|
||||
export const POSITION_INTERPOLATION_MS = 100;
|
||||
// 250ms is plenty for sub-second progress; the inline updateProgress
|
||||
// also short-circuits when the rounded second hasn't moved, so there's
|
||||
// no visible difference for the user.
|
||||
export const POSITION_INTERPOLATION_MS = 250;
|
||||
export const SEARCH_DEBOUNCE_MS = 200;
|
||||
export const TOAST_DURATION_MS = 3000;
|
||||
export const WS_BACKOFF_BASE_MS = 3000;
|
||||
@@ -317,6 +322,11 @@ export async function fetchVersion() {
|
||||
const label = document.getElementById('version-label');
|
||||
if (data.version) {
|
||||
label.textContent = `v${data.version}`;
|
||||
const folioVersion = document.getElementById('folio-version');
|
||||
if (folioVersion) folioVersion.textContent = `v${data.version}`;
|
||||
}
|
||||
if (data.update_available) {
|
||||
showUpdateBanner(data.update_available);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -324,6 +334,26 @@ export async function fetchVersion() {
|
||||
}
|
||||
}
|
||||
|
||||
export function showUpdateBanner(update) {
|
||||
const dismissed = sessionStorage.getItem('update_dismissed');
|
||||
if (dismissed === update.latest) return;
|
||||
|
||||
const banner = document.getElementById('updateBanner');
|
||||
const text = document.getElementById('updateBannerText');
|
||||
const link = document.getElementById('updateBannerLink');
|
||||
const closeBtn = document.getElementById('updateBannerClose');
|
||||
|
||||
text.textContent = t('update.available', { version: update.latest });
|
||||
link.href = update.url;
|
||||
link.textContent = t('update.view_release');
|
||||
banner.classList.remove('hidden');
|
||||
|
||||
closeBtn.onclick = () => {
|
||||
banner.classList.add('hidden');
|
||||
sessionStorage.setItem('update_dismissed', update.latest);
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Shared Utilities
|
||||
// ============================================================
|
||||
|
||||
@@ -57,7 +57,13 @@ export async function loadDisplayMonitors() {
|
||||
|
||||
const details = [monitor.resolution, monitor.manufacturer].filter(Boolean).join(' \u00B7 ');
|
||||
const detailsHtml = details ? `<span class="display-monitor-details">${details}</span>` : '';
|
||||
const primaryBadge = monitor.is_primary ? `<span class="display-primary-badge">${t('display.primary')}</span>` : '';
|
||||
const primaryBadge = monitor.is_primary
|
||||
? `<span class="display-primary-badge" title="${t('display.primary')}" aria-label="${t('display.primary')}">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
|
||||
<path fill="currentColor" d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
|
||||
</svg>
|
||||
</span>`
|
||||
: '';
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="display-monitor-header">
|
||||
|
||||
+647
-143
@@ -19,6 +19,8 @@ import { IconSelect } from './icon-select.js';
|
||||
export let activeTab = 'player';
|
||||
|
||||
export function setMiniPlayerVisible(visible) {
|
||||
// On any non-player tab the mini player must stay visible regardless of scroll.
|
||||
if (activeTab !== 'player') visible = true;
|
||||
const miniPlayer = document.getElementById('mini-player');
|
||||
if (visible) {
|
||||
miniPlayer.classList.remove('hidden');
|
||||
@@ -143,6 +145,22 @@ export function lightenColor(hex, percent) {
|
||||
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
|
||||
}
|
||||
|
||||
function darkenColor(hex, percent) {
|
||||
const num = parseInt(hex.replace('#', ''), 16);
|
||||
const r = Math.max(0, (num >> 16) - Math.round(255 * percent / 100));
|
||||
const g = Math.max(0, ((num >> 8) & 0xff) - Math.round(255 * percent / 100));
|
||||
const b = Math.max(0, (num & 0xff) - Math.round(255 * percent / 100));
|
||||
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
|
||||
}
|
||||
|
||||
function hexToRgbTriple(hex) {
|
||||
const num = parseInt(hex.replace('#', ''), 16);
|
||||
const r = (num >> 16) & 0xff;
|
||||
const g = (num >> 8) & 0xff;
|
||||
const b = num & 0xff;
|
||||
return `${r}, ${g}, ${b}`;
|
||||
}
|
||||
|
||||
export function initAccentColor() {
|
||||
const saved = localStorage.getItem('accentColor');
|
||||
if (saved) {
|
||||
@@ -157,12 +175,25 @@ export function initAccentColor() {
|
||||
}
|
||||
|
||||
export function applyAccentColor(color, hover) {
|
||||
document.documentElement.style.setProperty('--accent', color);
|
||||
document.documentElement.style.setProperty('--accent-hover', hover);
|
||||
const root = document.documentElement.style;
|
||||
root.setProperty('--accent', color);
|
||||
root.setProperty('--accent-hover', hover);
|
||||
// Editorial palette tokens — the redesign reads these directly,
|
||||
// so the picker must drive them too (the --accent alias alone has
|
||||
// no effect once components moved off it).
|
||||
root.setProperty('--copper', color);
|
||||
root.setProperty('--copper-hi', hover);
|
||||
root.setProperty('--copper-lo', darkenColor(color, 12));
|
||||
root.setProperty('--copper-rgb', hexToRgbTriple(color));
|
||||
// --copper-glow inherits the rgba(var(--copper-rgb), 0.35) formula
|
||||
// declared in styles.css, so it picks up the new RGB automatically.
|
||||
localStorage.setItem('accentColor', color);
|
||||
const dot = document.getElementById('accentDot');
|
||||
if (dot) dot.style.background = color;
|
||||
updateBackgroundColors();
|
||||
// Refresh the cached accent in the visualizer so the gradient
|
||||
// rebuilds on its next frame instead of querying CSS every frame.
|
||||
refreshVisualizerAccent();
|
||||
}
|
||||
|
||||
export function renderAccentSwatches() {
|
||||
@@ -206,81 +237,56 @@ document.addEventListener('click', (e) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 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;
|
||||
export function setVisualizerEnabled(value) {
|
||||
visualizerEnabled = !!value;
|
||||
localStorage.setItem('visualizerEnabled', visualizerEnabled);
|
||||
}
|
||||
let visualizerCanvas = null; // Cached canvas DOM ref
|
||||
let visualizerCtx = null;
|
||||
let visualizerGradient = null; // Pre-built gradient (rebuilt on accent change / resize)
|
||||
let visualizerAnimFrame = null;
|
||||
export let frequencyData = null;
|
||||
export function setFrequencyData(value) { frequencyData = value; }
|
||||
export let frequencyData = null; // Latest payload from backend (int-scaled or float-scaled)
|
||||
let frequencyDataVersion = 0; // Bumped on every setFrequencyData
|
||||
let lastRenderedVersion = -1; // Last version rendered in renderVisualizerFrame
|
||||
let frequenciesScale = 1.0; // Backend scale factor (1000 → ints, 1 → floats)
|
||||
export function setFrequencyData(value) {
|
||||
frequencyData = value;
|
||||
frequencyDataVersion++;
|
||||
// Backend may send integer-quantized bins (scale=1000) or legacy floats (no scale).
|
||||
if (value && typeof value.scale === 'number' && value.scale > 0) {
|
||||
frequenciesScale = 1.0 / value.scale;
|
||||
} else {
|
||||
frequenciesScale = 1.0;
|
||||
}
|
||||
}
|
||||
let smoothedFrequencies = null;
|
||||
const VISUALIZER_SMOOTHING = 0.15;
|
||||
// Cached accent — refreshed by applyAccentColor() rather than on every frame.
|
||||
let cachedAccentHex = '#1db954';
|
||||
let cachedAccentRGB = '29,185,84';
|
||||
function parseAccentHex(hex) {
|
||||
const h = (hex || '').trim().replace('#', '');
|
||||
if (h.length < 6) return null;
|
||||
const r = parseInt(h.slice(0, 2), 16);
|
||||
const g = parseInt(h.slice(2, 4), 16);
|
||||
const b = parseInt(h.slice(4, 6), 16);
|
||||
if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return null;
|
||||
return `${r},${g},${b}`;
|
||||
}
|
||||
export function refreshVisualizerAccent() {
|
||||
const accentHex = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--accent').trim();
|
||||
if (accentHex) {
|
||||
cachedAccentHex = accentHex;
|
||||
const rgb = parseAccentHex(accentHex);
|
||||
if (rgb) cachedAccentRGB = rgb;
|
||||
}
|
||||
// Force gradient rebuild on next frame.
|
||||
visualizerGradient = null;
|
||||
}
|
||||
|
||||
export async function checkVisualizerAvailability() {
|
||||
try {
|
||||
@@ -334,15 +340,28 @@ export function applyVisualizerMode() {
|
||||
}
|
||||
|
||||
function initVisualizerCanvas() {
|
||||
const canvas = document.getElementById('spectrogram-canvas');
|
||||
if (!canvas) return;
|
||||
visualizerCtx = canvas.getContext('2d');
|
||||
canvas.width = 300;
|
||||
canvas.height = 64;
|
||||
visualizerCanvas = document.getElementById('spectrogram-canvas');
|
||||
if (!visualizerCanvas) return;
|
||||
visualizerCtx = visualizerCanvas.getContext('2d');
|
||||
visualizerCanvas.width = 300;
|
||||
visualizerCanvas.height = 64;
|
||||
visualizerGradient = null; // Force rebuild
|
||||
refreshVisualizerAccent();
|
||||
}
|
||||
|
||||
function buildVisualizerGradient() {
|
||||
if (!visualizerCtx || !visualizerCanvas) return null;
|
||||
const h = visualizerCanvas.height;
|
||||
const grad = visualizerCtx.createLinearGradient(0, 0, 0, h);
|
||||
grad.addColorStop(0, `rgba(${cachedAccentRGB},1)`);
|
||||
grad.addColorStop(1, `rgba(${cachedAccentRGB},0.19)`);
|
||||
return grad;
|
||||
}
|
||||
|
||||
function startVisualizerRender() {
|
||||
if (visualizerAnimFrame) return;
|
||||
// Cache editorial spectrum bar refs once per start.
|
||||
cacheEditorialSpectrumBars();
|
||||
renderVisualizerFrame();
|
||||
}
|
||||
|
||||
@@ -351,74 +370,153 @@ export function stopVisualizerRender() {
|
||||
cancelAnimationFrame(visualizerAnimFrame);
|
||||
visualizerAnimFrame = null;
|
||||
}
|
||||
const canvas = document.getElementById('spectrogram-canvas');
|
||||
if (visualizerCtx && canvas) {
|
||||
visualizerCtx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
if (visualizerCtx && visualizerCanvas) {
|
||||
visualizerCtx.clearRect(0, 0, visualizerCanvas.width, visualizerCanvas.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;
|
||||
frequencyDataVersion++; // Force next render to redraw cleared state
|
||||
lastRenderedVersion = -1;
|
||||
smoothedFrequencies = null;
|
||||
document.body.classList.remove('audio-spectrum-live');
|
||||
// Reset spectrum bar transforms so the synthetic CSS animation takes back over.
|
||||
if (editorialSpectrumBars) {
|
||||
for (let i = 0; i < editorialSpectrumBars.length; i++) {
|
||||
editorialSpectrumBars[i].style.transform = '';
|
||||
}
|
||||
}
|
||||
// Drop cached bars so next start re-queries.
|
||||
editorialSpectrumBars = null;
|
||||
editorialSpectrumLastScale = null;
|
||||
}
|
||||
|
||||
function renderVisualizerFrame() {
|
||||
visualizerAnimFrame = requestAnimationFrame(renderVisualizerFrame);
|
||||
|
||||
const canvas = document.getElementById('spectrogram-canvas');
|
||||
if (!frequencyData || !visualizerCtx || !canvas) return;
|
||||
// VU needle + position progress always tick — they read live state
|
||||
// not bound to spectrum payloads. Keeping them in this single rAF
|
||||
// is cheaper than running a second rAF loop just for the needle.
|
||||
tickVuNeedle();
|
||||
|
||||
if (!frequencyData || !visualizerCtx || !visualizerCanvas) return;
|
||||
|
||||
// FPS gate: backend pushes ~visualizer_fps Hz; the monitor refreshes
|
||||
// at 60-144 Hz. Re-rendering an unchanged frame is wasted work, so
|
||||
// bail when no new payload has arrived since the last draw.
|
||||
if (frequencyDataVersion === lastRenderedVersion) return;
|
||||
lastRenderedVersion = frequencyDataVersion;
|
||||
|
||||
const bins = frequencyData.frequencies;
|
||||
const numBins = bins.length;
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
const w = visualizerCanvas.width;
|
||||
const h = visualizerCanvas.height;
|
||||
const gap = 2;
|
||||
const barWidth = (w / numBins) - gap;
|
||||
const accent = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--accent').trim();
|
||||
const scale = frequenciesScale;
|
||||
|
||||
if (!smoothedFrequencies || smoothedFrequencies.length !== numBins) {
|
||||
smoothedFrequencies = new Array(numBins).fill(0);
|
||||
smoothedFrequencies = new Float32Array(numBins);
|
||||
}
|
||||
for (let i = 0; i < numBins; i++) {
|
||||
const v = bins[i] * scale;
|
||||
smoothedFrequencies[i] = smoothedFrequencies[i] * VISUALIZER_SMOOTHING
|
||||
+ bins[i] * (1 - VISUALIZER_SMOOTHING);
|
||||
+ v * (1 - VISUALIZER_SMOOTHING);
|
||||
}
|
||||
|
||||
visualizerCtx.clearRect(0, 0, w, h);
|
||||
if (!visualizerGradient) visualizerGradient = buildVisualizerGradient();
|
||||
|
||||
visualizerCtx.clearRect(0, 0, w, h);
|
||||
visualizerCtx.fillStyle = visualizerGradient;
|
||||
visualizerCtx.beginPath();
|
||||
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();
|
||||
}
|
||||
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})`;
|
||||
}
|
||||
// Drive the editorial .spectrum bars from the same frequency data.
|
||||
updateEditorialSpectrum(smoothedFrequencies, numBins);
|
||||
}
|
||||
|
||||
// ─── Editorial spectrum (.spectrum bars) driven by audio ──────
|
||||
// The bin distribution from the FFT is heavy on lows (the bass + mids
|
||||
// dominate); a linear mapping leaves the right half of the spectrum
|
||||
// looking dead. Use a logarithmic frequency-to-bar mapping plus a
|
||||
// per-bar high-end gain so all bars carry visible motion.
|
||||
let editorialSpectrumBars = null; // Live HTMLCollection cached at start
|
||||
let editorialSpectrumBarCount = 0;
|
||||
let editorialSpectrumLastScale = null; // Float32Array of last applied scaleY × 1000 (int rounded)
|
||||
let editorialBarRanges = null; // Pre-computed [startIdx,endIdx] pairs per bar
|
||||
let editorialBarGains = null; // Pre-computed per-bar gain
|
||||
let editorialBarRangesForBins = -1; // numBins last used to compute ranges
|
||||
|
||||
function cacheEditorialSpectrumBars() {
|
||||
const root = document.querySelector('.now-playing .spectrum');
|
||||
if (!root) {
|
||||
editorialSpectrumBars = null;
|
||||
editorialSpectrumBarCount = 0;
|
||||
return;
|
||||
}
|
||||
const glow = document.getElementById('album-art-glow');
|
||||
if (glow) {
|
||||
glow.style.opacity = (0.4 + bass * 0.4).toFixed(2);
|
||||
editorialSpectrumBars = root.children;
|
||||
editorialSpectrumBarCount = editorialSpectrumBars.length;
|
||||
editorialSpectrumLastScale = new Int16Array(editorialSpectrumBarCount);
|
||||
editorialSpectrumLastScale.fill(-1);
|
||||
// Pre-compute per-bar gain (constant for the lifetime of the bar list).
|
||||
editorialBarGains = new Float32Array(editorialSpectrumBarCount);
|
||||
for (let i = 0; i < editorialSpectrumBarCount; i++) {
|
||||
editorialBarGains[i] = 1 + (i / editorialSpectrumBarCount) * 0.8;
|
||||
}
|
||||
editorialBarRangesForBins = -1; // Force range recompute on next call
|
||||
}
|
||||
|
||||
function recomputeEditorialBarRanges(numBins) {
|
||||
const barCount = editorialSpectrumBarCount;
|
||||
editorialBarRanges = new Int16Array(barCount * 2);
|
||||
const lowBin = 1;
|
||||
const highBin = numBins - 1;
|
||||
const span = highBin - lowBin;
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
const t0 = i / barCount;
|
||||
const t1 = (i + 1) / barCount;
|
||||
const startIdx = Math.max(lowBin, Math.floor(lowBin + t0 * t0 * span));
|
||||
const endIdx = Math.max(startIdx + 1, Math.floor(lowBin + t1 * t1 * span));
|
||||
editorialBarRanges[i * 2] = startIdx;
|
||||
editorialBarRanges[i * 2 + 1] = Math.min(endIdx, numBins);
|
||||
}
|
||||
editorialBarRangesForBins = numBins;
|
||||
}
|
||||
|
||||
function updateEditorialSpectrum(bins, numBins) {
|
||||
if (!editorialSpectrumBars) cacheEditorialSpectrumBars();
|
||||
const barCount = editorialSpectrumBarCount;
|
||||
if (!barCount) return;
|
||||
if (editorialBarRangesForBins !== numBins) recomputeEditorialBarRanges(numBins);
|
||||
document.body.classList.add('audio-spectrum-live');
|
||||
|
||||
const ranges = editorialBarRanges;
|
||||
const gains = editorialBarGains;
|
||||
const lastScale = editorialSpectrumLastScale;
|
||||
const bars = editorialSpectrumBars;
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
const startIdx = ranges[i * 2];
|
||||
const endIdx = ranges[i * 2 + 1];
|
||||
let peak = 0;
|
||||
for (let j = startIdx; j < endIdx; j++) {
|
||||
const v = bins[j];
|
||||
if (v > peak) peak = v;
|
||||
}
|
||||
// Backend ships AGC-normalized bins (peak ~1, transients up to ~1.5).
|
||||
// Map to a 0.12..1.0 scaleY, with 0.12 floor so silent bars stay visible.
|
||||
const raw = peak * 0.65 * gains[i];
|
||||
const scaleY = raw < 0.12 ? 0.12 : (raw > 1 ? 1 : raw);
|
||||
// Quantize to 1/1000 — anything finer is invisible. Skip the DOM
|
||||
// write when the bar hasn't moved.
|
||||
const q = (scaleY * 1000) | 0;
|
||||
if (q === lastScale[i]) continue;
|
||||
lastScale[i] = q;
|
||||
// transform: scaleY runs on the compositor — no layout/paint.
|
||||
bars[i].style.transform = `scaleY(${scaleY.toFixed(3)})`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -460,13 +558,24 @@ export async function loadAudioDevices() {
|
||||
select.appendChild(opt);
|
||||
}
|
||||
|
||||
if (status.current_device) {
|
||||
// Prefer server-reported device; fall back to the last user choice
|
||||
// saved in localStorage (so reloads persist even if the server
|
||||
// forgets between restarts).
|
||||
const savedDevice = localStorage.getItem('audioDevice') || '';
|
||||
const targetDevice = status.current_device || savedDevice;
|
||||
let pendingPushToServer = false;
|
||||
if (targetDevice) {
|
||||
for (let i = 0; i < select.options.length; i++) {
|
||||
if (select.options[i].value === status.current_device) {
|
||||
if (select.options[i].value === targetDevice) {
|
||||
select.selectedIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// If the saved device wasn't on the server, push it back so
|
||||
// capture starts on the right one.
|
||||
if (!status.current_device && savedDevice) {
|
||||
pendingPushToServer = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Enhance with icon grid
|
||||
@@ -485,7 +594,33 @@ export async function loadAudioDevices() {
|
||||
});
|
||||
_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' }));
|
||||
}
|
||||
}
|
||||
|
||||
// If the user's previously-chosen device wasn't recognized by
|
||||
// the server (e.g. server restart cleared in-memory state),
|
||||
// push it back so capture lands on the right one.
|
||||
if (pendingPushToServer && savedDevice) {
|
||||
try {
|
||||
await fetch('/api/media/visualizer/device', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
||||
body: JSON.stringify({ device_name: savedDevice })
|
||||
});
|
||||
} catch (_) { /* best-effort */ }
|
||||
}
|
||||
} catch (e) {
|
||||
section.style.display = 'none';
|
||||
}
|
||||
@@ -513,6 +648,13 @@ export async function onAudioDeviceChanged() {
|
||||
|
||||
const deviceName = select.value || null;
|
||||
|
||||
// Persist locally so reloads survive even if the server doesn't.
|
||||
if (deviceName) {
|
||||
localStorage.setItem('audioDevice', deviceName);
|
||||
} else {
|
||||
localStorage.removeItem('audioDevice');
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/media/visualizer/device', {
|
||||
method: 'POST',
|
||||
@@ -524,7 +666,12 @@ export async function onAudioDeviceChanged() {
|
||||
const result = await resp.json();
|
||||
updateAudioDeviceStatus({ available: result.success, ...result });
|
||||
await checkVisualizerAvailability();
|
||||
if (visualizerEnabled) applyVisualizerMode();
|
||||
// Picking a device is an explicit signal the user wants
|
||||
// capture: auto-enable the visualizer if it isn't already on.
|
||||
if (!visualizerEnabled && visualizerAvailable) {
|
||||
setVisualizerEnabled(true);
|
||||
}
|
||||
applyVisualizerMode();
|
||||
showToast(t('settings.audio.device_changed'), 'success');
|
||||
} else {
|
||||
showToast(t('settings.audio.device_change_failed'), 'error');
|
||||
@@ -596,9 +743,49 @@ export function setupProgressDrag(bar, fill) {
|
||||
});
|
||||
}
|
||||
|
||||
// Replace the album-art src and replay the .is-swapping CSS animation
|
||||
// so the new artwork crossfades in instead of popping. Re-toggling the
|
||||
// class across rAF restarts the keyframes even if it was already on.
|
||||
//
|
||||
// `forceAnim=false` skips the keyframe-restart reflow when the element
|
||||
// has never run the swap animation before — saves a synchronous layout
|
||||
// flush on first paint. The reflow IS still required when the class
|
||||
// is currently applied; otherwise the browser coalesces add+remove and
|
||||
// the keyframes don't replay.
|
||||
function swapArtworkSrc(imgEl, newSrc) {
|
||||
if (!imgEl) return;
|
||||
if (imgEl.src === newSrc) return;
|
||||
const wasSwapping = imgEl.classList.contains('is-swapping');
|
||||
if (wasSwapping) {
|
||||
imgEl.classList.remove('is-swapping');
|
||||
// Forced reflow restarts the keyframes — only needed when we have
|
||||
// to interrupt an in-flight animation.
|
||||
void imgEl.offsetWidth;
|
||||
}
|
||||
imgEl.src = newSrc;
|
||||
imgEl.classList.add('is-swapping');
|
||||
}
|
||||
|
||||
// Hash of the last fully-rendered status payload — lets us skip
|
||||
// updateUI altogether when the backend re-broadcasts the same state.
|
||||
let lastStatusFingerprint = null;
|
||||
function statusFingerprint(s) {
|
||||
return [
|
||||
s.state, s.title, s.artist, s.album, s.volume, s.muted,
|
||||
s.duration, s.source, s.album_art_url, s.position
|
||||
].join('|');
|
||||
}
|
||||
|
||||
export function updateUI(status) {
|
||||
setLastStatus(status);
|
||||
|
||||
// Idempotence: if nothing meaningful changed, skip the entire DOM
|
||||
// pass. Track switches arrive as 1-3 status_update broadcasts in
|
||||
// quick succession; this gates the redundant ones.
|
||||
const fingerprint = statusFingerprint(status);
|
||||
if (fingerprint === lastStatusFingerprint) return;
|
||||
lastStatusFingerprint = fingerprint;
|
||||
|
||||
const fallbackTitle = status.state === 'idle' ? t('player.no_media') : t('player.title_unavailable');
|
||||
dom.trackTitle.textContent = status.title || fallbackTitle;
|
||||
dom.artist.textContent = status.artist || '';
|
||||
@@ -622,10 +809,13 @@ export function updateUI(status) {
|
||||
|
||||
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 placeholderArt = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Cpath fill='%236a6a6a' opacity='0.35' d='M150 80c-38.66 0-70 31.34-70 70s31.34 70 70 70 70-31.34 70-70-31.34-70-70-70zm0 20c27.614 0 50 22.386 50 50s-22.386 50-50 50-50-22.386-50-50 22.386-50 50-50zm0 30a20 20 0 100 40 20 20 0 000-40z'/%3E%3C/svg%3E";
|
||||
const placeholderGlow = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Crect fill='%23282828' width='300' height='300'/%3E%3C/svg%3E";
|
||||
if (artworkSource) {
|
||||
fetch(`/api/media/artwork?_=${Date.now()}`, {
|
||||
// No cache-buster: when album_art_url is unchanged the
|
||||
// browser can reuse the decoded bitmap. The artworkKey gate
|
||||
// already skips fetches when the user hasn't switched tracks.
|
||||
fetch('/api/media/artwork', {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
.then(r => r.ok ? r.blob() : null)
|
||||
@@ -634,9 +824,12 @@ export function updateUI(status) {
|
||||
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;
|
||||
swapArtworkSrc(dom.albumArt, url);
|
||||
if (dom.miniAlbumArt.src !== url) dom.miniAlbumArt.src = url;
|
||||
if (dom.albumArtGlow && dom.albumArtGlow.src !== url) dom.albumArtGlow.src = url;
|
||||
// Mirror to fullscreen bloom directly — drops the
|
||||
// MutationObserver fan-out path.
|
||||
syncFullscreenBloomArt(url);
|
||||
if (oldBlobUrl) setTimeout(() => URL.revokeObjectURL(oldBlobUrl), 1000);
|
||||
})
|
||||
.catch(err => console.error('Artwork fetch failed:', err));
|
||||
@@ -645,18 +838,23 @@ export function updateUI(status) {
|
||||
URL.revokeObjectURL(currentArtworkBlobUrl);
|
||||
currentArtworkBlobUrl = null;
|
||||
}
|
||||
dom.albumArt.src = placeholderArt;
|
||||
dom.miniAlbumArt.src = placeholderArt;
|
||||
if (dom.albumArtGlow) dom.albumArtGlow.src = placeholderGlow;
|
||||
swapArtworkSrc(dom.albumArt, placeholderArt);
|
||||
if (dom.miniAlbumArt.src !== placeholderArt) dom.miniAlbumArt.src = placeholderArt;
|
||||
if (dom.albumArtGlow && dom.albumArtGlow.src !== placeholderGlow) dom.albumArtGlow.src = placeholderGlow;
|
||||
syncFullscreenBloomArt(placeholderGlow);
|
||||
}
|
||||
}
|
||||
|
||||
if (status.duration && status.position !== null) {
|
||||
// Only redo the progress DOM when position actually changed.
|
||||
const positionChanged =
|
||||
status.duration !== currentDuration ||
|
||||
Math.abs((status.position || 0) - (lastPositionValue || 0)) > 0.05;
|
||||
setCurrentDuration(status.duration);
|
||||
setCurrentPosition(status.position);
|
||||
lastPositionUpdate = Date.now();
|
||||
lastPositionValue = status.position;
|
||||
updateProgress(status.position, status.duration);
|
||||
if (positionChanged) updateProgress(status.position, status.duration);
|
||||
}
|
||||
|
||||
if (!isUserAdjustingVolume) {
|
||||
@@ -664,6 +862,17 @@ export function updateUI(status) {
|
||||
dom.volumeDisplay.textContent = `${status.volume}%`;
|
||||
dom.miniVolumeSlider.value = status.volume;
|
||||
dom.miniVolumeDisplay.textContent = `${status.volume}%`;
|
||||
// VU needle: map 0-100 volume to -22deg..+22deg rotation.
|
||||
const needle = document.getElementById('vuNeedle');
|
||||
if (needle) {
|
||||
const deg = -22 + (status.volume / 100) * 44;
|
||||
needle.style.transform = `rotate(${deg}deg)`;
|
||||
}
|
||||
// Editorial VU readout: VOL XX% / OUT (SYS or MUTED)
|
||||
const vuVol = document.getElementById('vu-vol');
|
||||
if (vuVol) vuVol.textContent = `${status.volume}%`;
|
||||
const vuOut = document.getElementById('vu-out');
|
||||
if (vuOut) vuOut.textContent = status.muted ? 'MUTE' : 'SYS';
|
||||
}
|
||||
|
||||
updateMuteIcon(status.muted);
|
||||
@@ -685,8 +894,101 @@ export function updateUI(status) {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── VU needle ───────────────────────────────────────────────
|
||||
// The needle reflects ACTUAL audio output level (computed from the
|
||||
// FFT data the visualizer feeds in). When audio capture isn't
|
||||
// running, fall back to a synthetic wobble bounded by the volume
|
||||
// slider position so the needle still looks alive.
|
||||
//
|
||||
// One unified rAF drives both the spectrum and the VU needle (see
|
||||
// renderVisualizerFrame → tickVuNeedle). If the visualizer isn't
|
||||
// rendering, a separate rAF takes over solely for the needle.
|
||||
let vuStandaloneHandle = null;
|
||||
let vuWobbleStart = 0;
|
||||
let vuLevelSmoothed = 0;
|
||||
let vuNeedleEl = null; // Cached needle element
|
||||
let vuVolumeSliderEl = null; // Cached slider element
|
||||
let vuLastAppliedDeg = -999; // Skip DOM writes when angle unchanged
|
||||
const VU_LEVEL_ATTACK = 0.7; // Fast climb so the needle catches musical hits
|
||||
const VU_LEVEL_RELEASE = 0.25; // Faster fall so it swings between hits, not pins
|
||||
|
||||
function readAudioLevel() {
|
||||
if (!frequencyData) return null;
|
||||
// Backend sends a true loudness signal (RMS-derived dB, 0..1) —
|
||||
// either as float (legacy) or scaled int (new format).
|
||||
if (typeof frequencyData.level === 'number') return frequencyData.level * frequenciesScale;
|
||||
if (!frequencyData.frequencies) return null;
|
||||
const bins = frequencyData.frequencies;
|
||||
if (!bins.length) return null;
|
||||
let peak = 0;
|
||||
for (let i = 1; i < bins.length; i++) {
|
||||
if (bins[i] > peak) peak = bins[i];
|
||||
}
|
||||
return Math.min(1, peak * frequenciesScale * 1.4);
|
||||
}
|
||||
|
||||
function tickVuNeedle() {
|
||||
if (!vuNeedleEl) vuNeedleEl = document.getElementById('vuNeedle');
|
||||
if (!vuNeedleEl) return;
|
||||
const audioLevel = readAudioLevel();
|
||||
let target;
|
||||
if (audioLevel != null) {
|
||||
const k = audioLevel > vuLevelSmoothed ? VU_LEVEL_ATTACK : VU_LEVEL_RELEASE;
|
||||
vuLevelSmoothed = vuLevelSmoothed * (1 - k) + audioLevel * k;
|
||||
target = -22 + vuLevelSmoothed * 44;
|
||||
} else {
|
||||
if (!vuVolumeSliderEl) vuVolumeSliderEl = document.getElementById('volume-slider');
|
||||
const vol = vuVolumeSliderEl ? Number(vuVolumeSliderEl.value) || 0 : 0;
|
||||
const base = -22 + (vol / 100) * 44;
|
||||
const mag = Math.max(2, Math.min(14, vol * 0.16));
|
||||
const t = (performance.now() - vuWobbleStart) / 1000;
|
||||
target = base
|
||||
+ Math.sin(t * 6.3) * mag * 0.55
|
||||
+ Math.sin(t * 11.7 + 1.3) * mag * 0.30
|
||||
+ (Math.random() - 0.5) * mag * 0.30;
|
||||
}
|
||||
// Quantize to 0.1° — finer is invisible. Skip when unchanged.
|
||||
const q = Math.round(target * 10) / 10;
|
||||
if (q === vuLastAppliedDeg) return;
|
||||
vuLastAppliedDeg = q;
|
||||
vuNeedleEl.style.transform = `rotate(${q}deg)`;
|
||||
}
|
||||
|
||||
function startVuWobble() {
|
||||
vuWobbleStart = performance.now();
|
||||
// If the visualizer rAF is already running, it ticks the needle for us.
|
||||
if (visualizerAnimFrame) return;
|
||||
if (vuStandaloneHandle) return;
|
||||
const standalone = () => {
|
||||
tickVuNeedle();
|
||||
// Stop ourselves once the unified visualizer loop is up.
|
||||
if (visualizerAnimFrame) {
|
||||
vuStandaloneHandle = null;
|
||||
return;
|
||||
}
|
||||
vuStandaloneHandle = requestAnimationFrame(standalone);
|
||||
};
|
||||
vuStandaloneHandle = requestAnimationFrame(standalone);
|
||||
}
|
||||
|
||||
function stopVuWobble() {
|
||||
if (vuStandaloneHandle) {
|
||||
cancelAnimationFrame(vuStandaloneHandle);
|
||||
vuStandaloneHandle = null;
|
||||
}
|
||||
vuLevelSmoothed = 0;
|
||||
vuLastAppliedDeg = -999;
|
||||
if (!vuNeedleEl) vuNeedleEl = document.getElementById('vuNeedle');
|
||||
if (vuNeedleEl) vuNeedleEl.style.transform = 'rotate(-22deg)';
|
||||
}
|
||||
|
||||
export function updatePlaybackState(state) {
|
||||
setCurrentPlayState(state);
|
||||
// Expose state to CSS so tonearm / vinyl spin can react.
|
||||
document.documentElement.dataset.playstate = state;
|
||||
// Drive the VU needle wobble — running only while playing.
|
||||
if (state === 'playing') startVuWobble();
|
||||
else stopVuWobble();
|
||||
switch(state) {
|
||||
case 'playing':
|
||||
dom.playbackState.textContent = t('state.playing');
|
||||
@@ -712,31 +1014,60 @@ export function updatePlaybackState(state) {
|
||||
dom.playPauseIcon.innerHTML = SVG_PLAY;
|
||||
dom.miniPlayPauseIcon.innerHTML = SVG_PLAY;
|
||||
}
|
||||
updateVinylSpin();
|
||||
}
|
||||
|
||||
// Cache last applied progress values so we can skip DOM writes when the
|
||||
// rounded second hasn't moved. Width is quantized to 0.1% — finer is
|
||||
// invisible but would still trigger compositor work.
|
||||
let lastProgressTenths = -1; // 0..1000 (0.1% increments)
|
||||
let lastProgressSec = -1;
|
||||
let lastDurationSec = -1;
|
||||
let cachedMiniBar = null;
|
||||
|
||||
function updateProgress(position, duration) {
|
||||
const percent = (position / duration) * 100;
|
||||
const widthStr = `${percent}%`;
|
||||
const currentStr = formatTime(position);
|
||||
const totalStr = formatTime(duration);
|
||||
const tenths = Math.round(percent * 10); // 0..1000
|
||||
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);
|
||||
const widthChanged = tenths !== lastProgressTenths;
|
||||
const posChanged = posRound !== lastProgressSec;
|
||||
const durChanged = durRound !== lastDurationSec;
|
||||
|
||||
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);
|
||||
if (widthChanged) {
|
||||
lastProgressTenths = tenths;
|
||||
const widthStr = (tenths / 10) + '%';
|
||||
dom.progressFill.style.width = widthStr;
|
||||
dom.miniProgressFill.style.width = widthStr;
|
||||
if (dom.miniPlayer) dom.miniPlayer.style.setProperty('--mini-progress', widthStr);
|
||||
}
|
||||
|
||||
if (posChanged) {
|
||||
lastProgressSec = posRound;
|
||||
const currentStr = formatTime(position);
|
||||
dom.currentTime.textContent = currentStr;
|
||||
if (dom.metaElapsed) dom.metaElapsed.textContent = currentStr;
|
||||
dom.miniCurrentTime.textContent = currentStr;
|
||||
dom.progressBar.setAttribute('aria-valuenow', posRound);
|
||||
}
|
||||
|
||||
if (durChanged) {
|
||||
lastDurationSec = durRound;
|
||||
const totalStr = formatTime(duration);
|
||||
dom.totalTime.textContent = totalStr;
|
||||
if (dom.metaLength) dom.metaLength.textContent = totalStr;
|
||||
dom.miniTotalTime.textContent = totalStr;
|
||||
dom.progressBar.dataset.duration = duration;
|
||||
dom.progressBar.setAttribute('aria-valuemax', durRound);
|
||||
}
|
||||
|
||||
if (posChanged || durChanged) {
|
||||
if (!cachedMiniBar) cachedMiniBar = document.getElementById('mini-progress-bar');
|
||||
if (cachedMiniBar) {
|
||||
if (posChanged) cachedMiniBar.setAttribute('aria-valuenow', posRound);
|
||||
if (durChanged) cachedMiniBar.setAttribute('aria-valuemax', durRound);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function startPositionInterpolation() {
|
||||
@@ -763,4 +1094,177 @@ function updateMuteIcon(muted) {
|
||||
const path = muted ? SVG_MUTED : SVG_UNMUTED;
|
||||
dom.muteIcon.innerHTML = path;
|
||||
dom.miniMuteIcon.innerHTML = path;
|
||||
const vuOut = document.getElementById('vu-out');
|
||||
if (vuOut) vuOut.textContent = muted ? 'MUTE' : 'SYS';
|
||||
const cluster = document.querySelector('.now-playing .vu-cluster');
|
||||
if (cluster) cluster.classList.toggle('muted', muted);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Fullscreen player mode — Listening Room
|
||||
//
|
||||
// Two-layer model:
|
||||
// 1. CSS overlay (`body.is-fullscreen-player`) — works everywhere,
|
||||
// reuses existing player markup, takes over the viewport via
|
||||
// position:fixed.
|
||||
// 2. Native Fullscreen API on top — true OS-level fullscreen when
|
||||
// the user agent allows it. The CSS class is the source of truth;
|
||||
// the native API is best-effort sugar.
|
||||
// ============================================================
|
||||
|
||||
let fsChromeIdleTimer = null;
|
||||
const FS_CHROME_IDLE_MS = 2500;
|
||||
let fsLastFocusedElement = null;
|
||||
|
||||
// Mirror the album-art onto #fs-bloom-art (the fullscreen ambient
|
||||
// bloom). Called directly from the artwork-swap path — no
|
||||
// MutationObserver, so we never repaint the 110px-radius blur twice.
|
||||
function syncFullscreenBloomArt(url) {
|
||||
const bloom = document.getElementById('fs-bloom-art');
|
||||
if (!bloom) return;
|
||||
const target = url || (dom && dom.albumArt && dom.albumArt.src) || '';
|
||||
if (target && bloom.src !== target) bloom.src = target;
|
||||
}
|
||||
|
||||
function showFsChrome() {
|
||||
document.body.classList.remove('fs-chrome-hidden');
|
||||
if (fsChromeIdleTimer) clearTimeout(fsChromeIdleTimer);
|
||||
if (document.body.classList.contains('is-fullscreen-player')) {
|
||||
fsChromeIdleTimer = setTimeout(() => {
|
||||
document.body.classList.add('fs-chrome-hidden');
|
||||
}, FS_CHROME_IDLE_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function onFsMouseMove() {
|
||||
showFsChrome();
|
||||
}
|
||||
|
||||
function onFsKeyDown(e) {
|
||||
// ESC exits regardless of focus location (native API also dispatches its own,
|
||||
// but we handle the CSS-only fallback case here).
|
||||
if (e.key === 'Escape' && document.body.classList.contains('is-fullscreen-player')) {
|
||||
e.preventDefault();
|
||||
exitPlayerFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
function onGlobalFsHotkey(e) {
|
||||
// 'F' toggles fullscreen — but never when user is typing into a field.
|
||||
if (e.key !== 'f' && e.key !== 'F') return;
|
||||
const tag = (e.target && e.target.tagName) || '';
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
||||
if (e.target && e.target.isContentEditable) return;
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
e.preventDefault();
|
||||
togglePlayerFullscreen();
|
||||
}
|
||||
|
||||
function onNativeFullscreenChange() {
|
||||
// If the user pressed ESC at the OS level or otherwise exited native
|
||||
// fullscreen, mirror the state in our CSS overlay.
|
||||
const hasNative = !!document.fullscreenElement;
|
||||
const hasOverlay = document.body.classList.contains('is-fullscreen-player');
|
||||
if (!hasNative && hasOverlay) {
|
||||
// User left native fullscreen — also drop the overlay so the UI
|
||||
// returns to its normal state in one motion.
|
||||
exitPlayerFullscreen({ skipNativeExit: true });
|
||||
}
|
||||
}
|
||||
|
||||
function updateFullscreenButtonIcons(active) {
|
||||
const enter = document.getElementById('fullscreen-icon-enter');
|
||||
const exit = document.getElementById('fullscreen-icon-exit');
|
||||
if (enter) enter.style.display = active ? 'none' : '';
|
||||
if (exit) exit.style.display = active ? '' : 'none';
|
||||
const btn = document.getElementById('fullscreenToggle');
|
||||
if (btn) {
|
||||
btn.classList.toggle('active', active);
|
||||
btn.setAttribute('aria-pressed', active ? 'true' : 'false');
|
||||
}
|
||||
}
|
||||
|
||||
export function enterPlayerFullscreen() {
|
||||
if (document.body.classList.contains('is-fullscreen-player')) return;
|
||||
|
||||
// If we're not on the player tab, jump to it first so the markup is visible.
|
||||
if (activeTab !== 'player') switchTab('player');
|
||||
|
||||
fsLastFocusedElement = document.activeElement;
|
||||
document.body.classList.add('is-fullscreen-player');
|
||||
setMiniPlayerVisible(false);
|
||||
updateFullscreenButtonIcons(true);
|
||||
// Initial mirror — subsequent swaps are pushed by updateUI directly,
|
||||
// so there is no MutationObserver in the hot path.
|
||||
syncFullscreenBloomArt();
|
||||
|
||||
document.addEventListener('mousemove', onFsMouseMove, { passive: true });
|
||||
document.addEventListener('keydown', onFsKeyDown);
|
||||
showFsChrome();
|
||||
|
||||
// Move keyboard focus onto the play/pause button so Space/Enter immediately
|
||||
// controls playback once the user enters the room.
|
||||
const playBtn = document.getElementById('btn-play-pause');
|
||||
if (playBtn) playBtn.focus({ preventScroll: true });
|
||||
|
||||
// Best-effort native fullscreen. Failure is silent — the CSS overlay
|
||||
// already gives the user the immersive view.
|
||||
const target = document.documentElement;
|
||||
if (target.requestFullscreen && !document.fullscreenElement) {
|
||||
target.requestFullscreen({ navigationUI: 'hide' }).catch(() => {});
|
||||
}
|
||||
|
||||
localStorage.setItem('fullscreenPlayerEnabled', 'true');
|
||||
}
|
||||
|
||||
export function exitPlayerFullscreen({ skipNativeExit = false } = {}) {
|
||||
if (!document.body.classList.contains('is-fullscreen-player')) return;
|
||||
|
||||
document.body.classList.remove('is-fullscreen-player', 'fs-chrome-hidden');
|
||||
updateFullscreenButtonIcons(false);
|
||||
|
||||
if (fsChromeIdleTimer) {
|
||||
clearTimeout(fsChromeIdleTimer);
|
||||
fsChromeIdleTimer = null;
|
||||
}
|
||||
|
||||
document.removeEventListener('mousemove', onFsMouseMove);
|
||||
document.removeEventListener('keydown', onFsKeyDown);
|
||||
|
||||
if (!skipNativeExit && document.fullscreenElement && document.exitFullscreen) {
|
||||
document.exitFullscreen().catch(() => {});
|
||||
}
|
||||
|
||||
// Re-evaluate mini-player visibility against scroll position.
|
||||
if (activeTab === 'player') {
|
||||
const playerContainer = document.querySelector('.player-container');
|
||||
if (playerContainer) {
|
||||
const rect = playerContainer.getBoundingClientRect();
|
||||
const inView = rect.top < window.innerHeight && rect.bottom > 0;
|
||||
setMiniPlayerVisible(!inView);
|
||||
}
|
||||
} else {
|
||||
setMiniPlayerVisible(true);
|
||||
}
|
||||
|
||||
// Restore focus to whatever invoked the toggle.
|
||||
if (fsLastFocusedElement && typeof fsLastFocusedElement.focus === 'function') {
|
||||
try { fsLastFocusedElement.focus({ preventScroll: true }); } catch (_) {}
|
||||
}
|
||||
fsLastFocusedElement = null;
|
||||
|
||||
localStorage.removeItem('fullscreenPlayerEnabled');
|
||||
}
|
||||
|
||||
export function togglePlayerFullscreen() {
|
||||
if (document.body.classList.contains('is-fullscreen-player')) {
|
||||
exitPlayerFullscreen();
|
||||
} else {
|
||||
enterPlayerFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
export function initPlayerFullscreen() {
|
||||
document.addEventListener('keydown', onGlobalFsHotkey);
|
||||
document.addEventListener('fullscreenchange', onNativeFullscreenChange);
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@ import {
|
||||
dom, t, showToast, setWs,
|
||||
WS_BACKOFF_BASE_MS, WS_BACKOFF_MAX_MS,
|
||||
WS_MAX_RECONNECT_ATTEMPTS, WS_PING_INTERVAL_MS,
|
||||
authRequired,
|
||||
authRequired, showUpdateBanner,
|
||||
} from './core.js';
|
||||
import { updateUI, visualizerEnabled, visualizerAvailable, setFrequencyData, stopPositionInterpolation, loadAudioDevices } from './player.js';
|
||||
import { updateUI, setFrequencyData, stopPositionInterpolation, loadAudioDevices } from './player.js';
|
||||
import { loadScripts, loadScriptsTable, displayQuickAccess } from './scripts.js';
|
||||
import { loadCallbacksTable } from './callbacks.js';
|
||||
import { loadHeaderLinks, loadLinksTable } from './links.js';
|
||||
@@ -81,9 +81,6 @@ export function connectWebSocket(token) {
|
||||
loadLinksTable();
|
||||
loadHeaderLinks();
|
||||
loadAudioDevices();
|
||||
if (visualizerEnabled && visualizerAvailable) {
|
||||
newWs.send(JSON.stringify({ type: 'enable_visualizer' }));
|
||||
}
|
||||
};
|
||||
|
||||
newWs.onmessage = (event) => {
|
||||
@@ -100,6 +97,8 @@ export function connectWebSocket(token) {
|
||||
loadHeaderLinks();
|
||||
loadLinksTable();
|
||||
displayQuickAccess();
|
||||
} else if (msg.type === 'update_available') {
|
||||
showUpdateBanner(msg.data);
|
||||
} else if (msg.type === 'audio_data') {
|
||||
setFrequencyData(msg.data);
|
||||
} else if (msg.type === 'error') {
|
||||
|
||||
@@ -19,12 +19,23 @@
|
||||
"player.status.connected": "Connected",
|
||||
"player.status.disconnected": "Disconnected",
|
||||
"player.no_media": "No media playing",
|
||||
"player.kicker": "Now Playing",
|
||||
"player.modes": "Modes",
|
||||
"header.connected": "Connected",
|
||||
"header.volume": "Vol. I",
|
||||
"header.edition": "Studio Reference",
|
||||
"header.edition_sub": "Studio Reference Edition",
|
||||
"meta.state": "State",
|
||||
"meta.source": "Source",
|
||||
"player.title_unavailable": "Title unavailable",
|
||||
"player.source": "Source:",
|
||||
"player.unknown_source": "Unknown",
|
||||
"player.vinyl": "Vinyl mode",
|
||||
"player.visualizer": "Audio visualizer",
|
||||
"player.background": "Dynamic background",
|
||||
"player.fullscreen": "Fullscreen player",
|
||||
"player.fullscreen.exit": "Exit fullscreen",
|
||||
"player.fullscreen.exit_short": "Exit",
|
||||
"state.playing": "Playing",
|
||||
"state.paused": "Paused",
|
||||
"state.stopped": "Stopped",
|
||||
@@ -126,8 +137,8 @@
|
||||
"callbacks.msg.list_failed": "Failed to load callbacks",
|
||||
"callbacks.confirm.delete": "Are you sure you want to delete the callback \"{name}\"?",
|
||||
"callbacks.confirm.unsaved": "You have unsaved changes. Are you sure you want to discard them?",
|
||||
"tab.player": "Player",
|
||||
"tab.browser": "Browser",
|
||||
"tab.player": "Now Spinning",
|
||||
"tab.browser": "Library",
|
||||
"tab.quick_access": "Quick Access",
|
||||
"tab.settings": "Settings",
|
||||
"tab.display": "Display",
|
||||
@@ -173,7 +184,27 @@
|
||||
"browser.play_all_error": "Failed to play folder",
|
||||
"browser.error_loading": "Error loading directory",
|
||||
"browser.error_loading_folders": "Failed to load media folders",
|
||||
"browser.manage_folders_hint": "Folder management coming soon! For now, edit config.yaml to add media folders.",
|
||||
"browser.manage_folders_hint": "Folder management is disabled. Set media_folders_management: true in config.yaml to enable.",
|
||||
"browser.unavailable": "Unavailable",
|
||||
"browser.folder_available": "Available",
|
||||
"browser.folder_unavailable": "Unavailable (path not reachable)",
|
||||
"browser.folder_disabled": "disabled",
|
||||
"browser.folder_edit": "Edit folder",
|
||||
"browser.folder_delete": "Delete folder",
|
||||
"browser.folder_created": "Media folder created successfully",
|
||||
"browser.folder_updated": "Media folder updated successfully",
|
||||
"browser.folder_deleted": "Media folder deleted successfully",
|
||||
"browser.folder_save_error": "Failed to save media folder",
|
||||
"browser.folder_delete_error": "Failed to delete media folder",
|
||||
"browser.folder_confirm_delete": "Are you sure you want to delete the folder \"{name}\"?",
|
||||
"browser.folders_description": "Media folders available for browsing. Folders on network shares show availability status.",
|
||||
"browser.folders_empty": "No media folders configured. Click \"+\" to add one.",
|
||||
"browser.folders_table.id": "ID",
|
||||
"browser.folders_table.label": "Label",
|
||||
"browser.folders_table.path": "Path",
|
||||
"browser.folders_table.status": "Status",
|
||||
"browser.folders_table.actions": "Actions",
|
||||
"settings.section.media_folders": "Media Folders",
|
||||
"browser.folder_dialog.title_add": "Add Media Folder",
|
||||
"browser.folder_dialog.title_edit": "Edit Media Folder",
|
||||
"browser.folder_dialog.folder_id": "Folder ID *",
|
||||
@@ -185,6 +216,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.",
|
||||
@@ -224,5 +260,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"
|
||||
}
|
||||
|
||||
@@ -19,12 +19,23 @@
|
||||
"player.status.connected": "Подключено",
|
||||
"player.status.disconnected": "Отключено",
|
||||
"player.no_media": "Медиа не воспроизводится",
|
||||
"player.kicker": "Сейчас играет",
|
||||
"player.modes": "Режимы",
|
||||
"header.connected": "Подключено",
|
||||
"header.volume": "Том I",
|
||||
"header.edition": "Studio Reference",
|
||||
"header.edition_sub": "Studio Reference Edition",
|
||||
"meta.state": "Состояние",
|
||||
"meta.source": "Источник",
|
||||
"player.title_unavailable": "Название недоступно",
|
||||
"player.source": "Источник:",
|
||||
"player.unknown_source": "Неизвестно",
|
||||
"player.vinyl": "Режим винила",
|
||||
"player.visualizer": "Аудио визуализатор",
|
||||
"player.background": "Динамический фон",
|
||||
"player.fullscreen": "Полноэкранный режим",
|
||||
"player.fullscreen.exit": "Выйти из полного экрана",
|
||||
"player.fullscreen.exit_short": "Выйти",
|
||||
"state.playing": "Воспроизведение",
|
||||
"state.paused": "Пауза",
|
||||
"state.stopped": "Остановлено",
|
||||
@@ -126,8 +137,8 @@
|
||||
"callbacks.msg.list_failed": "Не удалось загрузить обратные вызовы",
|
||||
"callbacks.confirm.delete": "Вы уверены, что хотите удалить обратный вызов \"{name}\"?",
|
||||
"callbacks.confirm.unsaved": "У вас есть несохраненные изменения. Вы уверены, что хотите отменить их?",
|
||||
"tab.player": "Плеер",
|
||||
"tab.browser": "Браузер",
|
||||
"tab.player": "Сейчас играет",
|
||||
"tab.browser": "Библиотека",
|
||||
"tab.quick_access": "Быстрый Доступ",
|
||||
"tab.settings": "Настройки",
|
||||
"tab.display": "Дисплей",
|
||||
@@ -173,7 +184,27 @@
|
||||
"browser.play_all_error": "Не удалось воспроизвести папку",
|
||||
"browser.error_loading": "Ошибка загрузки каталога",
|
||||
"browser.error_loading_folders": "Не удалось загрузить медиа папки",
|
||||
"browser.manage_folders_hint": "Управление папками скоро появится! Пока редактируйте config.yaml для добавления медиа папок.",
|
||||
"browser.manage_folders_hint": "Управление папками отключено. Установите media_folders_management: true в config.yaml для включения.",
|
||||
"browser.unavailable": "Недоступна",
|
||||
"browser.folder_available": "Доступна",
|
||||
"browser.folder_unavailable": "Недоступна (путь не найден)",
|
||||
"browser.folder_disabled": "отключена",
|
||||
"browser.folder_edit": "Редактировать папку",
|
||||
"browser.folder_delete": "Удалить папку",
|
||||
"browser.folder_created": "Медиа папка успешно создана",
|
||||
"browser.folder_updated": "Медиа папка успешно обновлена",
|
||||
"browser.folder_deleted": "Медиа папка успешно удалена",
|
||||
"browser.folder_save_error": "Не удалось сохранить медиа папку",
|
||||
"browser.folder_delete_error": "Не удалось удалить медиа папку",
|
||||
"browser.folder_confirm_delete": "Вы уверены, что хотите удалить папку \"{name}\"?",
|
||||
"browser.folders_description": "Медиа папки для просмотра. Для сетевых ресурсов показан статус доступности.",
|
||||
"browser.folders_empty": "Медиа папки не настроены. Нажмите \"+\" для добавления.",
|
||||
"browser.folders_table.id": "ID",
|
||||
"browser.folders_table.label": "Метка",
|
||||
"browser.folders_table.path": "Путь",
|
||||
"browser.folders_table.status": "Статус",
|
||||
"browser.folders_table.actions": "Действия",
|
||||
"settings.section.media_folders": "Медиа папки",
|
||||
"browser.folder_dialog.title_add": "Добавить медиа папку",
|
||||
"browser.folder_dialog.title_edit": "Редактировать медиа папку",
|
||||
"browser.folder_dialog.folder_id": "ID папки *",
|
||||
@@ -185,6 +216,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": "Соединение потеряно. Сервер может быть недоступен.",
|
||||
@@ -224,5 +260,7 @@
|
||||
"links.confirm.delete": "Вы уверены, что хотите удалить ссылку \"{name}\"?",
|
||||
"links.confirm.unsaved": "У вас есть несохраненные изменения. Вы уверены, что хотите отменить их?",
|
||||
"footer.created_by": "Создано",
|
||||
"footer.source_code": "Исходный код"
|
||||
"footer.source_code": "Исходный код",
|
||||
"update.available": "Доступно обновление: v{version}",
|
||||
"update.view_release": "Перейти к релизу"
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "media-server-frontend",
|
||||
"version": "1.0.0",
|
||||
"version": "0.2.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "media-server-frontend",
|
||||
"version": "1.0.0",
|
||||
"version": "0.2.0",
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.27.4"
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "media-server-frontend",
|
||||
"version": "1.0.0",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"description": "Frontend build tooling for media server WebUI",
|
||||
"scripts": {
|
||||
|
||||
+4
-5
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "media-server"
|
||||
version = "1.0.0"
|
||||
version = "0.2.1"
|
||||
description = "REST API server for controlling system-wide media playback"
|
||||
readme = "README.md"
|
||||
license = { text = "MIT" }
|
||||
@@ -32,6 +32,8 @@ dependencies = [
|
||||
"pyyaml>=6.0",
|
||||
"mutagen>=1.47.0",
|
||||
"pillow>=10.0.0",
|
||||
"soundcard>=0.4.0",
|
||||
"numpy>=1.24.0,<2.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -41,13 +43,10 @@ windows = [
|
||||
"comtypes>=1.2.0",
|
||||
"pycaw>=20230407",
|
||||
"screen-brightness-control>=0.20.0",
|
||||
"wmi>=1.5.1",
|
||||
"monitorcontrol>=3.0.0",
|
||||
"pystray>=0.19.0",
|
||||
]
|
||||
visualizer = [
|
||||
"soundcard>=0.4.0",
|
||||
"numpy>=1.24.0,<2.0",
|
||||
]
|
||||
dev = [
|
||||
"pytest>=7.0",
|
||||
"pytest-asyncio>=0.21",
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Tests for AudioAnalyzer.
|
||||
|
||||
Covers the pure-Python pieces that don't need real audio hardware:
|
||||
- Logarithmic FFT bin edge layout
|
||||
- Slow-AGC envelope follower (attack vs release behaviour)
|
||||
- Lifecycle reset of the AGC reference on start()
|
||||
|
||||
Tests are skipped when numpy isn't installed in the host environment
|
||||
so they don't block CI on a minimal interpreter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from media_server.services.audio_analyzer import AudioAnalyzer, _load_numpy
|
||||
|
||||
np = _load_numpy()
|
||||
needs_numpy = pytest.mark.skipif(np is None, reason="numpy not available")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def analyzer() -> AudioAnalyzer:
|
||||
return AudioAnalyzer(num_bins=16, sample_rate=44100, chunk_size=1024)
|
||||
|
||||
|
||||
# ── _compute_bin_edges ────────────────────────────────────────────
|
||||
|
||||
|
||||
@needs_numpy
|
||||
def test_bin_edges_count_matches_num_bins_plus_one(analyzer: AudioAnalyzer) -> None:
|
||||
edges = analyzer._compute_bin_edges()
|
||||
assert len(edges) == analyzer.num_bins + 1
|
||||
|
||||
|
||||
@needs_numpy
|
||||
def test_bin_edges_are_monotonic_non_decreasing(analyzer: AudioAnalyzer) -> None:
|
||||
edges = analyzer._compute_bin_edges()
|
||||
assert all(edges[i] <= edges[i + 1] for i in range(len(edges) - 1))
|
||||
|
||||
|
||||
@needs_numpy
|
||||
def test_bin_edges_stay_within_fft_size(analyzer: AudioAnalyzer) -> None:
|
||||
edges = analyzer._compute_bin_edges()
|
||||
fft_size = analyzer.chunk_size // 2 + 1
|
||||
assert max(edges) <= fft_size - 1
|
||||
assert min(edges) >= 0
|
||||
|
||||
|
||||
# ── AGC envelope follower (the new behaviour) ─────────────────────
|
||||
|
||||
|
||||
def _step_envelope(analyzer: AudioAnalyzer, peak: float) -> float:
|
||||
"""Run one frame of the AGC update with a known peak value.
|
||||
|
||||
Mirrors the math inside _capture_loop without spinning up a real
|
||||
capture thread or requiring numpy: pure Python on a single float.
|
||||
"""
|
||||
if peak > analyzer._spectrum_ref:
|
||||
analyzer._spectrum_ref += (peak - analyzer._spectrum_ref) * 0.05
|
||||
else:
|
||||
analyzer._spectrum_ref += (peak - analyzer._spectrum_ref) * 0.005
|
||||
return analyzer._spectrum_ref
|
||||
|
||||
|
||||
def test_agc_initial_reference_is_quiet(analyzer: AudioAnalyzer) -> None:
|
||||
assert analyzer._spectrum_ref == pytest.approx(0.01)
|
||||
|
||||
|
||||
def test_agc_attacks_quickly_toward_loud_signal(analyzer: AudioAnalyzer) -> None:
|
||||
# Drive 30 frames of a loud signal; reference should climb sharply.
|
||||
for _ in range(30):
|
||||
_step_envelope(analyzer, peak=1.0)
|
||||
# 30 frames of attack=0.05 brings (1 - 0.99^30) ≈ 0.78 of the way to 1.0.
|
||||
assert analyzer._spectrum_ref > 0.5
|
||||
assert analyzer._spectrum_ref < 1.0
|
||||
|
||||
|
||||
def test_agc_releases_slowly_toward_quiet_signal(analyzer: AudioAnalyzer) -> None:
|
||||
analyzer._spectrum_ref = 1.0
|
||||
for _ in range(30):
|
||||
_step_envelope(analyzer, peak=0.0)
|
||||
# Release coefficient is 0.005 — after 30 frames we should have shed
|
||||
# only ~14% of the headroom, not snap back to silent.
|
||||
assert analyzer._spectrum_ref > 0.7
|
||||
assert analyzer._spectrum_ref < 1.0
|
||||
|
||||
|
||||
def test_agc_is_asymmetric_attack_faster_than_release(analyzer: AudioAnalyzer) -> None:
|
||||
a = AudioAnalyzer()
|
||||
b = AudioAnalyzer()
|
||||
a._spectrum_ref = 0.5
|
||||
b._spectrum_ref = 0.5
|
||||
# One attack frame toward 1.0
|
||||
_step_envelope(a, peak=1.0)
|
||||
# One release frame toward 0.0 (same magnitude of error: 0.5)
|
||||
_step_envelope(b, peak=0.0)
|
||||
attack_delta = a._spectrum_ref - 0.5
|
||||
release_delta = 0.5 - b._spectrum_ref
|
||||
# Attack coefficient (0.05) is 10× the release coefficient (0.005).
|
||||
assert attack_delta == pytest.approx(release_delta * 10, rel=1e-6)
|
||||
|
||||
|
||||
# ── start() lifecycle reset ──────────────────────────────────────
|
||||
|
||||
|
||||
def test_start_resets_spectrum_ref_when_unavailable(
|
||||
monkeypatch: pytest.MonkeyPatch, analyzer: AudioAnalyzer
|
||||
) -> None:
|
||||
"""Even when start() returns False (no hardware), the AGC state
|
||||
should remain at the documented quiet baseline."""
|
||||
# Force unavailable so start() short-circuits without spawning a thread.
|
||||
monkeypatch.setattr(
|
||||
AudioAnalyzer, "available", property(lambda self: False)
|
||||
)
|
||||
analyzer._spectrum_ref = 0.95 # leftover from prior session
|
||||
started = analyzer.start()
|
||||
assert started is False
|
||||
# start() returned early before the reset — by design (no capture
|
||||
# means no need to renormalize). Document the contract.
|
||||
assert analyzer._spectrum_ref == 0.95
|
||||
|
||||
|
||||
def test_start_resets_spectrum_ref_when_available(
|
||||
monkeypatch: pytest.MonkeyPatch, analyzer: AudioAnalyzer
|
||||
) -> None:
|
||||
"""When capture actually starts, leftover AGC state from a prior
|
||||
session must be cleared so the first transients don't clip."""
|
||||
monkeypatch.setattr(
|
||||
AudioAnalyzer, "available", property(lambda self: True)
|
||||
)
|
||||
# Stub out the thread so we don't actually spin up a capture loop.
|
||||
monkeypatch.setattr(
|
||||
"media_server.services.audio_analyzer.threading.Thread",
|
||||
lambda *a, **kw: type("T", (), {"start": lambda self: None})(),
|
||||
)
|
||||
analyzer._spectrum_ref = 0.95 # leftover from prior session
|
||||
try:
|
||||
started = analyzer.start()
|
||||
assert started is True
|
||||
assert analyzer._spectrum_ref == pytest.approx(0.01)
|
||||
finally:
|
||||
analyzer._running = False
|
||||
|
||||
|
||||
# ── get_frequency_data thread-safe contract ───────────────────────
|
||||
|
||||
|
||||
def test_get_frequency_data_returns_none_before_capture(
|
||||
analyzer: AudioAnalyzer,
|
||||
) -> None:
|
||||
assert analyzer.get_frequency_data() is None
|
||||
Reference in New Issue
Block a user