Compare commits
116 Commits
99dbbb1019
...
v0.2.4
| Author | SHA1 | Date | |
|---|---|---|---|
| 770bba7e60 | |||
| d1f621f0b4 | |||
| 6120625fa9 | |||
| 57fdeb70fb | |||
| 0d07f7f1f4 | |||
| 372e4eb11f | |||
| d27484a46d | |||
| 261a14c575 | |||
| e7372b0ccb | |||
| ec5178142e | |||
| 46af2bb8cc | |||
| 25a492d5dd | |||
| f4be2bdb89 | |||
| 51ec1503f4 | |||
| 08c3c80df4 | |||
| 62eeca1b9e | |||
| 4c93bfb8c1 | |||
| 59840a1190 | |||
| 2a474ea52c | |||
| f85ce77f14 | |||
| b09569f390 | |||
| f2c82164e8 | |||
| 588a303c44 | |||
| 2049850180 | |||
| 9b84fdd0e5 | |||
| 3de2b4496e | |||
| d7f488ac70 | |||
| 968eb156bc | |||
| a0f74dfc39 | |||
| 6066b4a2c5 | |||
| 153424eff8 | |||
| 336d596b66 | |||
| d937c1590c | |||
| d157388a94 | |||
| e9e4165927 | |||
| 77b39e5684 | |||
| d9d4672ca3 | |||
| 265b001b99 | |||
| 14e9f2294e | |||
| 8110c152b0 | |||
| 21adeb1070 | |||
| 68614c982d | |||
| a2a258e898 | |||
| 456eb3a881 | |||
| c586b1b518 | |||
| ee5184920d | |||
| af556e0bff | |||
| 26b4672a99 | |||
| 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 | |||
| 1410a8d2cb | |||
| 1c0a011342 | |||
| 2b1e09ded9 | |||
| 415231f2f2 | |||
| 32e2ff532d | |||
| 309f547a5e | |||
| 402183765c | |||
| d7e10b1005 | |||
| 3f14512e5d | |||
| 26b5f74c24 | |||
| 1f6e4f6d55 | |||
| 6500d6f615 | |||
| 4d1bb78c83 | |||
| f80f6e9299 | |||
| 02168519b7 | |||
| c76ffb9997 | |||
| ddd8788701 | |||
| 5439af1955 | |||
| be48318212 | |||
| 0eca8292cb | |||
| 3cfc437599 | |||
| a20812ec29 | |||
| 652f10fc4c | |||
| 3846610042 | |||
| 92d6709d58 | |||
| 9404b37f05 | |||
| 73a6f387e1 | |||
| b11edc25b9 | |||
| 3d01d98da0 | |||
| 4112367175 | |||
| 00d313daa1 | |||
| 0691e3d338 | |||
| 8a8f00ff31 | |||
| 397d38ac12 | |||
| adf2d936da |
@@ -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
|
||||
@@ -0,0 +1,228 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
# --- Create Gitea release ---
|
||||
create-release:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_id: ${{ steps.create.outputs.release_id }}
|
||||
version: ${{ steps.create.outputs.version }}
|
||||
steps:
|
||||
- name: Fetch RELEASE_NOTES.md only
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: RELEASE_NOTES.md
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: Create Gitea release
|
||||
id: create
|
||||
env:
|
||||
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
|
||||
run: |
|
||||
TAG="${{ gitea.ref_name }}"
|
||||
VERSION="${TAG#v}"
|
||||
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
|
||||
|
||||
IS_PRE="false"
|
||||
if echo "$TAG" | grep -qE '(alpha|beta|rc)'; then
|
||||
IS_PRE="true"
|
||||
fi
|
||||
|
||||
if [ -f RELEASE_NOTES.md ]; then
|
||||
export RELEASE_NOTES=$(cat RELEASE_NOTES.md)
|
||||
echo "Found RELEASE_NOTES.md"
|
||||
else
|
||||
export RELEASE_NOTES=""
|
||||
echo "No RELEASE_NOTES.md found"
|
||||
fi
|
||||
|
||||
BODY_JSON=$(python3 -c "
|
||||
import json, os, textwrap
|
||||
|
||||
tag = '$TAG'
|
||||
release_notes = os.environ.get('RELEASE_NOTES', '')
|
||||
|
||||
sections = []
|
||||
|
||||
if release_notes.strip():
|
||||
sections.append(release_notes.strip())
|
||||
|
||||
sections.append(textwrap.dedent(f'''
|
||||
## Downloads
|
||||
|
||||
| Platform | File |
|
||||
|----------|------|
|
||||
| Windows (installer) | \`MediaServer-{tag}-setup.exe\` |
|
||||
| Windows (portable) | \`MediaServer-{tag}-win-x64.zip\` |
|
||||
| Linux | \`MediaServer-{tag}-linux-x64.tar.gz\` |
|
||||
''').strip())
|
||||
|
||||
print(json.dumps('\n\n'.join(sections)))
|
||||
")
|
||||
|
||||
RELEASE=$(curl -s -X POST "$BASE_URL/releases" \
|
||||
-H "Authorization: token $DEPLOY_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"tag_name\": \"$TAG\",
|
||||
\"name\": \"Media Server $TAG\",
|
||||
\"body\": $BODY_JSON,
|
||||
\"draft\": false,
|
||||
\"prerelease\": $IS_PRE
|
||||
}")
|
||||
|
||||
# Extract release ID; if creation failed (already exists), fetch existing
|
||||
RELEASE_ID=$(echo "$RELEASE" | python3 -c "
|
||||
import sys, json
|
||||
data = json.load(sys.stdin)
|
||||
if 'id' in data:
|
||||
print(data['id'])
|
||||
else:
|
||||
print('FAILED', file=sys.stderr)
|
||||
print(json.dumps(data, indent=2), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
" 2>&1) || {
|
||||
echo "::warning::Release already exists for tag $TAG — reusing existing release"
|
||||
RELEASE=$(curl -s "$BASE_URL/releases/tags/$TAG" \
|
||||
-H "Authorization: token $DEPLOY_TOKEN")
|
||||
RELEASE_ID=$(echo "$RELEASE" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
||||
}
|
||||
echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# --- Build Windows installer + portable ZIP ---
|
||||
build-windows:
|
||||
needs: create-release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Build frontend
|
||||
run: npm ci && npm run build
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install build tools
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends nsis zip
|
||||
|
||||
- name: Build Windows distribution
|
||||
run: |
|
||||
chmod +x build-dist-windows.sh
|
||||
./build-dist-windows.sh "${{ gitea.ref_name }}"
|
||||
|
||||
- name: Build NSIS installer
|
||||
run: |
|
||||
VERSION="${{ needs.create-release.outputs.version }}"
|
||||
makensis -DVERSION="${VERSION}" installer.nsi
|
||||
|
||||
- name: Upload assets to release
|
||||
env:
|
||||
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
|
||||
run: |
|
||||
RELEASE_ID="${{ needs.create-release.outputs.release_id }}"
|
||||
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
|
||||
|
||||
upload_asset() {
|
||||
local file="$1"
|
||||
local name
|
||||
name=$(basename "$file")
|
||||
|
||||
# Delete existing asset with the same name (idempotent re-runs)
|
||||
EXISTING=$(curl -s "$BASE_URL/releases/$RELEASE_ID/assets" \
|
||||
-H "Authorization: token $DEPLOY_TOKEN")
|
||||
ASSET_ID=$(echo "$EXISTING" | python3 -c "
|
||||
import sys, json
|
||||
assets = json.load(sys.stdin)
|
||||
for a in assets:
|
||||
if a['name'] == '$name':
|
||||
print(a['id'])
|
||||
break
|
||||
" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$ASSET_ID" ]; then
|
||||
echo "Replacing existing asset: $name (id=$ASSET_ID)"
|
||||
curl -s -X DELETE "$BASE_URL/releases/$RELEASE_ID/assets/$ASSET_ID" \
|
||||
-H "Authorization: token $DEPLOY_TOKEN"
|
||||
fi
|
||||
|
||||
echo "Uploading $name..."
|
||||
curl -s -X POST \
|
||||
"$BASE_URL/releases/$RELEASE_ID/assets?name=$name" \
|
||||
-H "Authorization: token $DEPLOY_TOKEN" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@$file"
|
||||
}
|
||||
|
||||
for FILE in build/MediaServer-*.zip build/MediaServer-*-setup.exe; do
|
||||
[ -f "$FILE" ] || continue
|
||||
upload_asset "$FILE"
|
||||
done
|
||||
|
||||
# --- Build Linux tarball ---
|
||||
build-linux:
|
||||
needs: create-release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Build frontend
|
||||
run: npm ci && npm run build
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Build Linux distribution
|
||||
run: |
|
||||
chmod +x build-dist-linux.sh
|
||||
./build-dist-linux.sh "${{ gitea.ref_name }}"
|
||||
|
||||
- name: Upload assets to release
|
||||
env:
|
||||
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
|
||||
run: |
|
||||
RELEASE_ID="${{ needs.create-release.outputs.release_id }}"
|
||||
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
|
||||
|
||||
FILE=$(ls build/MediaServer-*-linux-x64.tar.gz | head -1)
|
||||
NAME=$(basename "$FILE")
|
||||
|
||||
# Delete existing asset with the same name (idempotent re-runs)
|
||||
EXISTING=$(curl -s "$BASE_URL/releases/$RELEASE_ID/assets" \
|
||||
-H "Authorization: token $DEPLOY_TOKEN")
|
||||
ASSET_ID=$(echo "$EXISTING" | python3 -c "
|
||||
import sys, json
|
||||
assets = json.load(sys.stdin)
|
||||
for a in assets:
|
||||
if a['name'] == '$NAME':
|
||||
print(a['id'])
|
||||
break
|
||||
" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$ASSET_ID" ]; then
|
||||
echo "Replacing existing asset: $NAME (id=$ASSET_ID)"
|
||||
curl -s -X DELETE "$BASE_URL/releases/$RELEASE_ID/assets/$ASSET_ID" \
|
||||
-H "Authorization: token $DEPLOY_TOKEN"
|
||||
fi
|
||||
|
||||
echo "Uploading $NAME..."
|
||||
curl -s -X POST \
|
||||
"$BASE_URL/releases/$RELEASE_ID/assets?name=$NAME" \
|
||||
-H "Authorization: token $DEPLOY_TOKEN" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@$FILE"
|
||||
@@ -0,0 +1,36 @@
|
||||
name: Lint & Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
if: "!startsWith(github.event.head_commit.message, 'chore: release')"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Build frontend
|
||||
run: npm ci && npm run build
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install --upgrade pip
|
||||
pip install -e ".[dev]"
|
||||
|
||||
- name: Lint
|
||||
run: ruff check media_server/
|
||||
|
||||
- name: Test
|
||||
run: pytest --tb=short -q || test $? -eq 5
|
||||
@@ -49,3 +49,9 @@ Thumbs.db
|
||||
|
||||
# Thumbnail cache
|
||||
.cache/
|
||||
|
||||
# Node.js / esbuild
|
||||
node_modules/
|
||||
media_server/static/dist/
|
||||
# Added by code-review-graph
|
||||
.code-review-graph/
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"code-review-graph": {
|
||||
"command": "uvx",
|
||||
"args": [
|
||||
"code-review-graph",
|
||||
"serve"
|
||||
],
|
||||
"type": "stdio"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,16 +134,104 @@ To add support for a new language:
|
||||
|
||||
## Versioning
|
||||
|
||||
Version is tracked in two files that must be kept in sync:
|
||||
**`pyproject.toml`** is the single source of truth for the version string.
|
||||
|
||||
- `pyproject.toml` - `[project].version`
|
||||
- `media_server/__init__.py` - `__version__`
|
||||
At runtime, `media_server/__init__.py` reads the version via `importlib.metadata.version()` — no manual syncing needed.
|
||||
|
||||
When releasing a new version, update both files with the same version string.
|
||||
Version flow:
|
||||
1. `git tag v0.3.0` → CI reads the tag
|
||||
2. Build scripts stamp `pyproject.toml` with the clean version via `sed`
|
||||
3. `pip install` bakes the version into package metadata
|
||||
4. `importlib.metadata.version("media-server")` reads it at runtime
|
||||
|
||||
When bumping the version for a new release, only `pyproject.toml` needs to be updated.
|
||||
|
||||
**Important:** After making any changes, always ask the user if the version needs to be incremented.
|
||||
|
||||
## CI/CD
|
||||
|
||||
Gitea Actions workflow at `.gitea/workflows/test.yml` runs on every push/PR to `master`:
|
||||
|
||||
1. **Lint** — `ruff check media_server/` (rules: E, F, I, W)
|
||||
2. **Test** — `pytest --tb=short -q`
|
||||
|
||||
Release workflow at `.gitea/workflows/release.yml` triggers on `v*` tags:
|
||||
|
||||
1. **Create release** — Gitea release via REST API (detects pre-release from tag)
|
||||
2. **Build Windows** — cross-builds on Linux using embedded Python + NSIS installer
|
||||
3. **Upload assets** — portable ZIP + installer `.exe` attached to the release
|
||||
|
||||
### Releasing
|
||||
|
||||
```bash
|
||||
# Stable release
|
||||
git tag v1.0.0 && git push origin v1.0.0
|
||||
|
||||
# Pre-release
|
||||
git tag v1.1.0-alpha.1 && git push origin v1.1.0-alpha.1
|
||||
```
|
||||
|
||||
### Installer
|
||||
|
||||
The NSIS installer (`installer.nsi`) installs to `%LOCALAPPDATA%\Media Server` (no admin required) with optional:
|
||||
- **Desktop shortcut**
|
||||
- **Start with Windows** (Startup folder shortcut, runs hidden via VBS)
|
||||
|
||||
Uninstall preserves `config.yaml` (user data).
|
||||
|
||||
Reference: [gitea-python-ci-cd.md](https://git.dolgolyov-family.by/alexei.dolgolyov/claude-code-facts/src/branch/main/gitea-python-ci-cd.md)
|
||||
|
||||
**IMPORTANT:** When modifying CI/CD workflows, `installer.nsi`, or build scripts (`build-dist-*.sh`), always fetch and consult the guide above first to ensure changes stay in sync with established patterns.
|
||||
|
||||
### Before Pushing
|
||||
|
||||
Ensure CI will pass locally:
|
||||
|
||||
```bash
|
||||
ruff check media_server/
|
||||
pytest --tb=short -q
|
||||
```
|
||||
|
||||
## Git Rules
|
||||
|
||||
- **ALWAYS ask for user approval before committing and pushing changes.**
|
||||
- When pushing, always push to all remotes: `git push origin master && git push github master`
|
||||
|
||||
<!-- code-review-graph MCP tools -->
|
||||
## MCP Tools: code-review-graph
|
||||
|
||||
**IMPORTANT: This project has a knowledge graph. ALWAYS use the
|
||||
code-review-graph MCP tools BEFORE using Grep/Glob/Read to explore
|
||||
the codebase.** The graph is faster, cheaper (fewer tokens), and gives
|
||||
you structural context (callers, dependents, test coverage) that file
|
||||
scanning cannot.
|
||||
|
||||
### When to use graph tools FIRST
|
||||
|
||||
- **Exploring code**: `semantic_search_nodes` or `query_graph` instead of Grep
|
||||
- **Understanding impact**: `get_impact_radius` instead of manually tracing imports
|
||||
- **Code review**: `detect_changes` + `get_review_context` instead of reading entire files
|
||||
- **Finding relationships**: `query_graph` with callers_of/callees_of/imports_of/tests_for
|
||||
- **Architecture questions**: `get_architecture_overview` + `list_communities`
|
||||
|
||||
Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need.
|
||||
|
||||
### Key Tools
|
||||
|
||||
| Tool | Use when |
|
||||
|------|----------|
|
||||
| `detect_changes` | Reviewing code changes — gives risk-scored analysis |
|
||||
| `get_review_context` | Need source snippets for review — token-efficient |
|
||||
| `get_impact_radius` | Understanding blast radius of a change |
|
||||
| `get_affected_flows` | Finding which execution paths are impacted |
|
||||
| `query_graph` | Tracing callers, callees, imports, tests, dependencies |
|
||||
| `semantic_search_nodes` | Finding functions/classes by name or keyword |
|
||||
| `get_architecture_overview` | Understanding high-level codebase structure |
|
||||
| `refactor_tool` | Planning renames, finding dead code |
|
||||
|
||||
### Workflow
|
||||
|
||||
1. The graph auto-updates on file changes (via hooks).
|
||||
2. Use `detect_changes` for code review.
|
||||
3. Use `get_affected_flows` to understand impact.
|
||||
4. Use `query_graph` pattern="tests_for" to check coverage.
|
||||
|
||||
@@ -5,9 +5,14 @@ A REST API server for controlling system media playback on Windows, Linux, macOS
|
||||
## Features
|
||||
|
||||
- **Built-in Web UI** for real-time media control and monitoring
|
||||
- **Installable PWA** - Add to home screen on mobile for a native app experience
|
||||
- **Audio Visualizer** - Real-time spectrum analyzer with beat-reactive album art effects
|
||||
- **Dynamic WebGL Background** - Audio-reactive animated background with album art color extraction
|
||||
- **Media Browser** - Browse and play media files from configured folders
|
||||
- **Display Control** - Monitor brightness and power management
|
||||
- **Quick Actions & Scripts** - Execute custom scripts with one click
|
||||
- **Callbacks** - Trigger commands on media events (play, pause, volume, etc.)
|
||||
- **Header Links** - Configurable quick-access links in the UI header
|
||||
- Control any media player via system-wide media transport controls
|
||||
- Play/Pause/Stop/Next/Previous track
|
||||
- Volume control and mute
|
||||
@@ -25,7 +30,7 @@ The media server includes a built-in web interface for controlling and monitorin
|
||||
|
||||

|
||||
|
||||
### Features
|
||||
### Web UI Highlights
|
||||
|
||||
- **Real-time status updates** via WebSocket connection
|
||||
- **Album artwork display** with glow effect and automatic updates
|
||||
@@ -36,21 +41,28 @@ The media server includes a built-in web interface for controlling and monitorin
|
||||
- **Mini player** - Sticky compact player that appears when scrolling away from the main player
|
||||
- **Connection status indicator** - Know when you're connected
|
||||
- **Token authentication** - Saved in browser localStorage
|
||||
- **Responsive design** - Works on desktop and mobile
|
||||
- **Dark and light themes** - Toggle between dark and light modes
|
||||
- **Accent color picker** - Choose from 9 preset accent colors (green, blue, purple, pink, orange, red, teal, cyan, yellow)
|
||||
- **Tab-based navigation** - Player, Browser, Quick Actions, Scripts, and Callbacks tabs
|
||||
- **Audio spectrum visualizer** - Real-time frequency bars with beat-reactive album art scaling and glow (on-demand WASAPI loopback capture)
|
||||
- **Dynamic WebGL background** - Fragment shader-based animated background that reacts to audio beats and extracts colors from album art (toggle on/off in header)
|
||||
- **Display control** - Monitor brightness adjustment and power on/off
|
||||
- **Header quick links** - Configurable external URLs with icons shown in the header bar
|
||||
- **Installable PWA** - Add to home screen on mobile/desktop for standalone app experience with safe area support for notched phones
|
||||
- **Responsive design** - Works on desktop, tablet, and mobile
|
||||
- **Dark and light themes** - Toggle between dark and light modes with dynamic status bar theming
|
||||
- **Accent color picker** - Choose from 9 preset accent colors or pick a custom color
|
||||
- **Tab-based navigation** - Player, Display, Browser, Quick Actions, and Settings tabs
|
||||
- **Multi-language support** - English and Russian locales with automatic detection
|
||||
|
||||
### Accessing the Web UI
|
||||
|
||||
1. Start the media server:
|
||||
|
||||
```bash
|
||||
python -m media_server.main
|
||||
```
|
||||
|
||||
2. Open your browser and navigate to:
|
||||
```
|
||||
|
||||
```text
|
||||
http://localhost:8765/
|
||||
```
|
||||
|
||||
@@ -58,6 +70,32 @@ The media server includes a built-in web interface for controlling and monitorin
|
||||
|
||||
4. Start playing media in any supported player and watch the UI update in real-time!
|
||||
|
||||
### Installing as a PWA
|
||||
|
||||
The Web UI can be installed as a Progressive Web App for a native app-like experience:
|
||||
|
||||
1. Open the Web UI in Chrome/Edge on your phone or desktop
|
||||
2. Tap the **Install** icon in the address bar (or "Add to Home Screen" on mobile)
|
||||
3. The app launches in standalone mode — no browser chrome, with proper safe area handling for notched phones
|
||||
|
||||
### Audio Visualizer
|
||||
|
||||
The Web UI includes a real-time audio spectrum visualizer that captures system audio output:
|
||||
|
||||
- **On-demand capture** - Audio capture starts only when a client enables the visualizer, and stops when the last client disconnects
|
||||
- **Beat-reactive effects** - Album art pulses and glows in response to bass frequencies
|
||||
- **Dynamic WebGL background** - Animated shader background that reacts to bass frequencies and adapts colors from current album art
|
||||
- **Configurable device** - Select which audio output device to capture in Settings
|
||||
|
||||
Requires `soundcard` and `numpy` Python packages. Enable in `config.yaml`:
|
||||
|
||||
```yaml
|
||||
visualizer_enabled: true
|
||||
visualizer_fps: 30 # Frame rate (10-60)
|
||||
visualizer_bins: 32 # Frequency bins (8-128)
|
||||
# visualizer_device: "Speakers" # optional: specific device name
|
||||
```
|
||||
|
||||
### Remote Access
|
||||
|
||||
To access the Web UI from other devices on your network:
|
||||
@@ -106,7 +144,7 @@ The Media Browser feature allows you to browse and play media files from configu
|
||||
|
||||

|
||||
|
||||
### Browser Features
|
||||
### Browser Highlights
|
||||
|
||||
- **Folder Configuration** - Mount multiple media folders (music/video directories)
|
||||
- **Recursive Navigation** - Browse through folder hierarchies with breadcrumb navigation
|
||||
@@ -114,13 +152,14 @@ The Media Browser feature allows you to browse and play media files from configu
|
||||
- **Thumbnail Display** - Automatically generated thumbnails from album art (lazy-loaded)
|
||||
- **Metadata Extraction** - View title, artist, album, duration, bitrate, file size, and more
|
||||
- **Remote Playback** - Play files on the PC running the media server (not in the browser)
|
||||
- **Play All** - Play all media files in the current folder
|
||||
- **Play All** - Play all media files in the current folder (generates M3U playlist)
|
||||
- **File Download** - Download individual media files directly from the browser
|
||||
- **Search & Filter** - Real-time search across files in the current folder
|
||||
- **Pagination** - Navigate large folders with configurable page sizes (25, 50, 100, 200, 500)
|
||||
- **Last Path Memory** - Automatically returns to your last browsed location
|
||||
- **Folder Management** - Create, edit, and delete media folders from the UI
|
||||
|
||||
### Configuration
|
||||
### Browser Setup
|
||||
|
||||
Add media folders in your `config.yaml`:
|
||||
|
||||
@@ -150,9 +189,9 @@ When you play a file from the Media Browser:
|
||||
|
||||
### Media Player Compatibility
|
||||
|
||||
**⚠️ Important Limitation:** Not all media players expose their playback information to the Windows Media Session API. This means some players will open and play the file, but the Media Server UI won't show playback status, track information, or allow remote control.
|
||||
**Important Limitation:** Not all media players expose their playback information to the Windows Media Session API. This means some players will open and play the file, but the Media Server UI won't show playback status, track information, or allow remote control.
|
||||
|
||||
**✅ Compatible Players** (work with playback tracking):
|
||||
**Compatible Players** (work with playback tracking):
|
||||
|
||||
- **VLC Media Player** - Full support
|
||||
- **Groove Music** (Windows 10/11 built-in) - Full support
|
||||
@@ -160,39 +199,84 @@ When you play a file from the Media Browser:
|
||||
- **Chrome/Edge/Firefox** - Full support for web players
|
||||
- **foobar2000** - Full support (with proper configuration/plugins)
|
||||
|
||||
**❌ Limited/No Support:**
|
||||
**Limited/No Support:**
|
||||
|
||||
- **Windows Media Player Classic** - Opens files but doesn't expose session info
|
||||
- **Windows Media Player** (classic version) - Limited session support
|
||||
|
||||
**Recommendation:** Set **VLC Media Player** or **Groove Music** as your default audio player for the best experience with the Media Browser.
|
||||
|
||||
#### Changing Your Default Media Player (Windows)
|
||||
#### Changing Your Default Media Player (on Windows)
|
||||
|
||||
1. Open Windows Settings → Apps → Default apps
|
||||
1. Open Windows Settings > Apps > Default apps
|
||||
2. Search for "Music player" or "Video player"
|
||||
3. Select VLC Media Player or Groove Music
|
||||
4. Files opened from Media Browser will now use the selected player
|
||||
|
||||
### API Endpoints
|
||||
## Display Control
|
||||
|
||||
The Media Browser exposes several REST API endpoints:
|
||||
The Display Control feature allows you to manage monitor brightness and power state from the Web UI or via API.
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|--------------------------|--------|-----------------------------------|
|
||||
| `/api/browser/folders` | GET | List configured media folders |
|
||||
| `/api/browser/browse` | GET | Browse directory contents |
|
||||
| `/api/browser/metadata` | GET | Get media file metadata |
|
||||
| `/api/browser/thumbnail` | GET | Get thumbnail image |
|
||||
| `/api/browser/play` | POST | Open file with default player |
|
||||
- **Brightness adjustment** - Set brightness (0-100%) for each monitor
|
||||
- **Power management** - Turn monitors on or off
|
||||
- **Multi-monitor support** - See all connected monitors with model, manufacturer, and resolution info
|
||||
- **Technology**: DDC-CI on Windows, XRandR/ACPI on Linux, IOKit on macOS
|
||||
|
||||
All endpoints require bearer token authentication.
|
||||
### Display API
|
||||
|
||||
### Security Notes
|
||||
| Endpoint | Method | Body | Description |
|
||||
|------------------------------------------|----------|---------------------------|--------------------------------------------------|
|
||||
| `/api/display/monitors` | GET | - | List monitors (use `?refresh=true` to refresh) |
|
||||
| `/api/display/brightness/{monitor_id}` | POST | `{"brightness": 0-100}` | Set monitor brightness |
|
||||
| `/api/display/power/{monitor_id}` | POST | `{"on": true\ | false}` |
|
||||
|
||||
- **Path Traversal Protection** - All paths are validated to prevent directory traversal attacks
|
||||
- **Folder Restrictions** - Only configured folders are accessible
|
||||
- **Authentication Required** - All endpoints require a valid API token
|
||||
**Monitor response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 0,
|
||||
"name": "Monitor Name",
|
||||
"brightness": 100,
|
||||
"power_supported": true,
|
||||
"power_on": true,
|
||||
"model": "Model Number",
|
||||
"manufacturer": "Manufacturer",
|
||||
"resolution": "1920x1080",
|
||||
"is_primary": true
|
||||
}
|
||||
```
|
||||
|
||||
## Header Links
|
||||
|
||||
Configure quick-access links that appear in the Web UI header bar with custom icons.
|
||||
|
||||
### Links Setup
|
||||
|
||||
Add links in your `config.yaml`:
|
||||
|
||||
```yaml
|
||||
links:
|
||||
spotify:
|
||||
url: "https://open.spotify.com"
|
||||
icon: "mdi:spotify"
|
||||
label: "Spotify"
|
||||
settings:
|
||||
url: "https://your-server.com/settings"
|
||||
icon: "mdi:cog"
|
||||
label: "Settings"
|
||||
description: "System settings"
|
||||
```
|
||||
|
||||
### Links API
|
||||
|
||||
| Endpoint | Method | Body | Description |
|
||||
|-----------------------------------|----------|-------------------------------------|------------------|
|
||||
| `/api/links/list` | GET | - | List all links |
|
||||
| `/api/links/create/{link_name}` | POST | `{url, icon, label, description}` | Create link |
|
||||
| `/api/links/update/{link_name}` | PUT | `{url, icon, label, description}` | Update link |
|
||||
| `/api/links/delete/{link_name}` | DELETE | - | Delete link |
|
||||
|
||||
All connected WebSocket clients receive a `links_changed` notification when links are modified.
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -201,7 +285,7 @@ All endpoints require bearer token authentication.
|
||||
|
||||
## Installation
|
||||
|
||||
### Windows
|
||||
### Installing on Windows
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
@@ -209,7 +293,7 @@ pip install -r requirements.txt
|
||||
|
||||
Required packages: `winsdk`, `pywin32`, `pycaw`, `comtypes`
|
||||
|
||||
### Linux
|
||||
### Installing on Linux
|
||||
|
||||
```bash
|
||||
# Install system dependencies
|
||||
@@ -218,7 +302,7 @@ sudo apt-get install python3-dbus python3-gi libdbus-1-dev libglib2.0-dev
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### macOS
|
||||
### Installing on macOS
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
@@ -226,7 +310,7 @@ pip install -r requirements.txt
|
||||
|
||||
No additional dependencies - uses built-in `osascript`.
|
||||
|
||||
### Android (Termux)
|
||||
### Installing on Android (Termux)
|
||||
|
||||
```bash
|
||||
# In Termux
|
||||
@@ -239,26 +323,31 @@ Requires Termux and Termux:API apps from F-Droid.
|
||||
## Quick Start
|
||||
|
||||
1. Generate configuration with API token:
|
||||
|
||||
```bash
|
||||
python -m media_server.main --generate-config
|
||||
```
|
||||
|
||||
2. View your API token:
|
||||
|
||||
```bash
|
||||
python -m media_server.main --show-token
|
||||
```
|
||||
|
||||
3. Start the server:
|
||||
|
||||
```bash
|
||||
python -m media_server.main
|
||||
```
|
||||
|
||||
4. **Open the Web UI** (recommended):
|
||||
|
||||
- Navigate to `http://localhost:8765/` in your browser
|
||||
- Enter your API token from step 2
|
||||
- Start playing media and control it from the web interface!
|
||||
|
||||
5. Or test via API:
|
||||
|
||||
```bash
|
||||
# Health check (no auth required)
|
||||
curl http://localhost:8765/api/health
|
||||
@@ -267,13 +356,14 @@ Requires Termux and Termux:API apps from F-Droid.
|
||||
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:8765/api/media/status
|
||||
```
|
||||
|
||||
## Configuration
|
||||
## Configuration Reference
|
||||
|
||||
Configuration file locations:
|
||||
|
||||
- Windows: `%APPDATA%\media-server\config.yaml`
|
||||
- Linux/macOS: `~/.config/media-server/config.yaml`
|
||||
|
||||
### config.yaml
|
||||
### Full config.yaml Example
|
||||
|
||||
```yaml
|
||||
host: 0.0.0.0
|
||||
@@ -287,29 +377,75 @@ api_tokens:
|
||||
|
||||
poll_interval: 1.0
|
||||
log_level: INFO
|
||||
|
||||
# Audio device for system volume control (null = default device)
|
||||
audio_device: null
|
||||
|
||||
# Audio visualizer (requires soundcard + numpy)
|
||||
visualizer_enabled: true
|
||||
visualizer_fps: 30
|
||||
visualizer_bins: 32
|
||||
visualizer_device: null # null = auto-detect loopback
|
||||
|
||||
# Media folders for browser
|
||||
media_folders:
|
||||
music:
|
||||
path: "C:\\Users\\YourUsername\\Music"
|
||||
label: "My Music"
|
||||
enabled: true
|
||||
|
||||
# Thumbnail size: "small" (150x150), "medium" (300x300), or "both"
|
||||
thumbnail_size: "medium"
|
||||
|
||||
# Custom scripts (execute via API/UI)
|
||||
scripts:
|
||||
lock_screen:
|
||||
command: "rundll32.exe user32.dll,LockWorkStation"
|
||||
label: "Lock Screen"
|
||||
description: "Lock the workstation"
|
||||
icon: "mdi:lock"
|
||||
timeout: 5
|
||||
shell: true
|
||||
|
||||
# Callbacks (execute after media actions)
|
||||
callbacks:
|
||||
on_turn_off:
|
||||
command: "rundll32.exe user32.dll,LockWorkStation"
|
||||
timeout: 5
|
||||
shell: true
|
||||
|
||||
# Header quick links
|
||||
links:
|
||||
spotify:
|
||||
url: "https://open.spotify.com"
|
||||
icon: "mdi:spotify"
|
||||
label: "Spotify"
|
||||
```
|
||||
|
||||
### Authentication
|
||||
|
||||
The media server supports multiple API tokens with friendly labels. This allows you to:
|
||||
|
||||
- Issue different tokens for different clients (Home Assistant, mobile apps, web UI, etc.)
|
||||
- Identify which client is making requests in the server logs
|
||||
- Revoke individual tokens without affecting other clients
|
||||
|
||||
**Token labels** appear in all server logs, making it easy to track and debug client connections:
|
||||
|
||||
```
|
||||
```text
|
||||
2026-02-06 03:36:20,806 - media_server.services.websocket_manager - [home_assistant] - INFO - WebSocket client connected
|
||||
2026-02-06 03:28:24,258 - media_server.routes.scripts - [mobile] - INFO - Executing script: lock_screen
|
||||
```
|
||||
|
||||
**Viewing your tokens:**
|
||||
|
||||
```bash
|
||||
python -m media_server.main --show-token
|
||||
```
|
||||
|
||||
Output:
|
||||
```
|
||||
|
||||
```text
|
||||
Config directory: C:\Users\...\AppData\Roaming\media-server
|
||||
|
||||
API Tokens:
|
||||
@@ -334,13 +470,14 @@ export MEDIA_SERVER_LOG_LEVEL=DEBUG
|
||||
|
||||
### Health Check
|
||||
|
||||
```
|
||||
```text
|
||||
GET /api/health
|
||||
```
|
||||
|
||||
No authentication required. Returns server status and platform info.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
@@ -351,12 +488,13 @@ No authentication required. Returns server status and platform info.
|
||||
|
||||
### Get Media Status
|
||||
|
||||
```
|
||||
```text
|
||||
GET /api/media/status
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"state": "playing",
|
||||
@@ -372,60 +510,66 @@ Authorization: Bearer <token>
|
||||
}
|
||||
```
|
||||
|
||||
### Album Artwork
|
||||
|
||||
```text
|
||||
GET /api/media/artwork
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
Returns current album artwork as PNG/JPEG/WebP binary. Also accepts token as a query parameter.
|
||||
|
||||
### Media Controls
|
||||
|
||||
All control endpoints require authentication and return `{"success": true}` on success.
|
||||
|
||||
| Endpoint | Method | Body | Description |
|
||||
|----------|--------|------|-------------|
|
||||
| `/api/media/play` | POST | - | Resume playback |
|
||||
| `/api/media/pause` | POST | - | Pause playback |
|
||||
| `/api/media/stop` | POST | - | Stop playback |
|
||||
| `/api/media/next` | POST | - | Next track |
|
||||
| `/api/media/previous` | POST | - | Previous track |
|
||||
| `/api/media/volume` | POST | `{"volume": 75}` | Set volume (0-100) |
|
||||
| `/api/media/mute` | POST | - | Toggle mute |
|
||||
| `/api/media/seek` | POST | `{"position": 60.0}` | Seek to position (seconds) |
|
||||
| `/api/media/turn_on` | POST | - | Execute on_turn_on callback |
|
||||
| `/api/media/turn_off` | POST | - | Execute on_turn_off callback |
|
||||
| `/api/media/toggle` | POST | - | Execute on_toggle callback |
|
||||
| Endpoint | Method | Body | Description |
|
||||
|-------------------------|----------|------------------------|--------------------------------|
|
||||
| `/api/media/play` | POST | - | Resume playback |
|
||||
| `/api/media/pause` | POST | - | Pause playback |
|
||||
| `/api/media/stop` | POST | - | Stop playback |
|
||||
| `/api/media/next` | POST | - | Next track |
|
||||
| `/api/media/previous` | POST | - | Previous track |
|
||||
| `/api/media/volume` | POST | `{"volume": 75}` | Set volume (0-100) |
|
||||
| `/api/media/mute` | POST | - | Toggle mute |
|
||||
| `/api/media/seek` | POST | `{"position": 60.0}` | Seek to position (seconds) |
|
||||
| `/api/media/turn_on` | POST | - | Execute on_turn_on callback |
|
||||
| `/api/media/turn_off` | POST | - | Execute on_turn_off callback |
|
||||
| `/api/media/toggle` | POST | - | Execute on_toggle callback |
|
||||
|
||||
### Script Execution
|
||||
### Visualizer API
|
||||
|
||||
The server supports executing pre-defined scripts via API and the Web UI. Scripts and callbacks can be managed directly from the Web UI — add, edit, delete, and execute with real-time output display.
|
||||
| Endpoint | Method | Body | Description |
|
||||
|-----------------------------------|----------|-----------------------------------------|----------------------------------------|
|
||||
| `/api/media/visualizer/status` | GET | - | Check availability and running state |
|
||||
| `/api/media/visualizer/devices` | GET | - | List loopback audio devices |
|
||||
| `/api/media/visualizer/device` | POST | `{"device_name": "..." \ | null}` |
|
||||
|
||||
### Audio Devices
|
||||
|
||||
```text
|
||||
GET /api/audio/devices
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
Returns a list of available audio output devices.
|
||||
|
||||
### Script Management
|
||||
|
||||
Scripts can be managed via API or directly from the Web UI (Quick Actions tab).
|
||||
|
||||

|
||||
|
||||
#### List Scripts
|
||||
| Endpoint | Method | Body | Description |
|
||||
|----------------------------------------|----------|---------------------------------------------------------|--------------------|
|
||||
| `/api/scripts/list` | GET | - | List all scripts |
|
||||
| `/api/scripts/execute/{script_name}` | POST | `{"args": []}` | Execute a script |
|
||||
| `/api/scripts/create/{script_name}` | POST | `{command, label, description, icon, timeout, shell}` | Create a script |
|
||||
| `/api/scripts/update/{script_name}` | PUT | `{command, label, description, icon, timeout, shell}` | Update a script |
|
||||
| `/api/scripts/delete/{script_name}` | DELETE | - | Delete a script |
|
||||
|
||||
```
|
||||
GET /api/scripts/list
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
**Execute response:**
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "lock_screen",
|
||||
"label": "Lock Screen",
|
||||
"description": "Lock the workstation",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
#### Execute Script
|
||||
|
||||
```
|
||||
POST /api/scripts/execute/{script_name}
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{"args": []}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
@@ -436,7 +580,7 @@ Content-Type: application/json
|
||||
}
|
||||
```
|
||||
|
||||
### Configuring Scripts
|
||||
### Script Config Options
|
||||
|
||||
Add scripts in your `config.yaml`:
|
||||
|
||||
@@ -478,21 +622,33 @@ scripts:
|
||||
shell: true
|
||||
```
|
||||
|
||||
Script configuration options:
|
||||
Script fields:
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| `command` | Yes | Command to execute |
|
||||
| `label` | No | User-friendly display name (defaults to script name) |
|
||||
| `description` | No | Description of what the script does |
|
||||
| `icon` | No | Custom MDI icon (e.g., `mdi:power`) |
|
||||
| `timeout` | No | Execution timeout in seconds (default: 30, max: 300) |
|
||||
| `working_dir` | No | Working directory for the command |
|
||||
| `shell` | No | Run in shell (default: true) |
|
||||
| Field | Required | Description |
|
||||
|-----------------|------------|--------------------------------------------------------|
|
||||
| `command` | Yes | Command to execute |
|
||||
| `label` | No | User-friendly display name (defaults to script name) |
|
||||
| `description` | No | Description of what the script does |
|
||||
| `icon` | No | Custom MDI icon (e.g., `mdi:power`) |
|
||||
| `timeout` | No | Execution timeout in seconds (default: 30, max: 300) |
|
||||
| `working_dir` | No | Working directory for the command |
|
||||
| `shell` | No | Run in shell (default: true) |
|
||||
|
||||
### Configuring Callbacks
|
||||
### Callback Management
|
||||
|
||||
Callbacks are optional commands executed after media actions. Add them in your `config.yaml`:
|
||||
Callbacks are commands executed after media actions. They can be managed via API or the Web UI (Quick Actions tab).
|
||||
|
||||
| Endpoint | Method | Body | Description |
|
||||
|-------------------------------------------|----------|--------------------------------------------|--------------------------|
|
||||
| `/api/callbacks/list` | GET | - | List all callbacks |
|
||||
| `/api/callbacks/execute/{callback_name}` | POST | - | Execute (for debugging) |
|
||||
| `/api/callbacks/create/{callback_name}` | POST | `{command, timeout, working_dir, shell}` | Create a callback |
|
||||
| `/api/callbacks/update/{callback_name}` | PUT | `{command, timeout, working_dir, shell}` | Update a callback |
|
||||
| `/api/callbacks/delete/{callback_name}` | DELETE | - | Delete a callback |
|
||||
|
||||
### Callback Config Options
|
||||
|
||||
Add callbacks in your `config.yaml`:
|
||||
|
||||
```yaml
|
||||
callbacks:
|
||||
@@ -556,28 +712,83 @@ callbacks:
|
||||
|
||||
Available callbacks:
|
||||
|
||||
| Callback | Triggered by | Description |
|
||||
|----------|--------------|-------------|
|
||||
| `on_play` | `/api/media/play` | After play succeeds |
|
||||
| `on_pause` | `/api/media/pause` | After pause succeeds |
|
||||
| `on_stop` | `/api/media/stop` | After stop succeeds |
|
||||
| `on_next` | `/api/media/next` | After next track succeeds |
|
||||
| `on_previous` | `/api/media/previous` | After previous track succeeds |
|
||||
| `on_volume` | `/api/media/volume` | After volume change succeeds |
|
||||
| `on_mute` | `/api/media/mute` | After mute toggle |
|
||||
| `on_seek` | `/api/media/seek` | After seek succeeds |
|
||||
| `on_turn_on` | `/api/media/turn_on` | Callback-only action |
|
||||
| `on_turn_off` | `/api/media/turn_off` | Callback-only action |
|
||||
| `on_toggle` | `/api/media/toggle` | Callback-only action |
|
||||
| Callback | Triggered by | Description |
|
||||
|-----------------|-------------------------|---------------------------------|
|
||||
| `on_play` | `/api/media/play` | After play succeeds |
|
||||
| `on_pause` | `/api/media/pause` | After pause succeeds |
|
||||
| `on_stop` | `/api/media/stop` | After stop succeeds |
|
||||
| `on_next` | `/api/media/next` | After next track succeeds |
|
||||
| `on_previous` | `/api/media/previous` | After previous track succeeds |
|
||||
| `on_volume` | `/api/media/volume` | After volume change succeeds |
|
||||
| `on_mute` | `/api/media/mute` | After mute toggle |
|
||||
| `on_seek` | `/api/media/seek` | After seek succeeds |
|
||||
| `on_turn_on` | `/api/media/turn_on` | Callback-only action |
|
||||
| `on_turn_off` | `/api/media/turn_off` | Callback-only action |
|
||||
| `on_toggle` | `/api/media/toggle` | Callback-only action |
|
||||
|
||||
Callback configuration options:
|
||||
Callback fields:
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| `command` | Yes | Command to execute |
|
||||
| `timeout` | No | Execution timeout in seconds (default: 30, max: 300) |
|
||||
| `working_dir` | No | Working directory for the command |
|
||||
| `shell` | No | Run in shell (default: true) |
|
||||
| Field | Required | Description |
|
||||
|-----------------|------------|--------------------------------------------------------|
|
||||
| `command` | Yes | Command to execute |
|
||||
| `timeout` | No | Execution timeout in seconds (default: 30, max: 300) |
|
||||
| `working_dir` | No | Working directory for the command |
|
||||
| `shell` | No | Run in shell (default: true) |
|
||||
|
||||
### Browser API
|
||||
|
||||
| Endpoint | Method | Body | Description |
|
||||
|---------------------------------------------|----------|----------------------------------------|--------------------------------------------|
|
||||
| `/api/browser/folders` | GET | - | List configured media folders |
|
||||
| `/api/browser/browse` | GET | - | Browse directory (query: folder_id, path) |
|
||||
| `/api/browser/metadata` | GET | - | Get file metadata (query: file_path) |
|
||||
| `/api/browser/thumbnail` | GET | - | Get thumbnail image (query: file_path) |
|
||||
| `/api/browser/download` | GET | - | Download file (query: folder_id, path) |
|
||||
| `/api/browser/play` | POST | `{"file_path": "..."}` | Open file with default player |
|
||||
| `/api/browser/play-folder` | POST | `{"folder_id": "...", "path": ""}` | Play all files in folder (M3U) |
|
||||
| `/api/browser/folders/create` | POST | `{folder_id, label, path, enabled}` | Create folder config |
|
||||
| `/api/browser/folders/update/{folder_id}` | PUT | `{label, path, enabled}` | Update folder config |
|
||||
| `/api/browser/folders/delete/{folder_id}` | DELETE | - | Delete folder config |
|
||||
|
||||
All endpoints require bearer token authentication.
|
||||
|
||||
### Security Notes
|
||||
|
||||
- **Path Traversal Protection** - All paths are validated to prevent directory traversal attacks
|
||||
- **Folder Restrictions** - Only configured folders are accessible
|
||||
- **Authentication Required** - All endpoints require a valid API token
|
||||
- **Output Limits** - Script stdout/stderr capped at 10KB
|
||||
|
||||
### WebSocket
|
||||
|
||||
```text
|
||||
WebSocket /api/media/ws
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
The WebSocket connection provides real-time updates and low-latency control.
|
||||
|
||||
**Messages from server:**
|
||||
|
||||
| Type | Data | Description |
|
||||
|---------------------|------------------------|----------------------------------------|
|
||||
| `status` | Media status object | Initial status on connection |
|
||||
| `status_update` | Media status object | Status changes during playback |
|
||||
| `audio_data` | `[0.1, 0.2, ...]` | Visualizer frequency data (30 fps) |
|
||||
| `scripts_changed` | `{}` | Scripts were created/updated/deleted |
|
||||
| `links_changed` | `{}` | Links were created/updated/deleted |
|
||||
| `pong` | - | Response to client ping |
|
||||
| `error` | `{"message": "..."}` | Error messages |
|
||||
|
||||
**Messages from client:**
|
||||
|
||||
| Type | Data | Description |
|
||||
|-----------------------|------------------------|------------------------------------------------------------|
|
||||
| `ping` | - | Keepalive ping |
|
||||
| `get_status` | - | Request current status |
|
||||
| `volume` | `{"volume": 0-100}` | Low-latency volume control via WebSocket |
|
||||
| `enable_visualizer` | - | Subscribe to audio data (starts capture) |
|
||||
| `disable_visualizer` | - | Unsubscribe from audio data (stops capture on last client) |
|
||||
|
||||
## Running as a Service
|
||||
|
||||
@@ -595,20 +806,23 @@ To remove the scheduled task:
|
||||
Unregister-ScheduledTask -TaskName "MediaServer" -Confirm:$false
|
||||
```
|
||||
|
||||
### Windows Service
|
||||
### Windows Service (Alternative)
|
||||
|
||||
Install:
|
||||
|
||||
```bash
|
||||
python -m media_server.service.install_windows install
|
||||
```
|
||||
|
||||
Start/Stop:
|
||||
|
||||
```bash
|
||||
python -m media_server.service.install_windows start
|
||||
python -m media_server.service.install_windows stop
|
||||
```
|
||||
|
||||
Remove:
|
||||
|
||||
```bash
|
||||
python -m media_server.service.install_windows remove
|
||||
```
|
||||
@@ -616,24 +830,27 @@ python -m media_server.service.install_windows remove
|
||||
### Linux (systemd)
|
||||
|
||||
Install:
|
||||
|
||||
```bash
|
||||
sudo ./service/install_linux.sh install
|
||||
```
|
||||
|
||||
Enable and start for your user:
|
||||
|
||||
```bash
|
||||
sudo systemctl enable media-server@$USER
|
||||
sudo systemctl start media-server@$USER
|
||||
```
|
||||
|
||||
View logs:
|
||||
|
||||
```bash
|
||||
journalctl -u media-server@$USER -f
|
||||
```
|
||||
|
||||
## Command Line Options
|
||||
|
||||
```
|
||||
```text
|
||||
python -m media_server.main [OPTIONS]
|
||||
|
||||
Options:
|
||||
@@ -652,15 +869,19 @@ Options:
|
||||
|
||||
## Supported Media Players
|
||||
|
||||
### Windows
|
||||
### Players on Windows
|
||||
|
||||
- Spotify
|
||||
- Windows Media Player
|
||||
- VLC
|
||||
- Groove Music
|
||||
- Web browsers (Chrome, Edge, Firefox)
|
||||
- foobar2000
|
||||
- AIMP
|
||||
- Web browsers (Chrome, Edge, Firefox, Opera, Brave)
|
||||
- Any app using Windows Media Transport Controls
|
||||
|
||||
### Linux
|
||||
### Players on Linux
|
||||
|
||||
- Any MPRIS-compliant player:
|
||||
- Spotify
|
||||
- VLC
|
||||
@@ -669,28 +890,33 @@ Options:
|
||||
- Web browsers
|
||||
- MPD (with MPRIS bridge)
|
||||
|
||||
### macOS
|
||||
### Players on macOS
|
||||
|
||||
- Spotify
|
||||
- Apple Music
|
||||
- VLC (partial)
|
||||
- QuickTime Player
|
||||
|
||||
### Android (via Termux)
|
||||
### Players on Android (via Termux)
|
||||
|
||||
- System media controls
|
||||
- Limited seek support
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "No active media session"
|
||||
|
||||
- Ensure a media player is running and has played content
|
||||
- On Windows, check that the app supports media transport controls
|
||||
- On Linux, verify MPRIS with: `dbus-send --print-reply --dest=org.freedesktop.DBus /org/freedesktop/DBus org.freedesktop.DBus.ListNames | grep mpris`
|
||||
|
||||
### Permission errors on Linux
|
||||
|
||||
- Ensure your user has access to the D-Bus session bus
|
||||
- For systemd service, the `DBUS_SESSION_BUS_ADDRESS` must be set correctly
|
||||
|
||||
### Volume control not working
|
||||
|
||||
- Windows: Run as administrator if needed
|
||||
- Linux: Ensure PulseAudio/PipeWire is running
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
## v0.2.4 (2026-05-15)
|
||||
|
||||
### Features
|
||||
|
||||
- **Displays — DDC/CI picture controls:** Monitor cards now expose contrast (slider, same editorial copper treatment as brightness) plus a new "PICTURE TUNING" section with input source, color preset, and picture mode pickers built on the IconSelect widget — HDMI/DisplayPort/DVI/VGA/USB-C glyphs for inputs, thermometer for color temperatures, per-mode icons (movie reel, gamepad, ball, etc.) for picture modes. Backend probes DDC/CI capabilities per monitor at enumeration time and exposes `*_supported` flags so the UI hides rows the hardware doesn't advertise. New endpoints: `POST /api/display/{contrast,input_source,color_preset,picture_mode}/{id}`. Picture mode uses raw VCP `0xDC` with MCCS-spec labels and vendor-friendly fallbacks. 14 new i18n keys per locale (en/ru). ([57fdeb7](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/57fdeb7))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Displays — verify DDC/CI writes before reporting success:** DDC/CI writes are fire-and-forget at the protocol level — a successful send does not mean the monitor honored the value. A new `_verify_after_set` helper polls readback after every write and reports `{success: false}` when the monitor silently dropped it (common on LG ultrawides for VCP codes whose registers exist but whose feature isn't really implemented in firmware). Wired into `set_contrast`, `set_input_source`, `set_color_preset`, `set_picture_mode`; input source uses a longer settle window since switching can briefly disrupt the DDC/CI link. Picture mode (VCP `0xDC`) additionally requires the capability string to declare supported codes under `cmds[0xDC]` — without that declaration we treat the feature as unsupported even when reads succeed (the LG case where reads return a stuck value and every write is silently ignored). ([d1f621f](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d1f621f))
|
||||
|
||||
---
|
||||
|
||||
### Development / Internal
|
||||
|
||||
#### Chores
|
||||
|
||||
- **`restart-server.ps1` — installer vs dev launches:** Previously only killed processes named `media-server`, silently missing the installer-bundled process (which runs as plain `python.exe` via `media-server.bat`). The script now kills whatever currently owns the listen port regardless of process name, adds `-Mode auto|dev|installer` with auto-detection based on whether the installer launcher exists in `%LOCALAPPDATA%\Media Server`, verifies the port is listening after start, and merges registry PATH so newly-installed dev tools are visible. ([6120625](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/6120625))
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary>All Commits</summary>
|
||||
|
||||
| Hash | Message | Author |
|
||||
|------|---------|--------|
|
||||
| [57fdeb7](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/57fdeb7) | feat(displays): expose DDC/CI contrast, input source, color preset, picture mode | alexei.dolgolyov |
|
||||
| [6120625](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/6120625) | chore(scripts): harden restart-server.ps1 against installer vs dev launches | alexei.dolgolyov |
|
||||
| [d1f621f](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d1f621f) | fix(displays): verify DDC/CI writes and trust capability string for picture mode | 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.
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Build Linux distribution (self-contained venv + tarball)
|
||||
# Usage: ./build-dist-linux.sh [VERSION]
|
||||
|
||||
source "$(dirname "$0")/build-common.sh"
|
||||
|
||||
detect_version "${1:-}"
|
||||
echo "Building Media Server v${VERSION_CLEAN} for Linux"
|
||||
|
||||
# --- Configuration ---
|
||||
DIST_DIR="dist/media-server"
|
||||
BUILD_OUTPUT="build/MediaServer-v${VERSION_CLEAN}-linux-x64"
|
||||
|
||||
clean_dist "${DIST_DIR}" build
|
||||
verify_frontend
|
||||
|
||||
# --- Create self-contained virtualenv ---
|
||||
echo "Creating virtualenv..."
|
||||
python3 -m venv "${DIST_DIR}/venv"
|
||||
source "${DIST_DIR}/venv/bin/activate"
|
||||
pip install --quiet --upgrade pip
|
||||
pip install --quiet "."
|
||||
|
||||
# Remove the installed package (app source is on PYTHONPATH via launcher)
|
||||
rm -rf "${DIST_DIR}"/venv/lib/python*/site-packages/media_server*
|
||||
rm -rf "${DIST_DIR}"/venv/lib/python*/site-packages/media_server*.dist-info
|
||||
|
||||
deactivate
|
||||
|
||||
# Trim venv site-packages
|
||||
LINUX_SP=$(echo "${DIST_DIR}"/venv/lib/python*/site-packages)
|
||||
cleanup_site_packages "$LINUX_SP" "so" "so"
|
||||
|
||||
copy_app_files "$DIST_DIR"
|
||||
|
||||
# --- Create launcher ---
|
||||
cat > "${DIST_DIR}/media-server.sh" << 'LAUNCHER'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
export PYTHONPATH="$SCRIPT_DIR/app"
|
||||
source "$SCRIPT_DIR/venv/bin/activate"
|
||||
exec python -m media_server.main "$@"
|
||||
LAUNCHER
|
||||
chmod +x "${DIST_DIR}/media-server.sh"
|
||||
|
||||
# --- Create systemd service installer ---
|
||||
cat > "${DIST_DIR}/install-service.sh" << 'SERVICE'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
SERVICE_NAME="media-server"
|
||||
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "Please run with sudo: sudo ./install-service.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REAL_USER="${SUDO_USER:-$USER}"
|
||||
|
||||
cat > "$SERVICE_FILE" << EOF
|
||||
[Unit]
|
||||
Description=Media Server
|
||||
After=network.target sound.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=${REAL_USER}
|
||||
WorkingDirectory=${SCRIPT_DIR}
|
||||
ExecStart=${SCRIPT_DIR}/media-server.sh
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable "${SERVICE_NAME}"
|
||||
systemctl start "${SERVICE_NAME}"
|
||||
echo "Service '${SERVICE_NAME}' installed and started."
|
||||
echo "Check status: systemctl status ${SERVICE_NAME}"
|
||||
SERVICE
|
||||
chmod +x "${DIST_DIR}/install-service.sh"
|
||||
|
||||
# --- Package ---
|
||||
echo "Creating archive..."
|
||||
cp -r "${DIST_DIR}" "${BUILD_OUTPUT}"
|
||||
tar -czf "${BUILD_OUTPUT}.tar.gz" -C build "MediaServer-v${VERSION_CLEAN}-linux-x64"
|
||||
|
||||
echo "Build complete: ${BUILD_OUTPUT}.tar.gz"
|
||||
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Cross-build Windows distribution on Linux
|
||||
# Usage: ./build-dist-windows.sh [VERSION]
|
||||
|
||||
source "$(dirname "$0")/build-common.sh"
|
||||
|
||||
detect_version "${1:-}"
|
||||
echo "Building Media Server v${VERSION_CLEAN} for Windows"
|
||||
|
||||
# --- Configuration ---
|
||||
PYTHON_VERSION="3.11.9"
|
||||
PYTHON_SHORT="311"
|
||||
DIST_DIR="dist/media-server"
|
||||
WHEEL_DIR="build/win-wheels"
|
||||
SITE_PACKAGES="${DIST_DIR}/python/Lib/site-packages"
|
||||
BUILD_OUTPUT="build/MediaServer-v${VERSION_CLEAN}-win-x64"
|
||||
|
||||
clean_dist "${DIST_DIR}" "${WHEEL_DIR}" "${SITE_PACKAGES}"
|
||||
|
||||
# --- Download embedded Python (cache-friendly) ---
|
||||
mkdir -p build
|
||||
if [ ! -f build/python-embed.zip ]; then
|
||||
echo "Downloading embedded Python ${PYTHON_VERSION}..."
|
||||
curl -sL "https://www.python.org/ftp/python/${PYTHON_VERSION}/python-${PYTHON_VERSION}-embed-amd64.zip" \
|
||||
-o build/python-embed.zip
|
||||
else
|
||||
echo "Using cached embedded Python ${PYTHON_VERSION}"
|
||||
fi
|
||||
unzip -qo build/python-embed.zip -d "${DIST_DIR}/python"
|
||||
|
||||
# Patch ._pth to enable site-packages and app source
|
||||
PTH_FILE=$(ls "${DIST_DIR}"/python/python*._pth | head -1)
|
||||
sed -i 's/^#\s*import site/import site/' "$PTH_FILE"
|
||||
echo 'Lib\site-packages' >> "$PTH_FILE"
|
||||
echo '..\app' >> "$PTH_FILE"
|
||||
|
||||
# --- Download Windows wheels ---
|
||||
echo "Downloading Windows wheels..."
|
||||
|
||||
# Core dependencies
|
||||
# NOTE: uvicorn[standard] pulls in uvloop via `sys_platform != 'win32'` marker.
|
||||
# pip evaluates env markers against the HOST (Linux in CI), so uvloop is
|
||||
# requested, but `--platform win_amd64 --only-binary :all:` cannot find a
|
||||
# Windows wheel for uvloop (none exist). Result: pip backtracks across every
|
||||
# uvicorn[standard] version and ResolutionImpossible. Fix: use plain uvicorn
|
||||
# and list only the Windows-compatible standard extras we actually need.
|
||||
CORE_DEPS=(
|
||||
"fastapi>=0.109.0"
|
||||
"uvicorn>=0.27.0"
|
||||
"httptools>=0.5.0"
|
||||
"websockets>=10.4"
|
||||
"python-dotenv>=0.13"
|
||||
"pydantic>=2.0"
|
||||
"pydantic-settings>=2.0"
|
||||
"pyyaml>=6.0"
|
||||
"mutagen>=1.47.0"
|
||||
"pillow>=10.0.0"
|
||||
)
|
||||
|
||||
# Windows-specific dependencies
|
||||
# NOTE: wmi is a transitive dep of screen-brightness-control gated on
|
||||
# `platform_system == "Windows"`. pip evaluates env markers against the HOST
|
||||
# (Linux in CI), so it gets skipped during cross-build. Listed explicitly here
|
||||
# so the wheel actually lands in the Windows bundle. Same gotcha as the
|
||||
# uvicorn[standard]/uvloop case documented above.
|
||||
WIN_DEPS=(
|
||||
"winsdk>=1.0.0b10"
|
||||
"pywin32>=306"
|
||||
"comtypes>=1.2.0"
|
||||
"pycaw>=20230407"
|
||||
"screen-brightness-control>=0.20.0"
|
||||
"wmi>=1.5.1"
|
||||
"monitorcontrol>=3.0.0"
|
||||
)
|
||||
|
||||
# Visualizer dependencies
|
||||
VIS_DEPS=(
|
||||
"soundcard>=0.4.0"
|
||||
"numpy>=1.24.0,<2.0"
|
||||
# pystray lives here (not WIN_DEPS) so its transitive Pillow resolves in the
|
||||
# same pass as CORE_DEPS. Keeping it in the per-dep WIN_DEPS loop downloaded
|
||||
# a second Pillow version that clobbered the core one on unzip, producing
|
||||
# "_imaging extension was built for another version of Pillow" at runtime.
|
||||
"pystray>=0.19.0"
|
||||
)
|
||||
|
||||
# Resolve core + visualizer deps in a SINGLE call so pip picks compatible
|
||||
# transitive versions (notably pydantic/pydantic-core must match).
|
||||
# Per-dep loops resolve each dep independently and can leave mismatched
|
||||
# transitive versions that overwrite each other in the site-packages unzip.
|
||||
CROSS_DEPS=("${CORE_DEPS[@]}" "${VIS_DEPS[@]}")
|
||||
pip download --quiet --no-cache-dir --dest "$WHEEL_DIR" \
|
||||
--platform win_amd64 --python-version "${PYTHON_SHORT}" \
|
||||
--implementation cp --only-binary :all: \
|
||||
"${CROSS_DEPS[@]}"
|
||||
|
||||
# Windows-only deps in a loop with --pre: winsdk only ships beta wheels
|
||||
# (1.0.0bNN) and each dep needs its own platform/non-platform fallback.
|
||||
for dep in "${WIN_DEPS[@]}"; do
|
||||
pip download --pre --quiet --no-cache-dir --dest "$WHEEL_DIR" \
|
||||
--platform win_amd64 --python-version "${PYTHON_SHORT}" \
|
||||
--implementation cp --only-binary :all: \
|
||||
"$dep" 2>/dev/null || \
|
||||
pip download --pre --quiet --no-cache-dir --dest "$WHEEL_DIR" \
|
||||
--only-binary :all: \
|
||||
"$dep"
|
||||
done
|
||||
|
||||
# Remove numpy 2.x wheels pulled as transitive deps (soundcard requires <2.0)
|
||||
for f in "$WHEEL_DIR"/numpy-2*; do
|
||||
[ -f "$f" ] && echo "Removing incompatible: $(basename "$f")" && rm "$f"
|
||||
done
|
||||
|
||||
# Install wheels into site-packages
|
||||
echo "Installing wheels..."
|
||||
for whl in "$WHEEL_DIR"/*.whl; do
|
||||
unzip -qo "$whl" -d "$SITE_PACKAGES"
|
||||
done
|
||||
|
||||
# numpy wheels from PyPI don't include _distributor_init_local.py unless
|
||||
# patched by delvewheel. In embedded Python, os.add_dll_directory() is never
|
||||
# called, so libopenblas can't be found and numpy fails to import.
|
||||
# Generate the missing loader here instead.
|
||||
if [ -d "${SITE_PACKAGES}/numpy" ]; then
|
||||
cat > "${SITE_PACKAGES}/numpy/_distributor_init_local.py" << 'EOF'
|
||||
import os
|
||||
import sys
|
||||
if sys.platform == 'win32':
|
||||
_libs = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'numpy.libs'))
|
||||
if os.path.isdir(_libs):
|
||||
os.add_dll_directory(_libs)
|
||||
EOF
|
||||
echo "Generated numpy/_distributor_init_local.py"
|
||||
fi
|
||||
|
||||
cleanup_site_packages "$SITE_PACKAGES" "pyd" "dll"
|
||||
verify_frontend
|
||||
copy_app_files "$DIST_DIR"
|
||||
|
||||
# Copy scripts needed for auto-start
|
||||
mkdir -p "${DIST_DIR}/scripts"
|
||||
cp scripts/start-hidden.vbs "${DIST_DIR}/scripts/"
|
||||
|
||||
# --- Create launcher ---
|
||||
cat > "${DIST_DIR}/media-server.bat" << 'LAUNCHER'
|
||||
@echo off
|
||||
setlocal
|
||||
set "ROOT=%~dp0"
|
||||
"%ROOT%python\python.exe" -m media_server.main %*
|
||||
LAUNCHER
|
||||
|
||||
# --- Package ---
|
||||
echo "Creating archives..."
|
||||
mkdir -p build
|
||||
|
||||
# Portable ZIP
|
||||
cp -r "${DIST_DIR}" "${BUILD_OUTPUT}"
|
||||
cd build
|
||||
zip -qr "MediaServer-v${VERSION_CLEAN}-win-x64.zip" "MediaServer-v${VERSION_CLEAN}-win-x64"
|
||||
cd ..
|
||||
|
||||
echo "Build complete: build/MediaServer-v${VERSION_CLEAN}-win-x64.zip"
|
||||
echo "Dist directory ready for NSIS: ${DIST_DIR}"
|
||||
+16
-5
@@ -1,13 +1,14 @@
|
||||
# Media Server Configuration
|
||||
# Copy this file to config.yaml and customize as needed.
|
||||
# A secure token will be auto-generated on first run if not specified.
|
||||
# By default, authentication is DISABLED (no tokens = open access).
|
||||
# To enable auth, uncomment and configure the api_tokens section below.
|
||||
|
||||
# API Tokens - Multiple tokens with friendly labels
|
||||
# This allows you to identify which client is making requests in the logs
|
||||
api_tokens:
|
||||
home_assistant: "your-home-assistant-token-here"
|
||||
mobile: "your-mobile-app-token-here"
|
||||
web_ui: "your-web-ui-token-here"
|
||||
# api_tokens:
|
||||
# home_assistant: "your-home-assistant-token-here"
|
||||
# mobile: "your-mobile-app-token-here"
|
||||
# web_ui: "your-web-ui-token-here"
|
||||
|
||||
# Server settings
|
||||
host: "0.0.0.0"
|
||||
@@ -19,6 +20,7 @@ scripts:
|
||||
command: "rundll32.exe user32.dll,LockWorkStation"
|
||||
label: "Lock Screen"
|
||||
description: "Lock the workstation"
|
||||
icon: "mdi:lock"
|
||||
timeout: 5
|
||||
shell: true
|
||||
|
||||
@@ -26,6 +28,7 @@ scripts:
|
||||
command: "shutdown /h"
|
||||
label: "Hibernate"
|
||||
description: "Hibernate the PC"
|
||||
icon: "mdi:power-sleep"
|
||||
timeout: 10
|
||||
shell: true
|
||||
|
||||
@@ -33,6 +36,7 @@ scripts:
|
||||
command: "rundll32.exe powrprof.dll,SetSuspendState 0,1,0"
|
||||
label: "Sleep"
|
||||
description: "Put PC to sleep"
|
||||
icon: "mdi:sleep"
|
||||
timeout: 10
|
||||
shell: true
|
||||
|
||||
@@ -40,6 +44,7 @@ scripts:
|
||||
command: "shutdown /s /t 0"
|
||||
label: "Shutdown"
|
||||
description: "Shutdown the PC immediately"
|
||||
icon: "mdi:power"
|
||||
timeout: 10
|
||||
shell: true
|
||||
|
||||
@@ -47,9 +52,15 @@ scripts:
|
||||
command: "shutdown /r /t 0"
|
||||
label: "Restart"
|
||||
description: "Restart the PC immediately"
|
||||
icon: "mdi:restart"
|
||||
timeout: 10
|
||||
shell: true
|
||||
|
||||
# Media folder management from Web UI (default: true)
|
||||
# When enabled, media folders can be added, edited, and deleted from the Settings tab.
|
||||
# Set to false to disable folder management from the UI.
|
||||
# media_folders_management: false
|
||||
|
||||
# Callback scripts (executed after media actions)
|
||||
# All callbacks are optional - if not defined, the action runs without callback
|
||||
callbacks:
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import * as esbuild from 'esbuild';
|
||||
|
||||
const srcDir = 'media_server/static';
|
||||
const outDir = `${srcDir}/dist`;
|
||||
|
||||
const watch = process.argv.includes('--watch');
|
||||
|
||||
/** @type {esbuild.BuildOptions} */
|
||||
const jsOpts = {
|
||||
entryPoints: [`${srcDir}/js/app.js`],
|
||||
bundle: true,
|
||||
format: 'iife',
|
||||
outfile: `${outDir}/app.bundle.js`,
|
||||
minify: true,
|
||||
sourcemap: true,
|
||||
target: ['es2020'],
|
||||
logLevel: 'info',
|
||||
};
|
||||
|
||||
if (watch) {
|
||||
const jsCtx = await esbuild.context(jsOpts);
|
||||
await jsCtx.watch();
|
||||
console.log('Watching for changes...');
|
||||
} else {
|
||||
await esbuild.build(jsOpts);
|
||||
}
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
; Media Server NSIS Installer
|
||||
; Cross-compilable: apt install nsis && makensis -DVERSION="1.0.0" installer.nsi
|
||||
|
||||
!include "MUI2.nsh"
|
||||
!include "FileFunc.nsh"
|
||||
|
||||
; --- Configuration ---
|
||||
!define APPNAME "Media Server"
|
||||
!define EXENAME "media-server.bat"
|
||||
!define VBSNAME "start-hidden.vbs"
|
||||
!ifndef VERSION
|
||||
!define VERSION "0.0.0"
|
||||
!endif
|
||||
|
||||
Name "${APPNAME} ${VERSION}"
|
||||
OutFile "build\MediaServer-v${VERSION}-setup.exe"
|
||||
InstallDir "$LOCALAPPDATA\${APPNAME}"
|
||||
RequestExecutionLevel user
|
||||
|
||||
; --- UI ---
|
||||
!define MUI_ICON "media_server\static\icons\icon.ico"
|
||||
!define MUI_UNICON "media_server\static\icons\icon.ico"
|
||||
!define MUI_ABORTWARNING
|
||||
!define MUI_FINISHPAGE_RUN ""
|
||||
!define MUI_FINISHPAGE_RUN_TEXT "Launch ${APPNAME}"
|
||||
!define MUI_FINISHPAGE_RUN_FUNCTION LaunchApp
|
||||
|
||||
!insertmacro MUI_PAGE_WELCOME
|
||||
!insertmacro MUI_PAGE_DIRECTORY
|
||||
!insertmacro MUI_PAGE_COMPONENTS
|
||||
!insertmacro MUI_PAGE_INSTFILES
|
||||
!insertmacro MUI_PAGE_FINISH
|
||||
|
||||
!insertmacro MUI_UNPAGE_CONFIRM
|
||||
!insertmacro MUI_UNPAGE_INSTFILES
|
||||
|
||||
!insertmacro MUI_LANGUAGE "English"
|
||||
|
||||
; --- Functions ---
|
||||
Function LaunchApp
|
||||
ExecShell "open" "wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"'
|
||||
; Give the server a moment to start, then open the UI in the default browser
|
||||
Sleep 2000
|
||||
ExecShell "open" "http://localhost:8765/"
|
||||
FunctionEnd
|
||||
|
||||
Function .onInit
|
||||
; Check if server is running by trying to open its Python executable exclusively
|
||||
IfFileExists "$INSTDIR\python\python.exe" 0 done
|
||||
ClearErrors
|
||||
FileOpen $0 "$INSTDIR\python\python.exe" a
|
||||
IfErrors locked
|
||||
; File opened fine — server is not running
|
||||
FileClose $0
|
||||
Goto done
|
||||
locked:
|
||||
MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION \
|
||||
"${APPNAME} is currently running.$\n$\nYes = Stop the server and continue$\nNo = Continue without stopping (may cause errors)$\nCancel = Abort installation" \
|
||||
IDYES kill IDNO done
|
||||
Abort
|
||||
kill:
|
||||
nsExec::ExecToLog 'wmic process where "ExecutablePath like $\'%Media Server%python%$\'" call terminate'
|
||||
Sleep 2000
|
||||
done:
|
||||
FunctionEnd
|
||||
|
||||
; --- Sections ---
|
||||
Section "!Core (required)" SecCore
|
||||
SectionIn RO
|
||||
|
||||
SetOutPath "$INSTDIR"
|
||||
|
||||
; Wipe previous payload before extracting so stale files from an older
|
||||
; version cannot survive an upgrade. Without this, in-place upgrades
|
||||
; produce a half-old/half-new site-packages — e.g. an old PIL/_version.py
|
||||
; alongside a new PIL/_imaging.pyd, which raises "_imaging extension was
|
||||
; built for another version of Pillow" at runtime. config.yaml lives at
|
||||
; $INSTDIR root and is preserved.
|
||||
RMDir /r "$INSTDIR\python"
|
||||
RMDir /r "$INSTDIR\app"
|
||||
RMDir /r "$INSTDIR\scripts"
|
||||
Delete "$INSTDIR\${EXENAME}"
|
||||
Delete "$INSTDIR\VERSION"
|
||||
Delete "$INSTDIR\config.example.yaml"
|
||||
|
||||
; Copy entire distribution
|
||||
File /r "dist\media-server\*.*"
|
||||
|
||||
; Create config.yaml from example if it doesn't already exist (preserve user config on upgrade)
|
||||
IfFileExists "$INSTDIR\config.yaml" +2
|
||||
CopyFiles /SILENT "$INSTDIR\config.example.yaml" "$INSTDIR\config.yaml"
|
||||
|
||||
; Create uninstaller
|
||||
WriteUninstaller "$INSTDIR\uninstall.exe"
|
||||
|
||||
; Start Menu shortcuts
|
||||
CreateDirectory "$SMPROGRAMS\${APPNAME}"
|
||||
CreateShortcut "$SMPROGRAMS\${APPNAME}\${APPNAME}.lnk" \
|
||||
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
|
||||
"$INSTDIR\app\media_server\static\icons\icon.ico" 0
|
||||
CreateShortcut "$SMPROGRAMS\${APPNAME}\${APPNAME} (Console).lnk" \
|
||||
"$INSTDIR\${EXENAME}" "" \
|
||||
"$INSTDIR\app\media_server\static\icons\icon.ico" 0
|
||||
CreateShortcut "$SMPROGRAMS\${APPNAME}\Uninstall.lnk" \
|
||||
"$INSTDIR\uninstall.exe"
|
||||
|
||||
; Registry for Add/Remove Programs
|
||||
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
|
||||
"DisplayName" "${APPNAME}"
|
||||
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
|
||||
"DisplayVersion" "${VERSION}"
|
||||
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
|
||||
"UninstallString" "$\"$INSTDIR\uninstall.exe$\""
|
||||
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
|
||||
"InstallLocation" "$INSTDIR"
|
||||
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
|
||||
"Publisher" "Alexei Dolgolyov"
|
||||
WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
|
||||
"NoModify" 1
|
||||
WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
|
||||
"NoRepair" 1
|
||||
|
||||
; Calculate installed size
|
||||
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
|
||||
IntFmt $0 "0x%08X" $0
|
||||
WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
|
||||
"EstimatedSize" "$0"
|
||||
SectionEnd
|
||||
|
||||
Section "Desktop shortcut" SecDesktop
|
||||
CreateShortcut "$DESKTOP\${APPNAME}.lnk" \
|
||||
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
|
||||
"$INSTDIR\app\media_server\static\icons\icon.ico" 0
|
||||
SectionEnd
|
||||
|
||||
Section "Start with Windows" SecAutostart
|
||||
; Create Startup folder shortcut (runs hidden via VBS)
|
||||
CreateShortcut "$SMSTARTUP\${APPNAME}.lnk" \
|
||||
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
|
||||
"$INSTDIR\app\media_server\static\icons\icon.ico" 0
|
||||
SectionEnd
|
||||
|
||||
; --- Section descriptions ---
|
||||
!insertmacro MUI_FUNCTION_DESCRIPTION_BEGIN
|
||||
!insertmacro MUI_DESCRIPTION_TEXT ${SecCore} \
|
||||
"Core application files, embedded Python, and Start Menu shortcuts."
|
||||
!insertmacro MUI_DESCRIPTION_TEXT ${SecDesktop} \
|
||||
"Create a desktop shortcut to launch ${APPNAME}."
|
||||
!insertmacro MUI_DESCRIPTION_TEXT ${SecAutostart} \
|
||||
"Automatically start ${APPNAME} when you log in to Windows."
|
||||
!insertmacro MUI_FUNCTION_DESCRIPTION_END
|
||||
|
||||
; --- Uninstaller ---
|
||||
Section "Uninstall"
|
||||
; Stop running instance
|
||||
nsExec::ExecToLog 'wmic process where "ExecutablePath like $\'%Media Server%python%$\'" call terminate'
|
||||
nsExec::ExecToLog 'taskkill /F /IM media-server.exe'
|
||||
|
||||
; Remove application files
|
||||
RMDir /r "$INSTDIR\python"
|
||||
RMDir /r "$INSTDIR\app"
|
||||
RMDir /r "$INSTDIR\scripts"
|
||||
Delete "$INSTDIR\${EXENAME}"
|
||||
Delete "$INSTDIR\VERSION"
|
||||
Delete "$INSTDIR\uninstall.exe"
|
||||
|
||||
; Preserve config.yaml (user data) — only remove the example
|
||||
Delete "$INSTDIR\config.example.yaml"
|
||||
|
||||
; Remove shortcuts
|
||||
Delete "$DESKTOP\${APPNAME}.lnk"
|
||||
Delete "$SMSTARTUP\${APPNAME}.lnk"
|
||||
RMDir /r "$SMPROGRAMS\${APPNAME}"
|
||||
|
||||
; Remove registry
|
||||
DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}"
|
||||
|
||||
; Remove install dir only if empty (config.yaml may remain)
|
||||
RMDir "$INSTDIR"
|
||||
SectionEnd
|
||||
@@ -1,3 +1,23 @@
|
||||
"""Media Server - REST API for controlling system media playback."""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _detect_version() -> str:
|
||||
# 1. Package metadata (works when pip-installed in dev)
|
||||
try:
|
||||
return version("media-server")
|
||||
except PackageNotFoundError:
|
||||
pass
|
||||
|
||||
# 2. VERSION file written by build scripts (production builds)
|
||||
# Located at install root, two levels up from this package
|
||||
version_file = Path(__file__).resolve().parent.parent.parent / "VERSION"
|
||||
if version_file.is_file():
|
||||
return version_file.read_text().strip()
|
||||
|
||||
return "0.0.0-dev"
|
||||
|
||||
|
||||
__version__ = _detect_version()
|
||||
|
||||
+20
-2
@@ -15,6 +15,11 @@ security = HTTPBearer(auto_error=False)
|
||||
token_label_var: ContextVar[str] = ContextVar("token_label", default="unknown")
|
||||
|
||||
|
||||
def auth_enabled() -> bool:
|
||||
"""Check if authentication is enabled (i.e. at least one token is configured)."""
|
||||
return bool(settings.api_tokens)
|
||||
|
||||
|
||||
def get_token_label(token: str) -> Optional[str]:
|
||||
"""Get the label for a token. Returns None if token is invalid.
|
||||
|
||||
@@ -36,14 +41,19 @@ async def verify_token(
|
||||
) -> str:
|
||||
"""Verify the API token from the Authorization header.
|
||||
|
||||
When no tokens are configured, authentication is skipped entirely.
|
||||
Reuses the label from middleware context when already validated.
|
||||
|
||||
Returns:
|
||||
The token label
|
||||
The token label (or "anonymous" when auth is disabled)
|
||||
|
||||
Raises:
|
||||
HTTPException: If the token is missing or invalid
|
||||
HTTPException: If the token is missing or invalid (only when auth enabled)
|
||||
"""
|
||||
if not auth_enabled():
|
||||
token_label_var.set("anonymous")
|
||||
return "anonymous"
|
||||
|
||||
# Reuse label already set by middleware to avoid redundant O(n) scan
|
||||
existing = token_label_var.get("unknown")
|
||||
if existing != "unknown":
|
||||
@@ -80,6 +90,10 @@ class TokenAuth:
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
) -> str | None:
|
||||
"""Verify the token and return the label or raise an exception."""
|
||||
if not auth_enabled():
|
||||
token_label_var.set("anonymous")
|
||||
return "anonymous"
|
||||
|
||||
if credentials is None:
|
||||
if self.auto_error:
|
||||
raise HTTPException(
|
||||
@@ -122,6 +136,10 @@ async def verify_token_or_query(
|
||||
Raises:
|
||||
HTTPException: If the token is missing or invalid
|
||||
"""
|
||||
if not auth_enabled():
|
||||
token_label_var.set("anonymous")
|
||||
return "anonymous"
|
||||
|
||||
# Reuse label already set by middleware
|
||||
existing = token_label_var.get("unknown")
|
||||
if existing != "unknown":
|
||||
|
||||
+71
-8
@@ -1,7 +1,6 @@
|
||||
"""Configuration management for the media server."""
|
||||
|
||||
import os
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
@@ -27,6 +26,26 @@ class CallbackConfig(BaseModel):
|
||||
shell: bool = Field(default=True, description="Run command in shell")
|
||||
|
||||
|
||||
class ScriptParameterConfig(BaseModel):
|
||||
"""Configuration for a script parameter."""
|
||||
|
||||
type: str = Field(
|
||||
...,
|
||||
description="Parameter type: string, integer, float, boolean, select",
|
||||
pattern=r"^(string|integer|float|boolean|select)$",
|
||||
)
|
||||
description: str = Field(default="", description="Parameter description")
|
||||
required: bool = Field(default=False, description="Whether the parameter is required")
|
||||
default: Optional[str | int | float | bool] = Field(
|
||||
default=None, description="Default value if not provided"
|
||||
)
|
||||
min: Optional[float] = Field(default=None, description="Minimum value (numeric types only)")
|
||||
max: Optional[float] = Field(default=None, description="Maximum value (numeric types only)")
|
||||
options: Optional[list[str]] = Field(
|
||||
default=None, description="Allowed values (select type only)"
|
||||
)
|
||||
|
||||
|
||||
class ScriptConfig(BaseModel):
|
||||
"""Configuration for a custom script."""
|
||||
|
||||
@@ -37,6 +56,9 @@ class ScriptConfig(BaseModel):
|
||||
timeout: int = Field(default=30, description="Execution timeout in seconds", ge=1, le=300)
|
||||
working_dir: Optional[str] = Field(default=None, description="Working directory")
|
||||
shell: bool = Field(default=True, description="Run command in shell")
|
||||
parameters: dict[str, ScriptParameterConfig] = Field(
|
||||
default_factory=dict, description="Named parameters with type and validation rules"
|
||||
)
|
||||
|
||||
|
||||
class LinkConfig(BaseModel):
|
||||
@@ -45,6 +67,7 @@ class LinkConfig(BaseModel):
|
||||
url: str = Field(..., description="URL to open")
|
||||
icon: str = Field(default="mdi:link", description="MDI icon name (e.g., 'mdi:led-strip-variant')")
|
||||
label: str = Field(default="", description="Tooltip text")
|
||||
description: str = Field(default="", description="Optional description")
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
@@ -61,10 +84,10 @@ class Settings(BaseSettings):
|
||||
host: str = Field(default="0.0.0.0", description="Server bind address")
|
||||
port: int = Field(default=8765, description="Server port")
|
||||
|
||||
# Authentication
|
||||
# Authentication (empty = auth disabled, anyone can access the API)
|
||||
api_tokens: dict[str, str] = Field(
|
||||
default_factory=lambda: {"default": secrets.token_urlsafe(32)},
|
||||
description="Named API tokens for access control (label: token pairs)",
|
||||
default_factory=dict,
|
||||
description="Named API tokens for access control (label: token pairs). Empty = no auth.",
|
||||
)
|
||||
|
||||
# Media controller settings
|
||||
@@ -75,7 +98,10 @@ class Settings(BaseSettings):
|
||||
# Audio device settings
|
||||
audio_device: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Audio device name to control (None = default device). Use /api/audio/devices to list available devices.",
|
||||
description=(
|
||||
"Audio device name to control (None = default device)."
|
||||
" Use /api/audio/devices to list available devices."
|
||||
),
|
||||
)
|
||||
|
||||
# Logging
|
||||
@@ -98,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(
|
||||
@@ -111,6 +141,39 @@ class Settings(BaseSettings):
|
||||
description="Quick links displayed as icons in the header",
|
||||
)
|
||||
|
||||
# Audio visualizer
|
||||
visualizer_enabled: bool = Field(
|
||||
default=True,
|
||||
description="Enable audio spectrum visualizer (requires soundcard + numpy)",
|
||||
)
|
||||
visualizer_fps: int = Field(
|
||||
default=30,
|
||||
description="Visualizer update rate in frames per second",
|
||||
ge=10,
|
||||
le=60,
|
||||
)
|
||||
visualizer_bins: int = Field(
|
||||
default=32,
|
||||
description="Number of frequency bins for the visualizer",
|
||||
ge=8,
|
||||
le=128,
|
||||
)
|
||||
visualizer_device: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Loopback audio device name for visualizer (None = auto-detect)",
|
||||
)
|
||||
|
||||
# Update checker
|
||||
update_check_enabled: bool = Field(
|
||||
default=True,
|
||||
description="Check for new versions on startup and periodically",
|
||||
)
|
||||
update_check_interval: int = Field(
|
||||
default=21600,
|
||||
description="Update check interval in seconds (default: 6 hours)",
|
||||
ge=600,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def load_from_yaml(cls, path: Optional[Path] = None) -> "Settings":
|
||||
"""Load settings from a YAML configuration file."""
|
||||
@@ -162,9 +225,9 @@ def generate_default_config(path: Optional[Path] = None) -> Path:
|
||||
config = {
|
||||
"host": "0.0.0.0",
|
||||
"port": 8765,
|
||||
"api_tokens": {
|
||||
"default": secrets.token_urlsafe(32),
|
||||
},
|
||||
# "api_tokens": {
|
||||
# "default": "your-secret-token-here",
|
||||
# },
|
||||
"poll_interval": 1.0,
|
||||
"log_level": "INFO",
|
||||
# Audio device to control (use GET /api/audio/devices to list available devices)
|
||||
|
||||
@@ -451,6 +451,34 @@ class ConfigManager:
|
||||
del settings.links[name]
|
||||
logger.info(f"Link '{name}' deleted from config")
|
||||
|
||||
def set_setting(self, key: str, value) -> None:
|
||||
"""Set a top-level config setting and persist to YAML.
|
||||
|
||||
Args:
|
||||
key: Setting name (e.g., "visualizer_device").
|
||||
value: Setting value (None removes the key).
|
||||
"""
|
||||
with self._lock:
|
||||
if not self._config_path.exists():
|
||||
data = {}
|
||||
else:
|
||||
with open(self._config_path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
|
||||
if value is None:
|
||||
data.pop(key, None)
|
||||
else:
|
||||
data[key] = value
|
||||
|
||||
self._config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self._config_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||
|
||||
# Update in-memory settings
|
||||
if hasattr(settings, key):
|
||||
setattr(settings, key, value)
|
||||
logger.info("Setting '%s' updated to: %s", key, value)
|
||||
|
||||
|
||||
# Global config manager instance
|
||||
config_manager = ConfigManager()
|
||||
|
||||
+175
-31
@@ -2,6 +2,7 @@
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import socket
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
@@ -15,8 +16,17 @@ from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from . import __version__
|
||||
from .auth import get_token_label, token_label_var
|
||||
from .config import settings, generate_default_config, get_config_dir
|
||||
from .routes import audio_router, browser_router, callbacks_router, display_router, health_router, links_router, media_router, scripts_router
|
||||
from .config import generate_default_config, get_config_dir, settings
|
||||
from .routes import (
|
||||
audio_router,
|
||||
browser_router,
|
||||
callbacks_router,
|
||||
display_router,
|
||||
health_router,
|
||||
links_router,
|
||||
media_router,
|
||||
scripts_router,
|
||||
)
|
||||
from .services import get_media_controller
|
||||
from .services.websocket_manager import ws_manager
|
||||
|
||||
@@ -42,6 +52,9 @@ def setup_logging():
|
||||
handlers=[handler],
|
||||
)
|
||||
|
||||
# Suppress noisy third-party loggers
|
||||
logging.getLogger("screen_brightness_control").setLevel(logging.ERROR)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
@@ -50,19 +63,66 @@ async def lifespan(app: FastAPI):
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"Media Server starting on {settings.host}:{settings.port}")
|
||||
|
||||
# Log all configured tokens
|
||||
for label, token in settings.api_tokens.items():
|
||||
logger.info(f"API Token [{label}]: {token[:8]}...")
|
||||
# Log authentication status
|
||||
if settings.api_tokens:
|
||||
for label, token in settings.api_tokens.items():
|
||||
logger.info(f"API Token [{label}]: {token[:8]}...")
|
||||
else:
|
||||
logger.warning("No API tokens configured — authentication is DISABLED")
|
||||
|
||||
# Start WebSocket status monitor
|
||||
controller = get_media_controller()
|
||||
await ws_manager.start_status_monitor(controller.get_status)
|
||||
logger.info("WebSocket status monitor started")
|
||||
|
||||
# Start update checker
|
||||
update_checker = None
|
||||
if settings.update_check_enabled:
|
||||
from .services.gitea_release_provider import GiteaReleaseProvider
|
||||
from .services.update_checker import UpdateChecker
|
||||
|
||||
provider = GiteaReleaseProvider()
|
||||
update_checker = UpdateChecker(provider, __version__)
|
||||
await update_checker.start(settings.update_check_interval)
|
||||
# Store globally so health endpoint can access cached result
|
||||
app.state.update_checker = update_checker
|
||||
|
||||
# Register audio visualizer (capture starts on-demand when clients subscribe)
|
||||
analyzer = None
|
||||
if settings.visualizer_enabled:
|
||||
from .services.audio_analyzer import get_audio_analyzer
|
||||
|
||||
analyzer = get_audio_analyzer(
|
||||
num_bins=settings.visualizer_bins,
|
||||
target_fps=settings.visualizer_fps,
|
||||
device_name=settings.visualizer_device,
|
||||
)
|
||||
if analyzer.available:
|
||||
await ws_manager.start_audio_monitor(analyzer)
|
||||
logger.info("Audio visualizer available (capture on-demand)")
|
||||
else:
|
||||
logger.info("Audio visualizer unavailable (install soundcard + numpy)")
|
||||
|
||||
yield
|
||||
|
||||
# Stop update checker
|
||||
if update_checker is not None:
|
||||
await update_checker.stop()
|
||||
|
||||
# Stop audio visualizer
|
||||
await ws_manager.stop_audio_monitor()
|
||||
if analyzer and analyzer.running:
|
||||
analyzer.stop()
|
||||
|
||||
# Stop WebSocket status monitor
|
||||
await ws_manager.stop_status_monitor()
|
||||
|
||||
# Clean up platform-specific resources
|
||||
import platform as _platform
|
||||
if _platform.system() == "Windows":
|
||||
from .services.windows_media import shutdown_executor
|
||||
shutdown_executor()
|
||||
|
||||
logger.info("Media Server shutting down")
|
||||
|
||||
|
||||
@@ -79,10 +139,11 @@ def create_app() -> FastAPI:
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
||||
|
||||
# Add CORS middleware for cross-origin requests
|
||||
# Token auth is via Authorization header, not cookies, so credentials are not needed
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_credentials=False,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
@@ -91,24 +152,28 @@ def create_app() -> FastAPI:
|
||||
@app.middleware("http")
|
||||
async def token_logging_middleware(request: Request, call_next):
|
||||
"""Extract token label and set in context for logging."""
|
||||
token_label = "unknown"
|
||||
if not settings.api_tokens:
|
||||
token_label_var.set("anonymous")
|
||||
else:
|
||||
token_label = "unknown"
|
||||
|
||||
# Try Authorization header
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
if auth_header.startswith("Bearer "):
|
||||
token = auth_header[7:]
|
||||
label = get_token_label(token)
|
||||
if label:
|
||||
token_label = label
|
||||
# Try Authorization header
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
if auth_header.startswith("Bearer "):
|
||||
token = auth_header[7:]
|
||||
label = get_token_label(token)
|
||||
if label:
|
||||
token_label = label
|
||||
|
||||
# Try query parameter (for artwork endpoint)
|
||||
elif "token" in request.query_params:
|
||||
token = request.query_params["token"]
|
||||
label = get_token_label(token)
|
||||
if label:
|
||||
token_label = label
|
||||
# Try query parameter (for artwork endpoint)
|
||||
elif "token" in request.query_params:
|
||||
token = request.query_params["token"]
|
||||
label = get_token_label(token)
|
||||
if label:
|
||||
token_label = label
|
||||
|
||||
token_label_var.set(token_label)
|
||||
|
||||
token_label_var.set(token_label)
|
||||
response = await call_next(request)
|
||||
return response
|
||||
|
||||
@@ -125,6 +190,15 @@ def create_app() -> FastAPI:
|
||||
# Mount static files and serve UI at root
|
||||
static_dir = Path(__file__).parent / "static"
|
||||
if static_dir.exists():
|
||||
@app.get("/sw.js", include_in_schema=False)
|
||||
async def serve_service_worker():
|
||||
"""Serve service worker from root scope for PWA installability."""
|
||||
return FileResponse(
|
||||
static_dir / "sw.js",
|
||||
media_type="application/javascript",
|
||||
headers={"Cache-Control": "no-cache"},
|
||||
)
|
||||
|
||||
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
@@ -162,28 +236,98 @@ def main():
|
||||
action="store_true",
|
||||
help="Show the current API token and exit",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-tray",
|
||||
action="store_true",
|
||||
help="Disable system tray icon (for headless/service mode)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.generate_config:
|
||||
config_path = generate_default_config()
|
||||
print(f"Configuration file generated at: {config_path}")
|
||||
print(f"API Token has been saved to the config file.")
|
||||
print("Authentication is disabled by default. Add api_tokens to enable it.")
|
||||
return
|
||||
|
||||
if args.show_token:
|
||||
print(f"Config directory: {get_config_dir()}")
|
||||
print(f"\nAPI Tokens:")
|
||||
for label, token in settings.api_tokens.items():
|
||||
print(f" {label:20} {token}")
|
||||
if settings.api_tokens:
|
||||
print("\nAPI Tokens:")
|
||||
for label, token in settings.api_tokens.items():
|
||||
print(f" {label:20} {token}")
|
||||
else:
|
||||
print("\nAuthentication is DISABLED (no tokens configured)")
|
||||
return
|
||||
|
||||
uvicorn.run(
|
||||
"media_server.main:app",
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
reload=False,
|
||||
)
|
||||
# Check if port is available before starting
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
try:
|
||||
sock.bind((args.host if args.host != "0.0.0.0" else "127.0.0.1", args.port))
|
||||
except OSError:
|
||||
print(
|
||||
f"ERROR: Port {args.port} is already in use. "
|
||||
f"Another instance of Media Server may be running.\n"
|
||||
f"Stop the other process or use --port to pick a different port.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
from .tray import PYSTRAY_AVAILABLE, TrayManager
|
||||
|
||||
use_tray = PYSTRAY_AVAILABLE and not args.no_tray
|
||||
|
||||
if use_tray:
|
||||
import asyncio
|
||||
import threading
|
||||
|
||||
# Run uvicorn in a background thread so tray owns the main thread message loop
|
||||
uv_config = uvicorn.Config(
|
||||
"media_server.main:app",
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
log_level=settings.log_level.lower(),
|
||||
)
|
||||
server = uvicorn.Server(uv_config)
|
||||
|
||||
def run_server():
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
loop.run_until_complete(server.serve())
|
||||
|
||||
server_thread = threading.Thread(target=run_server, daemon=True)
|
||||
server_thread.start()
|
||||
|
||||
# Tray on main thread (blocking)
|
||||
tray = TrayManager(
|
||||
port=args.port,
|
||||
on_exit=lambda: setattr(server, "should_exit", True),
|
||||
)
|
||||
tray.run()
|
||||
|
||||
# Tray exited — wait for server to finish graceful shutdown
|
||||
server_thread.join(timeout=10)
|
||||
|
||||
if tray.restart_requested:
|
||||
import subprocess
|
||||
|
||||
# Always restart via `python -m media_server.main` — this works
|
||||
# regardless of how we were originally started (console_script,
|
||||
# python -m, or direct script invocation).
|
||||
cmd = [sys.executable, "-m", "media_server.main"]
|
||||
|
||||
subprocess.Popen(
|
||||
cmd,
|
||||
cwd=Path.cwd(),
|
||||
start_new_session=True,
|
||||
)
|
||||
else:
|
||||
uvicorn.run(
|
||||
"media_server.main:app",
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
reload=False,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""Pydantic models for the media server API."""
|
||||
|
||||
from .media import (
|
||||
MediaInfo,
|
||||
MediaState,
|
||||
MediaStatus,
|
||||
VolumeRequest,
|
||||
SeekRequest,
|
||||
MediaInfo,
|
||||
VolumeRequest,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
|
||||
@@ -9,4 +9,13 @@ from .links import router as links_router
|
||||
from .media import router as media_router
|
||||
from .scripts import router as scripts_router
|
||||
|
||||
__all__ = ["audio_router", "browser_router", "callbacks_router", "display_router", "health_router", "links_router", "media_router", "scripts_router"]
|
||||
__all__ = [
|
||||
"audio_router",
|
||||
"browser_router",
|
||||
"callbacks_router",
|
||||
"display_router",
|
||||
"health_router",
|
||||
"links_router",
|
||||
"media_router",
|
||||
"scripts_router",
|
||||
]
|
||||
|
||||
@@ -4,20 +4,19 @@ import asyncio
|
||||
import logging
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.parse import unquote
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..auth import verify_token, verify_token_or_query
|
||||
from ..config import MediaFolderConfig, settings
|
||||
from ..config_manager import config_manager
|
||||
from ..services import get_media_controller
|
||||
from ..services.browser_service import BrowserService
|
||||
from ..services.metadata_service import MetadataService
|
||||
from ..services.thumbnail_service import ThumbnailService
|
||||
from ..services import get_media_controller
|
||||
from ..services.websocket_manager import ws_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -25,6 +24,15 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/browser", tags=["browser"])
|
||||
|
||||
|
||||
def _require_folder_management() -> None:
|
||||
"""Raise 403 if media folder management is disabled in config."""
|
||||
if not settings.media_folders_management:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Media folder management is disabled. Set media_folders_management: true in config.yaml to enable.",
|
||||
)
|
||||
|
||||
|
||||
async def _broadcast_after_open(controller, label: str, max_wait: float = 2.0) -> None:
|
||||
"""Poll until media session registers, then broadcast status update.
|
||||
|
||||
@@ -84,17 +92,22 @@ async def list_folders(_: str = Depends(verify_token)):
|
||||
"""List all configured media folders.
|
||||
|
||||
Returns:
|
||||
Dictionary of folder configurations.
|
||||
Dictionary with folder configurations and management flag.
|
||||
"""
|
||||
folders = {}
|
||||
for folder_id, config in settings.media_folders.items():
|
||||
folder_path = Path(config.path)
|
||||
folders[folder_id] = {
|
||||
"id": folder_id,
|
||||
"label": config.label,
|
||||
"path": config.path,
|
||||
"enabled": config.enabled,
|
||||
"available": folder_path.is_dir(),
|
||||
}
|
||||
return folders
|
||||
return {
|
||||
"folders": folders,
|
||||
"management_enabled": settings.media_folders_management,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/folders/create")
|
||||
@@ -113,6 +126,7 @@ async def create_folder(
|
||||
Raises:
|
||||
HTTPException: If folder already exists or validation fails.
|
||||
"""
|
||||
_require_folder_management()
|
||||
try:
|
||||
# Validate folder_id format (alphanumeric and underscore only)
|
||||
if not request.folder_id.replace("_", "").isalnum():
|
||||
@@ -170,6 +184,7 @@ async def update_folder(
|
||||
Raises:
|
||||
HTTPException: If folder doesn't exist or validation fails.
|
||||
"""
|
||||
_require_folder_management()
|
||||
try:
|
||||
# Validate path exists
|
||||
path = Path(request.path)
|
||||
@@ -218,6 +233,7 @@ async def delete_folder(
|
||||
Raises:
|
||||
HTTPException: If folder doesn't exist.
|
||||
"""
|
||||
_require_folder_management()
|
||||
try:
|
||||
config_manager.delete_media_folder(folder_id)
|
||||
|
||||
@@ -281,7 +297,7 @@ async def browse(
|
||||
logger.warning(f"Folder temporarily unavailable: {e}")
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"Folder is temporarily unavailable. It may be a network share that is not accessible at the moment."
|
||||
detail="Folder is temporarily unavailable. It may be a network share that is not accessible at the moment."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error browsing directory (type: {type(e).__name__}): {e}")
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
@@ -17,6 +17,9 @@ from ..config_manager import config_manager
|
||||
router = APIRouter(prefix="/api/callbacks", tags=["callbacks"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Dedicated executor for callback/subprocess execution
|
||||
_callback_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="callback")
|
||||
|
||||
|
||||
class CallbackInfo(BaseModel):
|
||||
"""Information about a configured callback."""
|
||||
@@ -127,10 +130,10 @@ async def execute_callback(
|
||||
logger.info(f"Executing callback for debugging: {callback_name}")
|
||||
|
||||
try:
|
||||
# Execute in thread pool to not block
|
||||
# Execute in dedicated thread pool to not block the default executor
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
_callback_executor,
|
||||
lambda: _run_callback(
|
||||
command=callback_config.command,
|
||||
timeout=callback_config.timeout,
|
||||
@@ -234,7 +237,10 @@ async def create_callback(
|
||||
if callback_name in settings.callbacks:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Callback '{callback_name}' already exists. Use PUT /api/callbacks/update/{callback_name} to update it.",
|
||||
detail=(
|
||||
f"Callback '{callback_name}' already exists."
|
||||
f" Use PUT /api/callbacks/update/{callback_name} to update it."
|
||||
),
|
||||
)
|
||||
|
||||
# Create callback config
|
||||
@@ -279,7 +285,10 @@ async def update_callback(
|
||||
if callback_name not in settings.callbacks:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Callback '{callback_name}' not found. Use POST /api/callbacks/create/{callback_name} to create it.",
|
||||
detail=(
|
||||
f"Callback '{callback_name}' not found."
|
||||
f" Use POST /api/callbacks/create/{callback_name} to create it."
|
||||
),
|
||||
)
|
||||
|
||||
# Create updated callback config
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Display brightness and power control API endpoints."""
|
||||
"""Display brightness, power, contrast, input-source, color-preset and picture-mode API."""
|
||||
|
||||
import logging
|
||||
|
||||
@@ -7,9 +7,12 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from ..auth import verify_token
|
||||
from ..services.display_service import (
|
||||
get_brightness,
|
||||
list_monitors,
|
||||
set_brightness,
|
||||
set_color_preset,
|
||||
set_contrast,
|
||||
set_input_source,
|
||||
set_picture_mode,
|
||||
set_power,
|
||||
)
|
||||
|
||||
@@ -26,12 +29,35 @@ class PowerRequest(BaseModel):
|
||||
on: bool
|
||||
|
||||
|
||||
class ContrastRequest(BaseModel):
|
||||
contrast: int = Field(ge=0, le=100)
|
||||
|
||||
|
||||
class InputSourceRequest(BaseModel):
|
||||
source: str
|
||||
|
||||
|
||||
class ColorPresetRequest(BaseModel):
|
||||
preset: str
|
||||
|
||||
|
||||
class PictureModeRequest(BaseModel):
|
||||
code: int = Field(ge=0, le=255)
|
||||
|
||||
|
||||
@router.get("/monitors")
|
||||
async def get_monitors(
|
||||
refresh: bool = False, _: str = Depends(verify_token)
|
||||
refresh: bool = False,
|
||||
rediscover: bool = False,
|
||||
_: str = Depends(verify_token),
|
||||
) -> list[dict]:
|
||||
"""List all connected monitors with brightness and power info."""
|
||||
monitors = list_monitors(force_refresh=refresh)
|
||||
"""List all connected monitors with their reported DDC/CI capabilities.
|
||||
|
||||
- `refresh=true` bypasses the response TTL cache (re-reads current state).
|
||||
- `rediscover=true` also drops the per-monitor capability cache, forcing
|
||||
a full DDC/CI capability probe. Use after a monitor hot-swap.
|
||||
"""
|
||||
monitors = list_monitors(force_refresh=refresh, rediscover=rediscover)
|
||||
logger.debug("Found %d monitors", len(monitors))
|
||||
return [m.to_dict() for m in monitors]
|
||||
|
||||
@@ -57,3 +83,47 @@ async def set_monitor_power(
|
||||
if success:
|
||||
logger.info("Set monitor %d power %s", monitor_id, action)
|
||||
return {"success": success}
|
||||
|
||||
|
||||
@router.post("/contrast/{monitor_id}")
|
||||
async def set_monitor_contrast(
|
||||
monitor_id: int, request: ContrastRequest, _: str = Depends(verify_token)
|
||||
) -> dict:
|
||||
"""Set DDC/CI contrast for a specific monitor."""
|
||||
success = set_contrast(monitor_id, request.contrast)
|
||||
if success:
|
||||
logger.info("Set monitor %d contrast to %d", monitor_id, request.contrast)
|
||||
return {"success": success}
|
||||
|
||||
|
||||
@router.post("/input_source/{monitor_id}")
|
||||
async def set_monitor_input_source(
|
||||
monitor_id: int, request: InputSourceRequest, _: str = Depends(verify_token)
|
||||
) -> dict:
|
||||
"""Switch a monitor's DDC/CI input source (e.g. HDMI1, DP1)."""
|
||||
success = set_input_source(monitor_id, request.source)
|
||||
if success:
|
||||
logger.info("Set monitor %d input source to %s", monitor_id, request.source)
|
||||
return {"success": success}
|
||||
|
||||
|
||||
@router.post("/color_preset/{monitor_id}")
|
||||
async def set_monitor_color_preset(
|
||||
monitor_id: int, request: ColorPresetRequest, _: str = Depends(verify_token)
|
||||
) -> dict:
|
||||
"""Apply a DDC/CI color preset (color temperature) to the monitor."""
|
||||
success = set_color_preset(monitor_id, request.preset)
|
||||
if success:
|
||||
logger.info("Set monitor %d color preset to %s", monitor_id, request.preset)
|
||||
return {"success": success}
|
||||
|
||||
|
||||
@router.post("/picture_mode/{monitor_id}")
|
||||
async def set_monitor_picture_mode(
|
||||
monitor_id: int, request: PictureModeRequest, _: str = Depends(verify_token)
|
||||
) -> dict:
|
||||
"""Apply a DDC/CI picture/scene mode (VCP 0xDC) by raw code."""
|
||||
success = set_picture_mode(monitor_id, request.code)
|
||||
if success:
|
||||
logger.info("Set monitor %d picture mode to code %d", monitor_id, request.code)
|
||||
return {"success": success}
|
||||
|
||||
@@ -3,20 +3,33 @@
|
||||
import platform
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
from .. import __version__
|
||||
from ..auth import auth_enabled
|
||||
from ..config import settings
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["health"])
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health_check() -> dict[str, Any]:
|
||||
async def health_check(request: Request) -> dict[str, Any]:
|
||||
"""Health check endpoint - no authentication required.
|
||||
|
||||
Returns:
|
||||
Health status and server information
|
||||
"""
|
||||
return {
|
||||
result: dict[str, Any] = {
|
||||
"status": "healthy",
|
||||
"platform": platform.system(),
|
||||
"version": "1.0.0",
|
||||
"version": __version__,
|
||||
"auth_required": auth_enabled(),
|
||||
"media_folders_management": settings.media_folders_management,
|
||||
}
|
||||
|
||||
# Include cached update info if available
|
||||
checker = getattr(request.app.state, "update_checker", None)
|
||||
if checker is not None and checker.cached_update is not None:
|
||||
result["update_available"] = checker.cached_update
|
||||
|
||||
return result
|
||||
|
||||
@@ -23,6 +23,7 @@ class LinkInfo(BaseModel):
|
||||
url: str
|
||||
icon: str
|
||||
label: str
|
||||
description: str
|
||||
|
||||
|
||||
class LinkCreateRequest(BaseModel):
|
||||
@@ -31,6 +32,7 @@ class LinkCreateRequest(BaseModel):
|
||||
url: str = Field(..., description="URL to open", min_length=1)
|
||||
icon: str = Field(default="mdi:link", description="MDI icon name (e.g., 'mdi:led-strip-variant')")
|
||||
label: str = Field(default="", description="Tooltip text")
|
||||
description: str = Field(default="", description="Optional description")
|
||||
|
||||
|
||||
def _validate_link_name(name: str) -> None:
|
||||
@@ -67,6 +69,7 @@ async def list_links(_: str = Depends(verify_token)) -> list[LinkInfo]:
|
||||
url=config.url,
|
||||
icon=config.icon,
|
||||
label=config.label,
|
||||
description=config.description,
|
||||
)
|
||||
for name, config in settings.links.items()
|
||||
]
|
||||
|
||||
@@ -3,14 +3,13 @@
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
|
||||
from fastapi import status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect, status
|
||||
from fastapi.responses import Response
|
||||
|
||||
from ..auth import verify_token, verify_token_or_query
|
||||
from ..config import settings
|
||||
from ..models import MediaStatus, VolumeRequest, SeekRequest
|
||||
from ..services import get_media_controller, get_current_album_art
|
||||
from ..models import MediaStatus, SeekRequest, VolumeRequest
|
||||
from ..services import get_current_album_art, get_media_controller
|
||||
from ..services.websocket_manager import ws_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -268,10 +267,63 @@ async def get_artwork(_: str = Depends(verify_token_or_query)) -> Response:
|
||||
return Response(content=art_bytes, media_type=content_type)
|
||||
|
||||
|
||||
@router.get("/visualizer/status")
|
||||
async def visualizer_status(_: str = Depends(verify_token)) -> dict:
|
||||
"""Check if audio visualizer is available and running."""
|
||||
from ..services.audio_analyzer import get_audio_analyzer
|
||||
|
||||
analyzer = get_audio_analyzer()
|
||||
return {
|
||||
"available": analyzer.available,
|
||||
"running": analyzer.running,
|
||||
"current_device": analyzer.current_device,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/visualizer/devices")
|
||||
async def visualizer_devices(_: str = Depends(verify_token)) -> list[dict[str, str]]:
|
||||
"""List available loopback audio devices for the visualizer."""
|
||||
from ..services.audio_analyzer import AudioAnalyzer
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, AudioAnalyzer.list_loopback_devices)
|
||||
|
||||
|
||||
@router.post("/visualizer/device")
|
||||
async def set_visualizer_device(
|
||||
request: dict,
|
||||
_: str = Depends(verify_token),
|
||||
) -> dict:
|
||||
"""Set the loopback audio device for the visualizer.
|
||||
|
||||
Body: {"device_name": "Device Name" | null}
|
||||
Passing null resets to auto-detect.
|
||||
"""
|
||||
from ..services.audio_analyzer import get_audio_analyzer
|
||||
|
||||
device_name = request.get("device_name")
|
||||
analyzer = get_audio_analyzer()
|
||||
|
||||
# set_device() handles stop/start internally if capture was running
|
||||
success = analyzer.set_device(device_name)
|
||||
|
||||
# Persist selection to config.yaml so it survives server restarts
|
||||
if success:
|
||||
from ..config_manager import config_manager
|
||||
|
||||
config_manager.set_setting("visualizer_device", device_name)
|
||||
|
||||
return {
|
||||
"success": success,
|
||||
"current_device": analyzer.current_device,
|
||||
"running": analyzer.running,
|
||||
}
|
||||
|
||||
|
||||
@router.websocket("/ws")
|
||||
async def websocket_endpoint(
|
||||
websocket: WebSocket,
|
||||
token: str = Query(..., description="API authentication token"),
|
||||
token: str | None = Query(None, description="API authentication token"),
|
||||
) -> None:
|
||||
"""WebSocket endpoint for real-time media status updates.
|
||||
|
||||
@@ -288,15 +340,16 @@ async def websocket_endpoint(
|
||||
- {"type": "get_status"} - Request current status
|
||||
"""
|
||||
# Verify token
|
||||
from ..auth import get_token_label, token_label_var
|
||||
from ..auth import auth_enabled, get_token_label, token_label_var
|
||||
|
||||
label = get_token_label(token) if token else None
|
||||
if label is None:
|
||||
await websocket.close(code=4001, reason="Invalid authentication token")
|
||||
return
|
||||
|
||||
# Set label in context for logging
|
||||
token_label_var.set(label)
|
||||
if auth_enabled():
|
||||
label = get_token_label(token) if token else None
|
||||
if label is None:
|
||||
await websocket.close(code=4001, reason="Invalid authentication token")
|
||||
return
|
||||
token_label_var.set(label)
|
||||
else:
|
||||
token_label_var.set("anonymous")
|
||||
|
||||
await ws_manager.connect(websocket)
|
||||
|
||||
@@ -321,6 +374,10 @@ async def websocket_endpoint(
|
||||
if volume is not None:
|
||||
controller = get_media_controller()
|
||||
await controller.set_volume(int(volume))
|
||||
elif data.get("type") == "enable_visualizer":
|
||||
await ws_manager.subscribe_visualizer(websocket)
|
||||
elif data.get("type") == "disable_visualizer":
|
||||
await ws_manager.unsubscribe_visualizer(websocket)
|
||||
|
||||
except WebSocketDisconnect:
|
||||
await ws_manager.disconnect(websocket)
|
||||
|
||||
+245
-18
@@ -5,24 +5,30 @@ import logging
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..auth import verify_token
|
||||
from ..config import ScriptConfig, settings
|
||||
from ..config import ScriptConfig, ScriptParameterConfig, settings
|
||||
from ..config_manager import config_manager
|
||||
from ..services.websocket_manager import ws_manager
|
||||
|
||||
router = APIRouter(prefix="/api/scripts", tags=["scripts"])
|
||||
|
||||
# Dedicated executor for script/subprocess execution (avoids blocking the default pool)
|
||||
_script_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="script")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ScriptExecuteRequest(BaseModel):
|
||||
"""Request model for script execution with optional arguments."""
|
||||
"""Request model for script execution with optional parameters."""
|
||||
|
||||
args: list[str] = Field(default_factory=list, description="Additional arguments")
|
||||
params: dict[str, str | int | float | bool] = Field(
|
||||
default_factory=dict, description="Named parameters (validated against script schema)"
|
||||
)
|
||||
|
||||
|
||||
class ScriptExecuteResponse(BaseModel):
|
||||
@@ -37,6 +43,18 @@ class ScriptExecuteResponse(BaseModel):
|
||||
execution_time: float | None = None
|
||||
|
||||
|
||||
class ScriptParameterInfo(BaseModel):
|
||||
"""Information about a script parameter."""
|
||||
|
||||
type: str
|
||||
description: str = ""
|
||||
required: bool = False
|
||||
default: str | int | float | bool | None = None
|
||||
min: float | None = None
|
||||
max: float | None = None
|
||||
options: list[str] | None = None
|
||||
|
||||
|
||||
class ScriptInfo(BaseModel):
|
||||
"""Information about an available script."""
|
||||
|
||||
@@ -46,6 +64,7 @@ class ScriptInfo(BaseModel):
|
||||
description: str
|
||||
icon: str | None = None
|
||||
timeout: int
|
||||
parameters: dict[str, ScriptParameterInfo] = Field(default_factory=dict)
|
||||
|
||||
|
||||
@router.get("/list")
|
||||
@@ -63,11 +82,126 @@ async def list_scripts(_: str = Depends(verify_token)) -> list[ScriptInfo]:
|
||||
description=config.description,
|
||||
icon=config.icon,
|
||||
timeout=config.timeout,
|
||||
parameters={
|
||||
pname: ScriptParameterInfo(**pconfig.model_dump())
|
||||
for pname, pconfig in config.parameters.items()
|
||||
},
|
||||
)
|
||||
for name, config in settings.scripts.items()
|
||||
]
|
||||
|
||||
|
||||
def _validate_params(
|
||||
params: dict[str, str | int | float | bool],
|
||||
param_defs: dict[str, ScriptParameterConfig],
|
||||
) -> dict[str, str]:
|
||||
"""Validate parameters against script schema and return env vars.
|
||||
|
||||
Args:
|
||||
params: User-supplied parameter values.
|
||||
param_defs: Parameter definitions from script config.
|
||||
|
||||
Returns:
|
||||
Dict of environment variables (SCRIPT_PARAM_<NAME> -> str value).
|
||||
|
||||
Raises:
|
||||
HTTPException: On validation failure.
|
||||
"""
|
||||
# Reject unknown parameters
|
||||
unknown = set(params.keys()) - set(param_defs.keys())
|
||||
if unknown:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unknown parameters: {', '.join(sorted(unknown))}",
|
||||
)
|
||||
|
||||
env_vars: dict[str, str] = {}
|
||||
|
||||
for pname, pdef in param_defs.items():
|
||||
value = params.get(pname)
|
||||
|
||||
# Apply default if missing
|
||||
if value is None and pdef.default is not None:
|
||||
value = pdef.default
|
||||
|
||||
# Check required
|
||||
if value is None:
|
||||
if pdef.required:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Required parameter '{pname}' is missing",
|
||||
)
|
||||
continue
|
||||
|
||||
# Type validation and coercion
|
||||
if pdef.type == "integer":
|
||||
try:
|
||||
value = int(value)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Parameter '{pname}' must be an integer, got: {value!r}",
|
||||
)
|
||||
if pdef.min is not None and value < pdef.min:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Parameter '{pname}' must be >= {pdef.min}, got: {value}",
|
||||
)
|
||||
if pdef.max is not None and value > pdef.max:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Parameter '{pname}' must be <= {pdef.max}, got: {value}",
|
||||
)
|
||||
elif pdef.type == "float":
|
||||
try:
|
||||
value = float(value)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Parameter '{pname}' must be a number, got: {value!r}",
|
||||
)
|
||||
if pdef.min is not None and value < pdef.min:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Parameter '{pname}' must be >= {pdef.min}, got: {value}",
|
||||
)
|
||||
if pdef.max is not None and value > pdef.max:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Parameter '{pname}' must be <= {pdef.max}, got: {value}",
|
||||
)
|
||||
elif pdef.type == "boolean":
|
||||
if isinstance(value, str):
|
||||
if value.lower() in ("true", "1", "yes"):
|
||||
value = True
|
||||
elif value.lower() in ("false", "0", "no"):
|
||||
value = False
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Parameter '{pname}' must be a boolean, got: {value!r}",
|
||||
)
|
||||
elif not isinstance(value, bool):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Parameter '{pname}' must be a boolean, got: {value!r}",
|
||||
)
|
||||
elif pdef.type == "select":
|
||||
value = str(value)
|
||||
if pdef.options and value not in pdef.options:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Parameter '{pname}' must be one of {pdef.options}, got: {value!r}",
|
||||
)
|
||||
else:
|
||||
# string — just convert to str
|
||||
value = str(value)
|
||||
|
||||
env_vars[f"SCRIPT_PARAM_{pname.upper()}"] = str(value)
|
||||
|
||||
return env_vars
|
||||
|
||||
|
||||
@router.post("/execute/{script_name}")
|
||||
async def execute_script(
|
||||
script_name: str,
|
||||
@@ -78,7 +212,7 @@ async def execute_script(
|
||||
|
||||
Args:
|
||||
script_name: Name of the script to execute (must be defined in config)
|
||||
request: Optional arguments to pass to the script
|
||||
request: Optional parameters to pass to the script
|
||||
|
||||
Returns:
|
||||
Execution result including stdout, stderr, and exit code
|
||||
@@ -90,26 +224,24 @@ async def execute_script(
|
||||
detail=f"Script '{script_name}' not found. Use /api/scripts/list to see available scripts.",
|
||||
)
|
||||
script_config = settings.scripts[script_name]
|
||||
args = request.args if request else []
|
||||
params = request.params if request else {}
|
||||
|
||||
# Validate parameters and build env vars
|
||||
extra_env = _validate_params(params, script_config.parameters)
|
||||
|
||||
logger.info(f"Executing script: {script_name}")
|
||||
|
||||
try:
|
||||
# Build command
|
||||
command = script_config.command
|
||||
if args:
|
||||
# Append arguments to command
|
||||
command = f"{command} {' '.join(args)}"
|
||||
|
||||
# Execute in thread pool to not block
|
||||
# Execute in dedicated thread pool to not block the default executor
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
_script_executor,
|
||||
lambda: _run_script(
|
||||
command=command,
|
||||
command=script_config.command,
|
||||
timeout=script_config.timeout,
|
||||
shell=script_config.shell,
|
||||
working_dir=script_config.working_dir,
|
||||
extra_env=extra_env,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -136,6 +268,7 @@ def _run_script(
|
||||
timeout: int,
|
||||
shell: bool,
|
||||
working_dir: str | None,
|
||||
extra_env: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Run a script synchronously.
|
||||
|
||||
@@ -144,11 +277,16 @@ def _run_script(
|
||||
timeout: Timeout in seconds
|
||||
shell: Whether to run in shell
|
||||
working_dir: Working directory
|
||||
extra_env: Additional environment variables (e.g. SCRIPT_PARAM_*)
|
||||
|
||||
Returns:
|
||||
Dict with exit_code, stdout, stderr, execution_time
|
||||
"""
|
||||
start_time = time.time()
|
||||
env = None
|
||||
if extra_env:
|
||||
import os
|
||||
env = {**os.environ, **extra_env}
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
@@ -157,6 +295,7 @@ def _run_script(
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
env=env,
|
||||
)
|
||||
execution_time = time.time() - start_time
|
||||
return {
|
||||
@@ -186,6 +325,24 @@ def _run_script(
|
||||
# Script management endpoints
|
||||
|
||||
|
||||
class ScriptParameterCreateRequest(BaseModel):
|
||||
"""Request model for a script parameter definition."""
|
||||
|
||||
type: str = Field(
|
||||
..., description="Parameter type: string, integer, float, boolean, select"
|
||||
)
|
||||
description: str = Field(default="", description="Parameter description")
|
||||
required: bool = Field(default=False, description="Whether the parameter is required")
|
||||
default: str | int | float | bool | None = Field(
|
||||
default=None, description="Default value if not provided"
|
||||
)
|
||||
min: float | None = Field(default=None, description="Minimum value (numeric types only)")
|
||||
max: float | None = Field(default=None, description="Maximum value (numeric types only)")
|
||||
options: list[str] | None = Field(
|
||||
default=None, description="Allowed values (select type only)"
|
||||
)
|
||||
|
||||
|
||||
class ScriptCreateRequest(BaseModel):
|
||||
"""Request model for creating or updating a script."""
|
||||
|
||||
@@ -196,6 +353,60 @@ class ScriptCreateRequest(BaseModel):
|
||||
timeout: int = Field(default=30, description="Execution timeout in seconds", ge=1, le=300)
|
||||
working_dir: str | None = Field(default=None, description="Working directory")
|
||||
shell: bool = Field(default=True, description="Run command in shell")
|
||||
parameters: dict[str, ScriptParameterCreateRequest] = Field(
|
||||
default_factory=dict, description="Named parameters with type and validation rules"
|
||||
)
|
||||
|
||||
|
||||
def _validate_parameter_definitions(
|
||||
parameters: dict[str, ScriptParameterCreateRequest],
|
||||
) -> None:
|
||||
"""Validate parameter definitions are well-formed.
|
||||
|
||||
Args:
|
||||
parameters: Parameter definitions to validate.
|
||||
|
||||
Raises:
|
||||
HTTPException: If any definition is invalid.
|
||||
"""
|
||||
valid_types = {"string", "integer", "float", "boolean", "select"}
|
||||
|
||||
for pname, pdef in parameters.items():
|
||||
if not re.match(r"^[a-zA-Z][a-zA-Z0-9_]*$", pname):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=(
|
||||
f"Parameter name '{pname}' must start with a letter"
|
||||
" and contain only alphanumeric characters and underscores"
|
||||
),
|
||||
)
|
||||
|
||||
if pdef.type not in valid_types:
|
||||
allowed = ", ".join(sorted(valid_types))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Parameter '{pname}' has invalid type '{pdef.type}'. Must be one of: {allowed}",
|
||||
)
|
||||
|
||||
if pdef.type == "select":
|
||||
if not pdef.options or len(pdef.options) == 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Parameter '{pname}' of type 'select' must have a non-empty 'options' list",
|
||||
)
|
||||
|
||||
if pdef.type not in ("integer", "float"):
|
||||
if pdef.min is not None or pdef.max is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Parameter '{pname}': 'min'/'max' are only valid for integer/float types",
|
||||
)
|
||||
|
||||
if pdef.min is not None and pdef.max is not None and pdef.min > pdef.max:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Parameter '{pname}': 'min' ({pdef.min}) must be <= 'max' ({pdef.max})",
|
||||
)
|
||||
|
||||
|
||||
def _validate_script_name(name: str) -> None:
|
||||
@@ -254,8 +465,16 @@ async def create_script(
|
||||
detail=f"Script '{script_name}' already exists. Use PUT /api/scripts/update/{script_name} to update it.",
|
||||
)
|
||||
|
||||
# Create script config
|
||||
script_config = ScriptConfig(**request.model_dump())
|
||||
# Validate parameter definitions
|
||||
_validate_parameter_definitions(request.parameters)
|
||||
|
||||
# Build ScriptConfig with ScriptParameterConfig instances
|
||||
data = request.model_dump()
|
||||
data["parameters"] = {
|
||||
pname: ScriptParameterConfig(**pdef)
|
||||
for pname, pdef in data.get("parameters", {}).items()
|
||||
}
|
||||
script_config = ScriptConfig(**data)
|
||||
|
||||
# Add to config file and in-memory
|
||||
try:
|
||||
@@ -302,8 +521,16 @@ async def update_script(
|
||||
detail=f"Script '{script_name}' not found. Use POST /api/scripts/create/{script_name} to create it.",
|
||||
)
|
||||
|
||||
# Create updated script config
|
||||
script_config = ScriptConfig(**request.model_dump())
|
||||
# Validate parameter definitions
|
||||
_validate_parameter_definitions(request.parameters)
|
||||
|
||||
# Build ScriptConfig with ScriptParameterConfig instances
|
||||
data = request.model_dump()
|
||||
data["parameters"] = {
|
||||
pname: ScriptParameterConfig(**pdef)
|
||||
for pname, pdef in data.get("parameters", {}).items()
|
||||
}
|
||||
script_config = ScriptConfig(**data)
|
||||
|
||||
# Update config file and in-memory
|
||||
try:
|
||||
|
||||
@@ -13,15 +13,12 @@ Usage:
|
||||
|
||||
import os
|
||||
import sys
|
||||
import socket
|
||||
import logging
|
||||
|
||||
try:
|
||||
import win32serviceutil
|
||||
import win32service
|
||||
import win32event
|
||||
import servicemanager
|
||||
import win32api
|
||||
import win32event
|
||||
import win32service
|
||||
import win32serviceutil
|
||||
|
||||
WIN32_AVAILABLE = True
|
||||
except ImportError:
|
||||
@@ -64,8 +61,9 @@ class MediaServerService:
|
||||
def main(self):
|
||||
"""Main service loop."""
|
||||
import uvicorn
|
||||
from media_server.main import app
|
||||
|
||||
from media_server.config import settings
|
||||
from media_server.main import app
|
||||
|
||||
config = uvicorn.Config(
|
||||
app,
|
||||
@@ -95,10 +93,9 @@ def install_service():
|
||||
|
||||
try:
|
||||
# Get the path to the Python executable
|
||||
python_exe = sys.executable
|
||||
|
||||
# Get the path to this module
|
||||
module_path = os.path.abspath(__file__)
|
||||
os.path.abspath(__file__)
|
||||
|
||||
win32serviceutil.InstallService(
|
||||
MediaServerService._svc_name_,
|
||||
|
||||
@@ -40,8 +40,8 @@ def get_media_controller() -> "MediaController":
|
||||
system = platform.system()
|
||||
|
||||
if system == "Windows":
|
||||
from .windows_media import WindowsMediaController
|
||||
from ..config import settings
|
||||
from .windows_media import WindowsMediaController
|
||||
|
||||
_controller_instance = WindowsMediaController(audio_device=settings.audio_device)
|
||||
elif system == "Linux":
|
||||
|
||||
@@ -10,11 +10,10 @@ Installation:
|
||||
4. Grant necessary permissions to Termux:API
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
from typing import Optional, Any
|
||||
from typing import Any, Optional
|
||||
|
||||
from ..models import MediaState, MediaStatus
|
||||
from .media_controller import MediaController
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
"""Audio spectrum analyzer service using system loopback capture."""
|
||||
|
||||
import logging
|
||||
import math
|
||||
import platform
|
||||
import threading
|
||||
import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_np = None
|
||||
_sc = None
|
||||
|
||||
|
||||
def _load_numpy():
|
||||
global _np
|
||||
if _np is None:
|
||||
try:
|
||||
import os
|
||||
import sys
|
||||
if sys.platform == 'win32':
|
||||
# Embedded Python doesn't auto-load DLLs from numpy.libs;
|
||||
# add the directory explicitly so libopenblas can be found.
|
||||
try:
|
||||
import importlib.util
|
||||
spec = importlib.util.find_spec('numpy')
|
||||
if spec and spec.submodule_search_locations:
|
||||
numpy_dir = list(spec.submodule_search_locations)[0]
|
||||
libs_dir = os.path.join(os.path.dirname(numpy_dir), 'numpy.libs')
|
||||
if os.path.isdir(libs_dir):
|
||||
os.add_dll_directory(libs_dir)
|
||||
except Exception:
|
||||
pass
|
||||
import numpy as np
|
||||
_np = np
|
||||
except Exception as e:
|
||||
logger.warning("numpy unavailable - audio visualizer disabled: %s", e)
|
||||
return _np
|
||||
|
||||
|
||||
def _load_soundcard():
|
||||
global _sc
|
||||
if _sc is None:
|
||||
try:
|
||||
import soundcard as sc
|
||||
_sc = sc
|
||||
except Exception as e:
|
||||
logger.warning("soundcard unavailable - audio visualizer disabled: %s", e)
|
||||
return _sc
|
||||
|
||||
|
||||
class AudioAnalyzer:
|
||||
"""Captures system audio loopback and performs real-time FFT analysis."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
num_bins: int = 32,
|
||||
sample_rate: int = 44100,
|
||||
chunk_size: int = 1024,
|
||||
target_fps: int = 30,
|
||||
device_name: str | None = None,
|
||||
):
|
||||
self.num_bins = num_bins
|
||||
self.sample_rate = sample_rate
|
||||
self.chunk_size = chunk_size
|
||||
self.target_fps = target_fps
|
||||
self.device_name = device_name
|
||||
|
||||
self._running = False
|
||||
self._thread: threading.Thread | None = None
|
||||
self._lock = threading.Lock()
|
||||
self._lifecycle_lock = threading.Lock()
|
||||
self._data: dict | None = None
|
||||
self._current_device_name: str | None = None
|
||||
# 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()
|
||||
|
||||
def _compute_bin_edges(self) -> list[int]:
|
||||
"""Compute logarithmic frequency bin boundaries for perceptual grouping."""
|
||||
np = _load_numpy()
|
||||
if np is None:
|
||||
return []
|
||||
|
||||
fft_size = self.chunk_size // 2 + 1
|
||||
min_freq = 20.0
|
||||
max_freq = min(16000.0, self.sample_rate / 2)
|
||||
|
||||
edges = []
|
||||
for i in range(self.num_bins + 1):
|
||||
freq = min_freq * (max_freq / min_freq) ** (i / self.num_bins)
|
||||
bin_idx = int(freq * self.chunk_size / self.sample_rate)
|
||||
edges.append(min(bin_idx, fft_size - 1))
|
||||
return edges
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Whether audio capture dependencies are available."""
|
||||
return _load_numpy() is not None and _load_soundcard() is not None
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
"""Whether capture is currently active."""
|
||||
return self._running
|
||||
|
||||
def start(self) -> bool:
|
||||
"""Start audio capture in a background thread. Returns False if unavailable."""
|
||||
with self._lifecycle_lock:
|
||||
if self._running:
|
||||
return True
|
||||
if not self.available:
|
||||
return False
|
||||
|
||||
# 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()
|
||||
return True
|
||||
|
||||
def stop(self) -> None:
|
||||
"""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."""
|
||||
sc = _load_soundcard()
|
||||
if sc is None:
|
||||
return []
|
||||
|
||||
devices = []
|
||||
try:
|
||||
# COM may be needed on Windows for WASAPI
|
||||
if platform.system() == "Windows":
|
||||
try:
|
||||
import comtypes
|
||||
comtypes.CoInitializeEx(comtypes.COINIT_MULTITHREADED)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
loopback_mics = sc.all_microphones(include_loopback=True)
|
||||
for mic in loopback_mics:
|
||||
if mic.isloopback:
|
||||
devices.append({"id": mic.id, "name": mic.name})
|
||||
except Exception as e:
|
||||
logger.warning("Failed to list loopback devices: %s", e)
|
||||
|
||||
return devices
|
||||
|
||||
def _find_loopback_device(self):
|
||||
"""Find a loopback device for system audio capture."""
|
||||
sc = _load_soundcard()
|
||||
if sc is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
loopback_mics = sc.all_microphones(include_loopback=True)
|
||||
|
||||
# If a specific device is requested, find it by name (partial match)
|
||||
if self.device_name:
|
||||
target = self.device_name.lower()
|
||||
for mic in loopback_mics:
|
||||
if mic.isloopback and target in mic.name.lower():
|
||||
logger.info("Found requested loopback device: %s", mic.name)
|
||||
self._current_device_name = mic.name
|
||||
return mic
|
||||
logger.warning("Requested device '%s' not found, falling back to default", self.device_name)
|
||||
|
||||
# Default: first loopback device
|
||||
for mic in loopback_mics:
|
||||
if mic.isloopback:
|
||||
logger.info("Found loopback device: %s", mic.name)
|
||||
self._current_device_name = mic.name
|
||||
return mic
|
||||
|
||||
# Fallback: try to get default speaker's loopback
|
||||
default_speaker = sc.default_speaker()
|
||||
if default_speaker:
|
||||
for mic in loopback_mics:
|
||||
if default_speaker.name in mic.name:
|
||||
logger.info("Found speaker loopback: %s", mic.name)
|
||||
self._current_device_name = mic.name
|
||||
return mic
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Failed to find loopback device: %s", e)
|
||||
|
||||
return None
|
||||
|
||||
def set_device(self, device_name: str | None) -> bool:
|
||||
"""Change the loopback device. Restarts capture if running. Returns True on success."""
|
||||
was_running = self._running
|
||||
if was_running:
|
||||
self.stop()
|
||||
|
||||
self.device_name = device_name
|
||||
self._current_device_name = None
|
||||
|
||||
if was_running:
|
||||
return self.start()
|
||||
return True
|
||||
|
||||
@property
|
||||
def current_device(self) -> str | None:
|
||||
"""Return the name of the currently active loopback device."""
|
||||
return self._current_device_name
|
||||
|
||||
def _capture_loop(self) -> None:
|
||||
"""Background thread: capture audio and compute FFT continuously."""
|
||||
# Initialize COM on Windows (required for WASAPI/SoundCard)
|
||||
if platform.system() == "Windows":
|
||||
try:
|
||||
import comtypes
|
||||
comtypes.CoInitializeEx(comtypes.COINIT_MULTITHREADED)
|
||||
except Exception:
|
||||
try:
|
||||
import ctypes
|
||||
ctypes.windll.ole32.CoInitializeEx(0, 0)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to initialize COM: %s", e)
|
||||
|
||||
np = _load_numpy()
|
||||
sc = _load_soundcard()
|
||||
if np is None or sc is None:
|
||||
self._running = False
|
||||
return
|
||||
|
||||
device = self._find_loopback_device()
|
||||
if device is None:
|
||||
logger.warning("No loopback audio device found - visualizer disabled")
|
||||
self._running = False
|
||||
return
|
||||
|
||||
interval = 1.0 / self.target_fps
|
||||
# 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(
|
||||
samplerate=self.sample_rate,
|
||||
channels=1,
|
||||
blocksize=self.chunk_size,
|
||||
) as recorder:
|
||||
logger.info("Audio capture started on: %s", device.name)
|
||||
while self._running:
|
||||
t0 = time.monotonic()
|
||||
|
||||
try:
|
||||
data = recorder.record(numframes=self.chunk_size)
|
||||
except Exception as e:
|
||||
logger.debug("Audio capture read error: %s", e)
|
||||
time.sleep(interval)
|
||||
continue
|
||||
|
||||
# Mono mix if needed
|
||||
if data.ndim > 1:
|
||||
mono = data.mean(axis=1)
|
||||
else:
|
||||
mono = data.ravel()
|
||||
|
||||
if len(mono) < self.chunk_size:
|
||||
time.sleep(interval)
|
||||
continue
|
||||
|
||||
# Apply window 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).
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
# 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 = 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
|
||||
if elapsed < interval:
|
||||
time.sleep(interval - elapsed)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Audio capture loop error: %s", e)
|
||||
finally:
|
||||
self._running = False
|
||||
logger.info("Audio capture stopped")
|
||||
|
||||
|
||||
# Global singleton
|
||||
_analyzer: AudioAnalyzer | None = None
|
||||
|
||||
|
||||
def get_audio_analyzer(
|
||||
num_bins: int = 32,
|
||||
sample_rate: int = 44100,
|
||||
target_fps: int = 25,
|
||||
device_name: str | None = None,
|
||||
) -> AudioAnalyzer:
|
||||
"""Get or create the global AudioAnalyzer instance."""
|
||||
global _analyzer
|
||||
if _analyzer is None:
|
||||
_analyzer = AudioAnalyzer(
|
||||
num_bins=num_bins,
|
||||
sample_rate=sample_rate,
|
||||
target_fps=target_fps,
|
||||
device_name=device_name,
|
||||
)
|
||||
return _analyzer
|
||||
@@ -1,12 +1,10 @@
|
||||
"""Browser service for media file browsing and path validation."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import stat as stat_module
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from ..config import settings
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Display brightness and power control service."""
|
||||
"""Display brightness, power, contrast, input-source and color-preset control."""
|
||||
|
||||
import ctypes
|
||||
import ctypes.wintypes
|
||||
import logging
|
||||
import platform
|
||||
import struct
|
||||
@@ -8,6 +10,29 @@ from dataclasses import dataclass, field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# VCP 0xDC "Display Application" — picture / scene mode.
|
||||
# Vendors deviate from the MCCS spec, but these labels match the standard
|
||||
# meanings and cover what most monitors report through their capability
|
||||
# string. Unknown codes fall back to "Mode <n>".
|
||||
PICTURE_MODE_VCP = 0xDC
|
||||
PICTURE_MODE_LABELS: dict[int, str] = {
|
||||
0x00: "Default",
|
||||
0x01: "Standalone",
|
||||
0x02: "Mixed",
|
||||
0x03: "Productivity",
|
||||
0x04: "Movie",
|
||||
0x05: "Game",
|
||||
0x06: "Sports",
|
||||
0x07: "Professional",
|
||||
0x08: "Standard",
|
||||
0x09: "Default",
|
||||
0x0A: "Movie (Reduced Effects)",
|
||||
0x0B: "Movie (Enhanced)",
|
||||
0x0C: "User 1",
|
||||
0x0D: "User 2",
|
||||
0x0E: "User 3",
|
||||
}
|
||||
|
||||
_sbc = None
|
||||
_monitorcontrol = None
|
||||
|
||||
@@ -30,7 +55,7 @@ def _load_monitorcontrol():
|
||||
import monitorcontrol
|
||||
_monitorcontrol = monitorcontrol
|
||||
except ImportError:
|
||||
logger.warning("monitorcontrol not installed - display power control unavailable")
|
||||
logger.warning("monitorcontrol not installed - DDC/CI control unavailable")
|
||||
return _monitorcontrol
|
||||
|
||||
|
||||
@@ -61,6 +86,19 @@ class MonitorInfo:
|
||||
model: str = ""
|
||||
manufacturer: str = ""
|
||||
resolution: str | None = None
|
||||
is_primary: bool = False
|
||||
contrast: int | None = None
|
||||
contrast_supported: bool = False
|
||||
input_source: str | None = None
|
||||
available_input_sources: list[str] = field(default_factory=list)
|
||||
input_source_supported: bool = False
|
||||
color_preset: str | None = None
|
||||
available_color_presets: list[str] = field(default_factory=list)
|
||||
color_preset_supported: bool = False
|
||||
picture_mode: str | None = None
|
||||
picture_mode_code: int | None = None
|
||||
available_picture_modes: list[dict] = field(default_factory=list)
|
||||
picture_mode_supported: bool = False
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
@@ -72,20 +110,257 @@ class MonitorInfo:
|
||||
"model": self.model,
|
||||
"manufacturer": self.manufacturer,
|
||||
"resolution": self.resolution,
|
||||
"is_primary": self.is_primary,
|
||||
"contrast": self.contrast,
|
||||
"contrast_supported": self.contrast_supported,
|
||||
"input_source": self.input_source,
|
||||
"available_input_sources": self.available_input_sources,
|
||||
"input_source_supported": self.input_source_supported,
|
||||
"color_preset": self.color_preset,
|
||||
"available_color_presets": self.available_color_presets,
|
||||
"color_preset_supported": self.color_preset_supported,
|
||||
"picture_mode": self.picture_mode,
|
||||
"picture_mode_code": self.picture_mode_code,
|
||||
"available_picture_modes": self.available_picture_modes,
|
||||
"picture_mode_supported": self.picture_mode_supported,
|
||||
}
|
||||
|
||||
|
||||
# Cache for monitor list
|
||||
def _detect_primary_resolution() -> str | None:
|
||||
"""Detect the primary display resolution via Windows API."""
|
||||
if platform.system() != "Windows":
|
||||
return None
|
||||
try:
|
||||
class MONITORINFO(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("cbSize", ctypes.wintypes.DWORD),
|
||||
("rcMonitor", ctypes.wintypes.RECT),
|
||||
("rcWork", ctypes.wintypes.RECT),
|
||||
("dwFlags", ctypes.wintypes.DWORD),
|
||||
]
|
||||
|
||||
MONITORINFOF_PRIMARY = 1
|
||||
primary_res = None
|
||||
|
||||
def callback(hmon, hdc, rect, data):
|
||||
nonlocal primary_res
|
||||
mi = MONITORINFO()
|
||||
mi.cbSize = ctypes.sizeof(mi)
|
||||
ctypes.windll.user32.GetMonitorInfoW(hmon, ctypes.byref(mi))
|
||||
if mi.dwFlags & MONITORINFOF_PRIMARY:
|
||||
w = mi.rcMonitor.right - mi.rcMonitor.left
|
||||
h = mi.rcMonitor.bottom - mi.rcMonitor.top
|
||||
primary_res = f"{w}x{h}"
|
||||
return True
|
||||
|
||||
MONITORENUMPROC = ctypes.WINFUNCTYPE(
|
||||
ctypes.c_int,
|
||||
ctypes.wintypes.HMONITOR,
|
||||
ctypes.wintypes.HDC,
|
||||
ctypes.POINTER(ctypes.wintypes.RECT),
|
||||
ctypes.wintypes.LPARAM,
|
||||
)
|
||||
ctypes.windll.user32.EnumDisplayMonitors(
|
||||
None, None, MONITORENUMPROC(callback), 0
|
||||
)
|
||||
return primary_res
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _mark_primary(monitors: list[MonitorInfo]) -> None:
|
||||
"""Mark the primary display in the monitor list."""
|
||||
if not monitors:
|
||||
return
|
||||
|
||||
primary_res = _detect_primary_resolution()
|
||||
if primary_res:
|
||||
for m in monitors:
|
||||
if m.resolution == primary_res:
|
||||
m.is_primary = True
|
||||
return
|
||||
|
||||
# Fallback: mark first monitor as primary
|
||||
monitors[0].is_primary = True
|
||||
|
||||
|
||||
# Short TTL cache of the assembled monitor list (full response).
|
||||
_monitor_cache: list[MonitorInfo] | None = None
|
||||
_cache_time: float = 0
|
||||
_CACHE_TTL = 5.0 # seconds
|
||||
|
||||
# Per-monitor cache of static capabilities (option lists + support flags).
|
||||
# DDC/CI capability discovery is the slow part — it only changes when a
|
||||
# monitor is replaced or rewired, so we probe it once per monitor and reuse
|
||||
# it across refreshes. Cleared on explicit `rediscover` or when the monitor
|
||||
# count changes (cheap stale-detection for hot-plug events).
|
||||
_static_cache: dict[int, dict] = {}
|
||||
_static_cache_monitor_count: int = -1
|
||||
|
||||
def list_monitors(force_refresh: bool = False) -> list[MonitorInfo]:
|
||||
"""List all connected monitors with their current brightness."""
|
||||
global _monitor_cache, _cache_time
|
||||
|
||||
if not force_refresh and _monitor_cache is not None and (time.time() - _cache_time) < _CACHE_TTL:
|
||||
def _enum_name(value, enum_cls=None) -> str | None:
|
||||
"""Best-effort name for an enum or raw int returned by monitorcontrol.
|
||||
|
||||
monitorcontrol's getters sometimes hand back raw ints when the monitor
|
||||
reports a value the library wraps incompletely. Re-map those through the
|
||||
matching enum class so HA selects still receive symbolic option names.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
name = getattr(value, "name", None)
|
||||
if name:
|
||||
return name
|
||||
if enum_cls is not None:
|
||||
try:
|
||||
return enum_cls(value).name
|
||||
except (ValueError, KeyError):
|
||||
pass
|
||||
return str(value)
|
||||
|
||||
|
||||
def _probe_static_open(mon, mc, monitor_id: int) -> dict:
|
||||
"""Probe per-monitor static capabilities.
|
||||
|
||||
Must be called inside an open `with mon:` DDC/CI context. Tries each
|
||||
feature once to confirm the monitor responds, and enumerates option
|
||||
lists from the capability string. Heavy: this is what the cache is for.
|
||||
"""
|
||||
static = {
|
||||
"contrast_supported": False,
|
||||
"input_source_supported": False,
|
||||
"available_input_sources": [],
|
||||
"color_preset_supported": False,
|
||||
"available_color_presets": [],
|
||||
"picture_mode_supported": False,
|
||||
"available_picture_modes": [],
|
||||
}
|
||||
|
||||
try:
|
||||
caps = mon.get_vcp_capabilities() or {}
|
||||
except Exception as e:
|
||||
caps = {}
|
||||
logger.debug("Monitor %d: get_vcp_capabilities failed: %s", monitor_id, e)
|
||||
|
||||
try:
|
||||
mon.get_contrast()
|
||||
static["contrast_supported"] = True
|
||||
except Exception as e:
|
||||
logger.debug("Monitor %d: contrast unsupported: %s", monitor_id, e)
|
||||
|
||||
try:
|
||||
mon.get_input_source()
|
||||
static["input_source_supported"] = True
|
||||
except Exception as e:
|
||||
logger.debug("Monitor %d: input_source unsupported: %s", monitor_id, e)
|
||||
|
||||
inputs = caps.get("inputs") or []
|
||||
input_enum = mc.InputSource if mc else None
|
||||
static["available_input_sources"] = [
|
||||
n for n in (_enum_name(s, input_enum) for s in inputs) if n is not None
|
||||
]
|
||||
|
||||
try:
|
||||
mon.get_color_preset()
|
||||
static["color_preset_supported"] = True
|
||||
if mc is not None:
|
||||
static["available_color_presets"] = [p.name for p in mc.ColorPreset]
|
||||
except Exception as e:
|
||||
logger.debug("Monitor %d: color_preset unsupported: %s", monitor_id, e)
|
||||
|
||||
# Picture / scene mode (VCP 0xDC). Trickier than color preset because
|
||||
# many monitors (LG ultrawides included) respond to READS but silently
|
||||
# drop every WRITE - they implement the register but not the feature.
|
||||
# The capability string is the most reliable signal: a monitor that
|
||||
# really implements picture mode declares its supported codes under
|
||||
# cmds[0xDC]. If 0xDC isn't declared, treat the feature as unsupported
|
||||
# to avoid exposing a non-functional select.
|
||||
cmds = caps.get("cmds") or {}
|
||||
declared = cmds.get(PICTURE_MODE_VCP)
|
||||
if declared:
|
||||
try:
|
||||
mon.vcp.get_vcp_feature(PICTURE_MODE_VCP)
|
||||
static["picture_mode_supported"] = True
|
||||
static["available_picture_modes"] = [
|
||||
{"code": c, "label": PICTURE_MODE_LABELS.get(c, f"Mode {c}")}
|
||||
for c in sorted(declared)
|
||||
]
|
||||
except Exception as e:
|
||||
logger.debug("Monitor %d: picture_mode declared but unreadable: %s", monitor_id, e)
|
||||
else:
|
||||
logger.debug("Monitor %d: picture_mode (VCP 0xDC) not declared in capability string", monitor_id)
|
||||
|
||||
return static
|
||||
|
||||
|
||||
def _probe_dynamic_open(mon, mc, monitor_id: int, static: dict) -> dict:
|
||||
"""Read current values for features known to be supported.
|
||||
|
||||
Must be called inside an open `with mon:` context. Skips reads for
|
||||
unsupported features (saves one I2C roundtrip each), so the warm path
|
||||
only touches features the monitor actually responds to.
|
||||
"""
|
||||
dynamic = {
|
||||
"power_on": True,
|
||||
"contrast": None,
|
||||
"input_source": None,
|
||||
"color_preset": None,
|
||||
"picture_mode": None,
|
||||
"picture_mode_code": None,
|
||||
}
|
||||
|
||||
try:
|
||||
dynamic["power_on"] = mon.get_power_mode() == mc.PowerMode.on
|
||||
except Exception as e:
|
||||
logger.debug("Monitor %d: power readback failed: %s", monitor_id, e)
|
||||
|
||||
if static.get("contrast_supported"):
|
||||
try:
|
||||
dynamic["contrast"] = mon.get_contrast()
|
||||
except Exception as e:
|
||||
logger.debug("Monitor %d: contrast readback failed: %s", monitor_id, e)
|
||||
|
||||
if static.get("input_source_supported"):
|
||||
try:
|
||||
src = mon.get_input_source()
|
||||
dynamic["input_source"] = _enum_name(src, mc.InputSource if mc else None)
|
||||
except Exception as e:
|
||||
logger.debug("Monitor %d: input_source readback failed: %s", monitor_id, e)
|
||||
|
||||
if static.get("color_preset_supported"):
|
||||
try:
|
||||
preset = mon.get_color_preset()
|
||||
dynamic["color_preset"] = _enum_name(preset, mc.ColorPreset if mc else None)
|
||||
except Exception as e:
|
||||
logger.debug("Monitor %d: color_preset readback failed: %s", monitor_id, e)
|
||||
|
||||
if static.get("picture_mode_supported"):
|
||||
try:
|
||||
current, _maximum = mon.vcp.get_vcp_feature(PICTURE_MODE_VCP)
|
||||
dynamic["picture_mode_code"] = current
|
||||
dynamic["picture_mode"] = PICTURE_MODE_LABELS.get(current, f"Mode {current}")
|
||||
except Exception as e:
|
||||
logger.debug("Monitor %d: picture_mode readback failed: %s", monitor_id, e)
|
||||
|
||||
return dynamic
|
||||
|
||||
|
||||
def list_monitors(force_refresh: bool = False, rediscover: bool = False) -> list[MonitorInfo]:
|
||||
"""List all connected monitors with their current state.
|
||||
|
||||
Args:
|
||||
force_refresh: bypass the short TTL response cache.
|
||||
rediscover: also drop the per-monitor static capability cache, so the
|
||||
next probe re-runs DDC/CI capability discovery. Use after hot-plug
|
||||
or when a monitor's reported capabilities change.
|
||||
"""
|
||||
global _monitor_cache, _cache_time, _static_cache_monitor_count
|
||||
|
||||
if (
|
||||
not force_refresh
|
||||
and not rediscover
|
||||
and _monitor_cache is not None
|
||||
and (time.time() - _cache_time) < _CACHE_TTL
|
||||
):
|
||||
return _monitor_cache
|
||||
|
||||
sbc = _load_sbc()
|
||||
@@ -97,7 +372,13 @@ def list_monitors(force_refresh: bool = False) -> list[MonitorInfo]:
|
||||
info_list = sbc.list_monitors_info()
|
||||
brightnesses = sbc.get_brightness()
|
||||
|
||||
# Get DDC/CI monitors for power state
|
||||
# Invalidate the static cache on explicit rediscover OR on topology
|
||||
# change (hot-plug / disconnect). Both indicate the cached probe is
|
||||
# potentially stale.
|
||||
if rediscover or len(info_list) != _static_cache_monitor_count:
|
||||
_static_cache.clear()
|
||||
_static_cache_monitor_count = len(info_list)
|
||||
|
||||
mc = _load_monitorcontrol()
|
||||
ddc_monitors = []
|
||||
if mc:
|
||||
@@ -119,29 +400,49 @@ def list_monitors(force_refresh: bool = False) -> list[MonitorInfo]:
|
||||
edid = info.get("edid", "")
|
||||
resolution = _parse_edid_resolution(edid) if edid else None
|
||||
|
||||
# Read power state via DDC/CI
|
||||
power_on = True
|
||||
static: dict = {}
|
||||
dynamic: dict = {}
|
||||
|
||||
# Open the DDC handle ONCE; do static probe (if needed) + dynamic
|
||||
# readback inside the same context. Opening the handle is the
|
||||
# expensive part — keep both phases under one open.
|
||||
if power_supported and i < len(ddc_monitors):
|
||||
try:
|
||||
with ddc_monitors[i] as mon:
|
||||
power_mode = mon.get_power_mode()
|
||||
power_on = power_mode == mc.PowerMode.on
|
||||
except Exception:
|
||||
pass
|
||||
if i not in _static_cache:
|
||||
_static_cache[i] = _probe_static_open(mon, mc, i)
|
||||
static = _static_cache[i]
|
||||
dynamic = _probe_dynamic_open(mon, mc, i, static)
|
||||
except Exception as e:
|
||||
logger.debug("Monitor %d: DDC/CI session failed: %s", i, e)
|
||||
static = _static_cache.get(i, {})
|
||||
|
||||
monitors.append(MonitorInfo(
|
||||
id=i,
|
||||
name=name,
|
||||
brightness=brightness,
|
||||
power_supported=power_supported,
|
||||
power_on=power_on,
|
||||
power_on=dynamic.get("power_on", True),
|
||||
model=model,
|
||||
manufacturer=manufacturer,
|
||||
resolution=resolution,
|
||||
contrast=dynamic.get("contrast"),
|
||||
contrast_supported=static.get("contrast_supported", False),
|
||||
input_source=dynamic.get("input_source"),
|
||||
available_input_sources=static.get("available_input_sources", []),
|
||||
input_source_supported=static.get("input_source_supported", False),
|
||||
color_preset=dynamic.get("color_preset"),
|
||||
available_color_presets=static.get("available_color_presets", []),
|
||||
color_preset_supported=static.get("color_preset_supported", False),
|
||||
picture_mode=dynamic.get("picture_mode"),
|
||||
picture_mode_code=dynamic.get("picture_mode_code"),
|
||||
available_picture_modes=static.get("available_picture_modes", []),
|
||||
picture_mode_supported=static.get("picture_mode_supported", False),
|
||||
))
|
||||
except Exception as e:
|
||||
logger.error("Failed to enumerate monitors: %s", e)
|
||||
|
||||
_mark_primary(monitors)
|
||||
_monitor_cache = monitors
|
||||
_cache_time = time.time()
|
||||
return monitors
|
||||
@@ -171,9 +472,7 @@ def set_brightness(monitor_id: int, value: int) -> bool:
|
||||
value = max(0, min(100, value))
|
||||
try:
|
||||
sbc.set_brightness(value, display=monitor_id)
|
||||
# Invalidate cache
|
||||
global _monitor_cache
|
||||
_monitor_cache = None
|
||||
_invalidate_cache()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Failed to set brightness for monitor %d: %s", monitor_id, e)
|
||||
@@ -199,10 +498,155 @@ def set_power(monitor_id: int, on: bool) -> bool:
|
||||
else:
|
||||
monitor.set_power_mode(mc.PowerMode.off_soft)
|
||||
|
||||
# Invalidate cache
|
||||
global _monitor_cache
|
||||
_monitor_cache = None
|
||||
_invalidate_cache()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Failed to set power for monitor %d: %s", monitor_id, e)
|
||||
return False
|
||||
|
||||
|
||||
def _verify_after_set(getter, expected, *, retries: int = 3, delay: float = 0.1) -> bool:
|
||||
"""Poll a DDC/CI getter to confirm the monitor actually applied a write.
|
||||
|
||||
DDC/CI writes are fire-and-forget at the protocol level: a successful
|
||||
send does not mean the monitor honored the value. Many monitors silently
|
||||
drop writes for codes their firmware doesn't really implement (LG's
|
||||
ColorPreset / Picture Mode are common offenders). Without this check the
|
||||
API would report `success: true` while the monitor sat unchanged.
|
||||
|
||||
Compares both raw and `.value` forms so enum/int mismatches don't flag a
|
||||
spurious failure.
|
||||
"""
|
||||
expected_int = getattr(expected, "value", expected)
|
||||
for _ in range(retries):
|
||||
time.sleep(delay)
|
||||
try:
|
||||
actual = getter()
|
||||
except Exception:
|
||||
continue
|
||||
actual_int = getattr(actual, "value", actual)
|
||||
if actual == expected or actual_int == expected_int:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def set_contrast(monitor_id: int, value: int) -> bool:
|
||||
"""Set contrast for a specific monitor (0-100) via DDC/CI."""
|
||||
mc = _load_monitorcontrol()
|
||||
if mc is None:
|
||||
return False
|
||||
|
||||
value = max(0, min(100, value))
|
||||
try:
|
||||
ddc_monitors = mc.get_monitors()
|
||||
if monitor_id >= len(ddc_monitors):
|
||||
return False
|
||||
with ddc_monitors[monitor_id] as monitor:
|
||||
monitor.set_contrast(value)
|
||||
if not _verify_after_set(monitor.get_contrast, value):
|
||||
logger.warning("Monitor %d: contrast %d not applied", monitor_id, value)
|
||||
return False
|
||||
_invalidate_cache()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Failed to set contrast for monitor %d: %s", monitor_id, e)
|
||||
return False
|
||||
|
||||
|
||||
def set_input_source(monitor_id: int, source: str) -> bool:
|
||||
"""Set the DDC/CI input source by enum name (e.g. 'HDMI1', 'DP1')."""
|
||||
mc = _load_monitorcontrol()
|
||||
if mc is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
target = mc.InputSource[source]
|
||||
except KeyError:
|
||||
logger.error("Unknown input source: %s", source)
|
||||
return False
|
||||
|
||||
try:
|
||||
ddc_monitors = mc.get_monitors()
|
||||
if monitor_id >= len(ddc_monitors):
|
||||
return False
|
||||
with ddc_monitors[monitor_id] as monitor:
|
||||
monitor.set_input_source(target)
|
||||
# Source switches can briefly disrupt the DDC/CI link; allow a
|
||||
# longer settle window before declaring failure.
|
||||
if not _verify_after_set(monitor.get_input_source, target, retries=5, delay=0.2):
|
||||
logger.warning("Monitor %d: input source %s not applied", monitor_id, source)
|
||||
return False
|
||||
_invalidate_cache()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Failed to set input source for monitor %d: %s", monitor_id, e)
|
||||
return False
|
||||
|
||||
|
||||
def set_color_preset(monitor_id: int, preset: str) -> bool:
|
||||
"""Set the DDC/CI color preset by enum name (e.g. 'COLOR_TEMP_6500K')."""
|
||||
mc = _load_monitorcontrol()
|
||||
if mc is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
target = mc.ColorPreset[preset]
|
||||
except KeyError:
|
||||
logger.error("Unknown color preset: %s", preset)
|
||||
return False
|
||||
|
||||
try:
|
||||
ddc_monitors = mc.get_monitors()
|
||||
if monitor_id >= len(ddc_monitors):
|
||||
return False
|
||||
with ddc_monitors[monitor_id] as monitor:
|
||||
monitor.set_color_preset(target)
|
||||
if not _verify_after_set(monitor.get_color_preset, target):
|
||||
logger.warning(
|
||||
"Monitor %d: color preset %s not applied (monitor silently rejected)",
|
||||
monitor_id, preset,
|
||||
)
|
||||
return False
|
||||
_invalidate_cache()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Failed to set color preset for monitor %d: %s", monitor_id, e)
|
||||
return False
|
||||
|
||||
|
||||
def set_picture_mode(monitor_id: int, code: int) -> bool:
|
||||
"""Set the DDC/CI picture/scene mode (VCP 0xDC) by raw code."""
|
||||
mc = _load_monitorcontrol()
|
||||
if mc is None:
|
||||
return False
|
||||
|
||||
if not 0 <= code <= 255:
|
||||
logger.error("Picture mode code %d out of range", code)
|
||||
return False
|
||||
|
||||
try:
|
||||
ddc_monitors = mc.get_monitors()
|
||||
if monitor_id >= len(ddc_monitors):
|
||||
return False
|
||||
with ddc_monitors[monitor_id] as monitor:
|
||||
monitor.vcp.set_vcp_feature(PICTURE_MODE_VCP, code)
|
||||
# Raw VCP read returns (current, maximum) — only compare current.
|
||||
def _read_picture_mode():
|
||||
current, _ = monitor.vcp.get_vcp_feature(PICTURE_MODE_VCP)
|
||||
return current
|
||||
if not _verify_after_set(_read_picture_mode, code):
|
||||
logger.warning(
|
||||
"Monitor %d: picture mode code %d not applied (monitor silently rejected)",
|
||||
monitor_id, code,
|
||||
)
|
||||
return False
|
||||
_invalidate_cache()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Failed to set picture mode for monitor %d: %s", monitor_id, e)
|
||||
return False
|
||||
|
||||
|
||||
def _invalidate_cache() -> None:
|
||||
global _monitor_cache
|
||||
_monitor_cache = None
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Gitea release provider implementation."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Optional
|
||||
|
||||
from .release_provider import ReleaseInfo, ReleaseProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default repository coordinates
|
||||
_DEFAULT_BASE_URL = "https://git.dolgolyov-family.by"
|
||||
_DEFAULT_OWNER = "alexei.dolgolyov"
|
||||
_DEFAULT_REPO = "media-player-server"
|
||||
|
||||
|
||||
class GiteaReleaseProvider(ReleaseProvider):
|
||||
"""Fetches the latest release from a Gitea repository."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str = _DEFAULT_BASE_URL,
|
||||
owner: str = _DEFAULT_OWNER,
|
||||
repo: str = _DEFAULT_REPO,
|
||||
timeout: float = 10.0,
|
||||
) -> None:
|
||||
self._api_url = f"{base_url}/api/v1/repos/{owner}/{repo}/releases"
|
||||
self._release_page_url = f"{base_url}/{owner}/{repo}/releases/tag"
|
||||
self._timeout = timeout
|
||||
|
||||
async def get_latest_release(self) -> Optional[ReleaseInfo]:
|
||||
"""Fetch the latest stable release from Gitea API.
|
||||
|
||||
Returns:
|
||||
ReleaseInfo for the latest non-prerelease, or None on failure.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
data = await asyncio.to_thread(self._fetch_releases)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to check for updates: %s", e)
|
||||
return None
|
||||
|
||||
if not data:
|
||||
return None
|
||||
|
||||
# Find the first non-prerelease, non-draft release
|
||||
for release in data:
|
||||
if release.get("draft") or release.get("prerelease"):
|
||||
continue
|
||||
|
||||
tag = release.get("tag_name", "")
|
||||
version = tag.lstrip("v")
|
||||
if not version:
|
||||
continue
|
||||
|
||||
return ReleaseInfo(
|
||||
version=version,
|
||||
url=f"{self._release_page_url}/{tag}",
|
||||
prerelease=False,
|
||||
)
|
||||
|
||||
logger.debug("No stable releases found")
|
||||
return None
|
||||
|
||||
def _fetch_releases(self) -> list[dict]:
|
||||
"""Synchronous HTTP fetch of releases (run in thread)."""
|
||||
url = f"{self._api_url}?limit=5"
|
||||
req = urllib.request.Request(url, headers={"Accept": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
except (urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError, OSError) as e:
|
||||
raise RuntimeError(f"Gitea API request failed: {e}") from e
|
||||
@@ -3,7 +3,7 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import subprocess
|
||||
from typing import Optional, Any
|
||||
from typing import Any, Optional
|
||||
|
||||
from ..models import MediaState, MediaStatus
|
||||
from .media_controller import MediaController
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import subprocess
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from ..models import MediaState, MediaStatus
|
||||
@@ -203,11 +202,6 @@ class MacOSMediaController(MediaController):
|
||||
async def play(self) -> bool:
|
||||
"""Resume playback using media key simulation."""
|
||||
# Use system media key
|
||||
script = '''
|
||||
tell application "System Events"
|
||||
key code 16 using {command down, option down}
|
||||
end tell
|
||||
'''
|
||||
# Fallback: try specific app
|
||||
active_app = self._get_active_app()
|
||||
if active_app == "Spotify":
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -21,68 +20,75 @@ class MetadataService:
|
||||
Dictionary with audio metadata.
|
||||
"""
|
||||
try:
|
||||
import mutagen
|
||||
from mutagen import File as MutagenFile
|
||||
|
||||
audio = MutagenFile(str(file_path), easy=True)
|
||||
if audio is None:
|
||||
return {"error": "Unable to read audio file"}
|
||||
|
||||
metadata = {
|
||||
"type": "audio",
|
||||
"filename": file_path.name,
|
||||
"path": str(file_path),
|
||||
}
|
||||
try:
|
||||
metadata = {
|
||||
"type": "audio",
|
||||
"filename": file_path.name,
|
||||
"path": str(file_path),
|
||||
}
|
||||
|
||||
# Extract duration
|
||||
if hasattr(audio.info, "length"):
|
||||
metadata["duration"] = round(audio.info.length, 2)
|
||||
# Extract duration
|
||||
if hasattr(audio.info, "length"):
|
||||
metadata["duration"] = round(audio.info.length, 2)
|
||||
|
||||
# Extract bitrate
|
||||
if hasattr(audio.info, "bitrate"):
|
||||
metadata["bitrate"] = audio.info.bitrate
|
||||
# Extract bitrate
|
||||
if hasattr(audio.info, "bitrate"):
|
||||
metadata["bitrate"] = audio.info.bitrate
|
||||
|
||||
# Extract sample rate
|
||||
if hasattr(audio.info, "sample_rate"):
|
||||
metadata["sample_rate"] = audio.info.sample_rate
|
||||
elif hasattr(audio.info, "samplerate"):
|
||||
metadata["sample_rate"] = audio.info.samplerate
|
||||
# Extract sample rate
|
||||
if hasattr(audio.info, "sample_rate"):
|
||||
metadata["sample_rate"] = audio.info.sample_rate
|
||||
elif hasattr(audio.info, "samplerate"):
|
||||
metadata["sample_rate"] = audio.info.samplerate
|
||||
|
||||
# Extract channels
|
||||
if hasattr(audio.info, "channels"):
|
||||
metadata["channels"] = audio.info.channels
|
||||
# Extract channels
|
||||
if hasattr(audio.info, "channels"):
|
||||
metadata["channels"] = audio.info.channels
|
||||
|
||||
# Extract tags (use easy=True for consistent tag names)
|
||||
if audio is not None and hasattr(audio, "tags") and audio.tags:
|
||||
# Easy tags provide lists, so we take the first item
|
||||
tags = audio.tags
|
||||
# Extract tags (use easy=True for consistent tag names)
|
||||
if audio is not None and hasattr(audio, "tags") and audio.tags:
|
||||
# Easy tags provide lists, so we take the first item
|
||||
tags = audio.tags
|
||||
|
||||
if "title" in tags:
|
||||
metadata["title"] = tags["title"][0] if isinstance(tags["title"], list) else tags["title"]
|
||||
if "title" in tags:
|
||||
metadata["title"] = tags["title"][0] if isinstance(tags["title"], list) else tags["title"]
|
||||
|
||||
if "artist" in tags:
|
||||
metadata["artist"] = tags["artist"][0] if isinstance(tags["artist"], list) else tags["artist"]
|
||||
if "artist" in tags:
|
||||
metadata["artist"] = tags["artist"][0] if isinstance(tags["artist"], list) else tags["artist"]
|
||||
|
||||
if "album" in tags:
|
||||
metadata["album"] = tags["album"][0] if isinstance(tags["album"], list) else tags["album"]
|
||||
if "album" in tags:
|
||||
metadata["album"] = tags["album"][0] if isinstance(tags["album"], list) else tags["album"]
|
||||
|
||||
if "albumartist" in tags:
|
||||
metadata["album_artist"] = tags["albumartist"][0] if isinstance(tags["albumartist"], list) else tags["albumartist"]
|
||||
if "albumartist" in tags:
|
||||
metadata["album_artist"] = (
|
||||
tags["albumartist"][0] if isinstance(tags["albumartist"], list) else tags["albumartist"]
|
||||
)
|
||||
|
||||
if "date" in tags:
|
||||
metadata["date"] = tags["date"][0] if isinstance(tags["date"], list) else tags["date"]
|
||||
if "date" in tags:
|
||||
metadata["date"] = tags["date"][0] if isinstance(tags["date"], list) else tags["date"]
|
||||
|
||||
if "genre" in tags:
|
||||
metadata["genre"] = tags["genre"][0] if isinstance(tags["genre"], list) else tags["genre"]
|
||||
if "genre" in tags:
|
||||
metadata["genre"] = tags["genre"][0] if isinstance(tags["genre"], list) else tags["genre"]
|
||||
|
||||
if "tracknumber" in tags:
|
||||
metadata["track_number"] = tags["tracknumber"][0] if isinstance(tags["tracknumber"], list) else tags["tracknumber"]
|
||||
if "tracknumber" in tags:
|
||||
metadata["track_number"] = (
|
||||
tags["tracknumber"][0] if isinstance(tags["tracknumber"], list) else tags["tracknumber"]
|
||||
)
|
||||
|
||||
# If no title tag, use filename
|
||||
if "title" not in metadata:
|
||||
metadata["title"] = file_path.stem
|
||||
# If no title tag, use filename
|
||||
if "title" not in metadata:
|
||||
metadata["title"] = file_path.stem
|
||||
|
||||
return metadata
|
||||
return metadata
|
||||
finally:
|
||||
if hasattr(audio, 'close'):
|
||||
audio.close()
|
||||
|
||||
except ImportError:
|
||||
logger.error("mutagen library not installed, cannot extract metadata")
|
||||
@@ -106,7 +112,6 @@ class MetadataService:
|
||||
Dictionary with video metadata.
|
||||
"""
|
||||
try:
|
||||
import mutagen
|
||||
from mutagen import File as MutagenFile
|
||||
|
||||
video = MutagenFile(str(file_path))
|
||||
@@ -117,40 +122,44 @@ class MetadataService:
|
||||
"title": file_path.stem,
|
||||
}
|
||||
|
||||
metadata = {
|
||||
"type": "video",
|
||||
"filename": file_path.name,
|
||||
"path": str(file_path),
|
||||
}
|
||||
try:
|
||||
metadata = {
|
||||
"type": "video",
|
||||
"filename": file_path.name,
|
||||
"path": str(file_path),
|
||||
}
|
||||
|
||||
# Extract duration
|
||||
if hasattr(video.info, "length"):
|
||||
metadata["duration"] = round(video.info.length, 2)
|
||||
# Extract duration
|
||||
if hasattr(video.info, "length"):
|
||||
metadata["duration"] = round(video.info.length, 2)
|
||||
|
||||
# Extract bitrate
|
||||
if hasattr(video.info, "bitrate"):
|
||||
metadata["bitrate"] = video.info.bitrate
|
||||
# Extract bitrate
|
||||
if hasattr(video.info, "bitrate"):
|
||||
metadata["bitrate"] = video.info.bitrate
|
||||
|
||||
# Extract video-specific properties if available
|
||||
if hasattr(video.info, "width"):
|
||||
metadata["width"] = video.info.width
|
||||
# Extract video-specific properties if available
|
||||
if hasattr(video.info, "width"):
|
||||
metadata["width"] = video.info.width
|
||||
|
||||
if hasattr(video.info, "height"):
|
||||
metadata["height"] = video.info.height
|
||||
if hasattr(video.info, "height"):
|
||||
metadata["height"] = video.info.height
|
||||
|
||||
# Try to extract title from tags
|
||||
if hasattr(video, "tags") and video.tags:
|
||||
tags = video.tags
|
||||
if hasattr(tags, "get"):
|
||||
title = tags.get("title") or tags.get("TITLE") or tags.get("\xa9nam")
|
||||
if title:
|
||||
metadata["title"] = title[0] if isinstance(title, list) else str(title)
|
||||
# Try to extract title from tags
|
||||
if hasattr(video, "tags") and video.tags:
|
||||
tags = video.tags
|
||||
if hasattr(tags, "get"):
|
||||
title = tags.get("title") or tags.get("TITLE") or tags.get("\xa9nam")
|
||||
if title:
|
||||
metadata["title"] = title[0] if isinstance(title, list) else str(title)
|
||||
|
||||
# If no title tag, use filename
|
||||
if "title" not in metadata:
|
||||
metadata["title"] = file_path.stem
|
||||
# If no title tag, use filename
|
||||
if "title" not in metadata:
|
||||
metadata["title"] = file_path.stem
|
||||
|
||||
return metadata
|
||||
return metadata
|
||||
finally:
|
||||
if hasattr(video, 'close'):
|
||||
video.close()
|
||||
|
||||
except ImportError:
|
||||
logger.error("mutagen library not installed, cannot extract metadata")
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
...
|
||||
@@ -3,9 +3,7 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
@@ -151,10 +149,10 @@ class ThumbnailService:
|
||||
Thumbnail bytes (JPEG) or None if no album art.
|
||||
"""
|
||||
try:
|
||||
import mutagen
|
||||
from io import BytesIO
|
||||
|
||||
from mutagen import File as MutagenFile
|
||||
from PIL import Image
|
||||
from io import BytesIO
|
||||
|
||||
audio = MutagenFile(str(file_path))
|
||||
if audio is None:
|
||||
@@ -232,9 +230,10 @@ class ThumbnailService:
|
||||
Thumbnail bytes (JPEG) or None if ffmpeg not available.
|
||||
"""
|
||||
try:
|
||||
from PIL import Image
|
||||
from io import BytesIO
|
||||
|
||||
from PIL import Image
|
||||
|
||||
# Check if ffmpeg is available
|
||||
if not shutil.which("ffmpeg"):
|
||||
logger.debug("ffmpeg not available, cannot generate video thumbnail")
|
||||
@@ -247,7 +246,11 @@ class ThumbnailService:
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-i", str(file_path),
|
||||
"-vf", f"thumbnail,scale={target_size[0]}:{target_size[1]}:force_original_aspect_ratio=increase,crop={target_size[0]}:{target_size[1]}",
|
||||
"-vf", (
|
||||
f"thumbnail,scale={target_size[0]}:{target_size[1]}"
|
||||
f":force_original_aspect_ratio=increase"
|
||||
f",crop={target_size[0]}:{target_size[1]}"
|
||||
),
|
||||
"-frames:v", "1",
|
||||
"-f", "image2pipe",
|
||||
"-vcodec", "mjpeg",
|
||||
|
||||
@@ -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
|
||||
@@ -1,6 +1,7 @@
|
||||
"""WebSocket connection manager and status broadcaster."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Callable, Coroutine
|
||||
@@ -18,11 +19,16 @@ class ConnectionManager:
|
||||
self._active_connections: set[WebSocket] = set()
|
||||
self._lock = asyncio.Lock()
|
||||
self._last_status: dict[str, Any] | None = None
|
||||
self._get_status_func: Callable[[], Coroutine[Any, Any, Any]] | None = None
|
||||
self._broadcast_task: asyncio.Task | None = None
|
||||
self._poll_interval: float = 0.5 # Internal poll interval for change detection
|
||||
self._position_broadcast_interval: float = 5.0 # Send position updates every 5s during playback
|
||||
self._last_broadcast_time: float = 0.0
|
||||
self._running: bool = False
|
||||
# Audio visualizer
|
||||
self._visualizer_subscribers: set[WebSocket] = set()
|
||||
self._audio_task: asyncio.Task | None = None
|
||||
self._audio_analyzer = None
|
||||
|
||||
async def connect(self, websocket: WebSocket) -> None:
|
||||
"""Accept a new WebSocket connection."""
|
||||
@@ -34,16 +40,31 @@ class ConnectionManager:
|
||||
)
|
||||
|
||||
# Send current status immediately upon connection
|
||||
if self._last_status:
|
||||
status = self._last_status
|
||||
if not status and self._get_status_func:
|
||||
try:
|
||||
await websocket.send_json({"type": "status", "data": self._last_status})
|
||||
result = await self._get_status_func()
|
||||
status = result.model_dump()
|
||||
self._last_status = status
|
||||
except Exception as e:
|
||||
logger.debug("Failed to fetch initial status: %s", e)
|
||||
if status:
|
||||
try:
|
||||
await websocket.send_json({"type": "status", "data": status})
|
||||
except Exception as e:
|
||||
logger.debug("Failed to send initial status: %s", e)
|
||||
|
||||
async def disconnect(self, websocket: WebSocket) -> None:
|
||||
"""Remove a WebSocket connection."""
|
||||
"""Remove a WebSocket connection. Stops audio capture if last visualizer subscriber."""
|
||||
should_stop = False
|
||||
async with self._lock:
|
||||
self._active_connections.discard(websocket)
|
||||
was_subscriber = websocket in self._visualizer_subscribers
|
||||
self._visualizer_subscribers.discard(websocket)
|
||||
if was_subscriber and len(self._visualizer_subscribers) == 0:
|
||||
should_stop = True
|
||||
if should_stop:
|
||||
await self._maybe_stop_capture()
|
||||
logger.info(
|
||||
"WebSocket client disconnected. Total: %d", len(self._active_connections)
|
||||
)
|
||||
@@ -83,6 +104,128 @@ class ConnectionManager:
|
||||
await self.broadcast(message)
|
||||
logger.info("Broadcast sent: links_changed")
|
||||
|
||||
async def subscribe_visualizer(self, websocket: WebSocket) -> None:
|
||||
"""Subscribe a client to audio visualizer data. Starts capture on first subscriber."""
|
||||
should_start = False
|
||||
async with self._lock:
|
||||
self._visualizer_subscribers.add(websocket)
|
||||
if len(self._visualizer_subscribers) == 1 and self._audio_analyzer:
|
||||
should_start = True
|
||||
if should_start:
|
||||
await self._maybe_start_capture()
|
||||
logger.debug("Visualizer subscriber added. Total: %d", len(self._visualizer_subscribers))
|
||||
|
||||
async def unsubscribe_visualizer(self, websocket: WebSocket) -> None:
|
||||
"""Unsubscribe a client from audio visualizer data. Stops capture on last subscriber."""
|
||||
should_stop = False
|
||||
async with self._lock:
|
||||
self._visualizer_subscribers.discard(websocket)
|
||||
if len(self._visualizer_subscribers) == 0:
|
||||
should_stop = True
|
||||
if should_stop:
|
||||
await self._maybe_stop_capture()
|
||||
logger.debug("Visualizer subscriber removed. Total: %d", len(self._visualizer_subscribers))
|
||||
|
||||
async def _maybe_start_capture(self) -> None:
|
||||
"""Start audio capture if not already running (called on first subscriber)."""
|
||||
if self._audio_analyzer and not self._audio_analyzer.running:
|
||||
loop = asyncio.get_event_loop()
|
||||
started = await loop.run_in_executor(None, self._audio_analyzer.start)
|
||||
if started:
|
||||
logger.info("Audio capture started (first subscriber)")
|
||||
else:
|
||||
logger.warning("Audio capture failed to start")
|
||||
|
||||
async def _maybe_stop_capture(self) -> None:
|
||||
"""Stop audio capture if running (called when last subscriber leaves)."""
|
||||
if self._audio_analyzer and self._audio_analyzer.running:
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, self._audio_analyzer.stop)
|
||||
logger.info("Audio capture stopped (no subscribers)")
|
||||
|
||||
async def start_audio_monitor(self, analyzer) -> None:
|
||||
"""Register the audio analyzer. Capture starts on-demand when clients subscribe."""
|
||||
self._audio_analyzer = analyzer
|
||||
if analyzer and analyzer.available:
|
||||
self._audio_task = asyncio.create_task(self._audio_broadcast_loop())
|
||||
logger.info("Audio visualizer broadcast loop started (capture on-demand)")
|
||||
|
||||
async def stop_audio_monitor(self) -> None:
|
||||
"""Stop audio frequency broadcasting."""
|
||||
if self._audio_task:
|
||||
self._audio_task.cancel()
|
||||
try:
|
||||
await self._audio_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._audio_task = None
|
||||
|
||||
async def _audio_broadcast_loop(self) -> None:
|
||||
"""Background loop: read frequency data from analyzer and broadcast to subscribers.
|
||||
|
||||
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)
|
||||
|
||||
analyzer = self._audio_analyzer
|
||||
if not subscribers or not analyzer or not analyzer.running:
|
||||
await asyncio.sleep(idle_interval)
|
||||
continue
|
||||
|
||||
# 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
|
||||
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=(',', ':'))
|
||||
|
||||
async def _send(ws: WebSocket) -> WebSocket | None:
|
||||
try:
|
||||
await ws.send_text(text)
|
||||
return None
|
||||
except Exception:
|
||||
return ws
|
||||
|
||||
results = await asyncio.gather(*(_send(ws) for ws in subscribers))
|
||||
|
||||
failed = [ws for ws in results if ws is not None]
|
||||
for ws in failed:
|
||||
await self.disconnect(ws)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error("Error in audio broadcast: %s", e)
|
||||
await asyncio.sleep(idle_interval)
|
||||
|
||||
def status_changed(
|
||||
self, old: dict[str, Any] | None, new: dict[str, Any]
|
||||
) -> bool:
|
||||
@@ -137,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)
|
||||
@@ -166,7 +310,7 @@ class ConnectionManager:
|
||||
has_clients = len(self._active_connections) > 0
|
||||
|
||||
if not has_clients:
|
||||
await asyncio.sleep(self._poll_interval)
|
||||
await asyncio.sleep(2.0) # Sleep longer when no clients connected
|
||||
continue
|
||||
|
||||
status = await get_status_func()
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
import time as _time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Optional, Any
|
||||
from typing import Any
|
||||
|
||||
from ..models import MediaState, MediaStatus
|
||||
from .media_controller import MediaController
|
||||
@@ -16,8 +18,10 @@ _executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="winrt")
|
||||
# Global storage for current album art (as bytes)
|
||||
_current_album_art_bytes: bytes | None = None
|
||||
|
||||
# Lock protecting _position_cache and _track_skip_pending from concurrent access
|
||||
_position_lock = threading.Lock()
|
||||
|
||||
# Global storage for position tracking
|
||||
import time as _time
|
||||
_position_cache = {
|
||||
"track_id": "",
|
||||
"base_position": 0.0,
|
||||
@@ -43,6 +47,8 @@ def get_current_album_art() -> bytes | None:
|
||||
try:
|
||||
from winsdk.windows.media.control import (
|
||||
GlobalSystemMediaTransportControlsSessionManager as MediaManager,
|
||||
)
|
||||
from winsdk.windows.media.control import (
|
||||
GlobalSystemMediaTransportControlsSessionPlaybackStatus as PlaybackStatus,
|
||||
)
|
||||
|
||||
@@ -57,11 +63,11 @@ _volume_control = None
|
||||
_configured_device_name: str | None = None
|
||||
|
||||
try:
|
||||
from ctypes import cast, POINTER
|
||||
from comtypes import CLSCTX_ALL, CoInitialize, CoUninitialize
|
||||
from pycaw.pycaw import AudioUtilities, IAudioEndpointVolume
|
||||
|
||||
import warnings
|
||||
from ctypes import POINTER, cast
|
||||
|
||||
from comtypes import CLSCTX_ALL
|
||||
from pycaw.pycaw import AudioUtilities, IAudioEndpointVolume
|
||||
# Suppress pycaw warnings about missing device properties
|
||||
warnings.filterwarnings("ignore", category=UserWarning, module="pycaw")
|
||||
|
||||
@@ -224,124 +230,138 @@ def _sync_get_media_status() -> dict[str, Any]:
|
||||
is_playing = result["state"] == "playing"
|
||||
current_title = result.get('title', '')
|
||||
|
||||
# Check if track skip is pending and title changed
|
||||
skip_just_completed = False
|
||||
if _track_skip_pending["active"]:
|
||||
if current_title and current_title != _track_skip_pending["old_title"]:
|
||||
# Title changed - clear the skip flag and start grace period
|
||||
_track_skip_pending["active"] = False
|
||||
_track_skip_pending["old_title"] = ""
|
||||
_track_skip_pending["grace_until"] = current_time + 300.0 # Long grace period
|
||||
_track_skip_pending["stale_pos"] = -999 # Reset stale position tracking
|
||||
skip_just_completed = True
|
||||
# Reset position cache for new track
|
||||
new_track_id = f"{current_title}:{result.get('artist', '')}:{result.get('duration', 0)}"
|
||||
_position_cache["track_id"] = new_track_id
|
||||
with _position_lock:
|
||||
# Check if track skip is pending and title changed
|
||||
skip_just_completed = False
|
||||
if _track_skip_pending["active"]:
|
||||
if current_title and current_title != _track_skip_pending["old_title"]:
|
||||
# Title changed - clear the skip flag and start grace period
|
||||
_track_skip_pending["active"] = False
|
||||
_track_skip_pending["old_title"] = ""
|
||||
_track_skip_pending["grace_until"] = current_time + 300.0 # Long grace period
|
||||
_track_skip_pending["stale_pos"] = -999 # Reset stale position tracking
|
||||
skip_just_completed = True
|
||||
# Reset position cache for new track
|
||||
new_track_id = (
|
||||
f"{current_title}:{result.get('artist', '')}:{result.get('duration', 0)}"
|
||||
)
|
||||
_position_cache["track_id"] = new_track_id
|
||||
_position_cache["base_position"] = 0.0
|
||||
_position_cache["base_time"] = current_time
|
||||
_position_cache["last_smtc_pos"] = -999 # Force fresh start
|
||||
_position_cache["is_playing"] = is_playing
|
||||
logger.debug(
|
||||
f"Track skip complete, new title: {current_title},"
|
||||
f" grace until: {_track_skip_pending['grace_until']}"
|
||||
)
|
||||
elif current_time - _track_skip_pending["skip_time"] > 5.0:
|
||||
# Timeout after 5 seconds
|
||||
_track_skip_pending["active"] = False
|
||||
logger.debug("Track skip timeout")
|
||||
|
||||
# Check if we're in grace period (after skip, ignore high SMTC positions)
|
||||
in_grace_period = current_time < _track_skip_pending.get("grace_until", 0)
|
||||
|
||||
# If track skip is pending or just completed, use cached/reset position
|
||||
if _track_skip_pending["active"]:
|
||||
pos = 0.0
|
||||
_position_cache["base_position"] = 0.0
|
||||
_position_cache["base_time"] = current_time
|
||||
_position_cache["last_smtc_pos"] = -999 # Force fresh start
|
||||
_position_cache["is_playing"] = is_playing
|
||||
logger.debug(f"Track skip complete, new title: {current_title}, grace until: {_track_skip_pending['grace_until']}")
|
||||
elif current_time - _track_skip_pending["skip_time"] > 5.0:
|
||||
# Timeout after 5 seconds
|
||||
_track_skip_pending["active"] = False
|
||||
logger.debug("Track skip timeout")
|
||||
elif skip_just_completed:
|
||||
# Just completed skip - interpolate from 0
|
||||
if is_playing:
|
||||
elapsed = current_time - _position_cache["base_time"]
|
||||
pos = elapsed
|
||||
else:
|
||||
pos = 0.0
|
||||
elif in_grace_period:
|
||||
# Grace period after track skip
|
||||
# SMTC position is stale (from old track) and won't update until seek/pause
|
||||
# We interpolate from 0 and only trust SMTC when it changes or reports low value
|
||||
|
||||
# Check if we're in grace period (after skip, ignore high SMTC positions)
|
||||
in_grace_period = current_time < _track_skip_pending.get("grace_until", 0)
|
||||
# Calculate interpolated position from start of new track
|
||||
if is_playing:
|
||||
elapsed = current_time - _position_cache.get("base_time", current_time)
|
||||
interpolated_pos = _position_cache.get("base_position", 0.0) + elapsed
|
||||
else:
|
||||
interpolated_pos = _position_cache.get("base_position", 0.0)
|
||||
|
||||
# If track skip is pending or just completed, use cached/reset position
|
||||
if _track_skip_pending["active"]:
|
||||
pos = 0.0
|
||||
_position_cache["base_position"] = 0.0
|
||||
_position_cache["base_time"] = current_time
|
||||
_position_cache["is_playing"] = is_playing
|
||||
elif skip_just_completed:
|
||||
# Just completed skip - interpolate from 0
|
||||
if is_playing:
|
||||
elapsed = current_time - _position_cache["base_time"]
|
||||
pos = elapsed
|
||||
# Get the stale position we've been tracking
|
||||
stale_pos = _track_skip_pending.get("stale_pos", -999)
|
||||
|
||||
# Detect if SMTC position changed significantly from the stale value (user seeked)
|
||||
smtc_changed = stale_pos >= 0 and abs(smtc_pos - stale_pos) > 3.0
|
||||
|
||||
# Trust SMTC if:
|
||||
# 1. It reports a low position (indicating new track started)
|
||||
# 2. It changed from the stale value (user seeked)
|
||||
if smtc_pos < 10.0 or smtc_changed:
|
||||
# SMTC is now trustworthy
|
||||
_position_cache["base_position"] = smtc_pos
|
||||
_position_cache["base_time"] = current_time
|
||||
_position_cache["last_smtc_pos"] = smtc_pos
|
||||
_position_cache["is_playing"] = is_playing
|
||||
pos = smtc_pos
|
||||
_track_skip_pending["grace_until"] = 0
|
||||
_track_skip_pending["stale_pos"] = -999
|
||||
logger.debug(
|
||||
f"Grace period: accepting SMTC pos {smtc_pos}"
|
||||
f" (low={smtc_pos < 10}, changed={smtc_changed})"
|
||||
)
|
||||
else:
|
||||
# SMTC is stale - keep interpolating
|
||||
pos = interpolated_pos
|
||||
# Record the stale position for change detection
|
||||
if stale_pos < 0:
|
||||
_track_skip_pending["stale_pos"] = smtc_pos
|
||||
# Keep grace period active indefinitely while SMTC is stale
|
||||
_track_skip_pending["grace_until"] = current_time + 300.0
|
||||
logger.debug(
|
||||
f"Grace period: SMTC stale ({smtc_pos}),"
|
||||
f" using interpolated {interpolated_pos}"
|
||||
)
|
||||
else:
|
||||
pos = 0.0
|
||||
elif in_grace_period:
|
||||
# Grace period after track skip
|
||||
# SMTC position is stale (from old track) and won't update until seek/pause
|
||||
# We interpolate from 0 and only trust SMTC when it changes or reports low value
|
||||
# Normal position tracking
|
||||
# Create track ID from title + artist + duration
|
||||
track_id = f"{current_title}:{result.get('artist', '')}:{result.get('duration', 0)}"
|
||||
|
||||
# Calculate interpolated position from start of new track
|
||||
if is_playing:
|
||||
elapsed = current_time - _position_cache.get("base_time", current_time)
|
||||
interpolated_pos = _position_cache.get("base_position", 0.0) + elapsed
|
||||
else:
|
||||
interpolated_pos = _position_cache.get("base_position", 0.0)
|
||||
# Detect if SMTC position changed (new track, seek, or state change)
|
||||
smtc_pos_changed = abs(smtc_pos - _position_cache.get("last_smtc_pos", -999)) > 0.5
|
||||
track_changed = track_id != _position_cache.get("track_id", "")
|
||||
|
||||
# Get the stale position we've been tracking
|
||||
stale_pos = _track_skip_pending.get("stale_pos", -999)
|
||||
if smtc_pos_changed or track_changed:
|
||||
# SMTC updated - store new baseline
|
||||
_position_cache["track_id"] = track_id
|
||||
_position_cache["last_smtc_pos"] = smtc_pos
|
||||
_position_cache["base_position"] = smtc_pos
|
||||
_position_cache["base_time"] = current_time
|
||||
_position_cache["is_playing"] = is_playing
|
||||
pos = smtc_pos
|
||||
elif is_playing:
|
||||
# Interpolate position based on elapsed time
|
||||
elapsed = current_time - _position_cache.get("base_time", current_time)
|
||||
pos = _position_cache.get("base_position", smtc_pos) + elapsed
|
||||
else:
|
||||
# Paused - use base position
|
||||
pos = _position_cache.get("base_position", smtc_pos)
|
||||
|
||||
# Detect if SMTC position changed significantly from the stale value (user seeked)
|
||||
smtc_changed = stale_pos >= 0 and abs(smtc_pos - stale_pos) > 3.0
|
||||
# Update playing state
|
||||
if _position_cache.get("is_playing") != is_playing:
|
||||
_position_cache["base_position"] = (
|
||||
pos if is_playing else _position_cache.get("base_position", smtc_pos)
|
||||
)
|
||||
_position_cache["base_time"] = current_time
|
||||
_position_cache["is_playing"] = is_playing
|
||||
|
||||
# Trust SMTC if:
|
||||
# 1. It reports a low position (indicating new track started)
|
||||
# 2. It changed from the stale value (user seeked)
|
||||
if smtc_pos < 10.0 or smtc_changed:
|
||||
# SMTC is now trustworthy
|
||||
_position_cache["base_position"] = smtc_pos
|
||||
_position_cache["base_time"] = current_time
|
||||
_position_cache["last_smtc_pos"] = smtc_pos
|
||||
_position_cache["is_playing"] = is_playing
|
||||
pos = smtc_pos
|
||||
_track_skip_pending["grace_until"] = 0
|
||||
_track_skip_pending["stale_pos"] = -999
|
||||
logger.debug(f"Grace period: accepting SMTC pos {smtc_pos} (low={smtc_pos < 10}, changed={smtc_changed})")
|
||||
else:
|
||||
# SMTC is stale - keep interpolating
|
||||
pos = interpolated_pos
|
||||
# Record the stale position for change detection
|
||||
if stale_pos < 0:
|
||||
_track_skip_pending["stale_pos"] = smtc_pos
|
||||
# Keep grace period active indefinitely while SMTC is stale
|
||||
_track_skip_pending["grace_until"] = current_time + 300.0
|
||||
logger.debug(f"Grace period: SMTC stale ({smtc_pos}), using interpolated {interpolated_pos}")
|
||||
else:
|
||||
# Normal position tracking
|
||||
# Create track ID from title + artist + duration
|
||||
track_id = f"{current_title}:{result.get('artist', '')}:{result.get('duration', 0)}"
|
||||
|
||||
# Detect if SMTC position changed (new track, seek, or state change)
|
||||
smtc_pos_changed = abs(smtc_pos - _position_cache.get("last_smtc_pos", -999)) > 0.5
|
||||
track_changed = track_id != _position_cache.get("track_id", "")
|
||||
|
||||
if smtc_pos_changed or track_changed:
|
||||
# SMTC updated - store new baseline
|
||||
_position_cache["track_id"] = track_id
|
||||
_position_cache["last_smtc_pos"] = smtc_pos
|
||||
_position_cache["base_position"] = smtc_pos
|
||||
_position_cache["base_time"] = current_time
|
||||
_position_cache["is_playing"] = is_playing
|
||||
pos = smtc_pos
|
||||
elif is_playing:
|
||||
# Interpolate position based on elapsed time
|
||||
elapsed = current_time - _position_cache.get("base_time", current_time)
|
||||
pos = _position_cache.get("base_position", smtc_pos) + elapsed
|
||||
else:
|
||||
# Paused - use base position
|
||||
pos = _position_cache.get("base_position", smtc_pos)
|
||||
|
||||
# Update playing state
|
||||
if _position_cache.get("is_playing") != is_playing:
|
||||
_position_cache["base_position"] = pos if is_playing else _position_cache.get("base_position", smtc_pos)
|
||||
_position_cache["base_time"] = current_time
|
||||
_position_cache["is_playing"] = is_playing
|
||||
|
||||
# Sanity check: position should be non-negative and <= duration
|
||||
if pos >= 0:
|
||||
if result["duration"] and pos <= result["duration"]:
|
||||
result["position"] = pos
|
||||
elif result["duration"] and pos > result["duration"]:
|
||||
result["position"] = result["duration"]
|
||||
elif not result["duration"]:
|
||||
result["position"] = pos
|
||||
# Sanity check: position should be non-negative and <= duration
|
||||
if pos >= 0:
|
||||
if result["duration"] and pos <= result["duration"]:
|
||||
result["position"] = pos
|
||||
elif result["duration"] and pos > result["duration"]:
|
||||
result["position"] = result["duration"]
|
||||
elif not result["duration"]:
|
||||
result["position"] = pos
|
||||
|
||||
logger.debug(f"Timeline: duration={result['duration']}, position={result['position']}")
|
||||
except Exception as e:
|
||||
@@ -483,6 +503,11 @@ def _sync_seek(position: float) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def shutdown_executor() -> None:
|
||||
"""Shut down the WinRT thread pool executor."""
|
||||
_executor.shutdown(wait=False)
|
||||
|
||||
|
||||
class WindowsMediaController(MediaController):
|
||||
"""Media controller for Windows using WinRT and pycaw."""
|
||||
|
||||
@@ -602,10 +627,10 @@ class WindowsMediaController(MediaController):
|
||||
|
||||
result = await self._run_command("next")
|
||||
if result:
|
||||
# Set flag to force position to 0 until title changes
|
||||
_track_skip_pending["active"] = True
|
||||
_track_skip_pending["old_title"] = old_title
|
||||
_track_skip_pending["skip_time"] = _time.time()
|
||||
with _position_lock:
|
||||
_track_skip_pending["active"] = True
|
||||
_track_skip_pending["old_title"] = old_title
|
||||
_track_skip_pending["skip_time"] = _time.time()
|
||||
logger.debug(f"Track skip initiated, old title: {old_title}")
|
||||
return result
|
||||
|
||||
@@ -620,10 +645,10 @@ class WindowsMediaController(MediaController):
|
||||
|
||||
result = await self._run_command("previous")
|
||||
if result:
|
||||
# Set flag to force position to 0 until title changes
|
||||
_track_skip_pending["active"] = True
|
||||
_track_skip_pending["old_title"] = old_title
|
||||
_track_skip_pending["skip_time"] = _time.time()
|
||||
with _position_lock:
|
||||
_track_skip_pending["active"] = True
|
||||
_track_skip_pending["old_title"] = old_title
|
||||
_track_skip_pending["skip_time"] = _time.time()
|
||||
logger.debug(f"Track skip initiated, old title: {old_title}")
|
||||
return result
|
||||
|
||||
|
||||
+6787
-350
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.
Binary file not shown.
|
After Width: | Height: | Size: 208 B |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<defs>
|
||||
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#1db954;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#1ed760;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="50" cy="50" r="45" fill="url(#grad)"/>
|
||||
<path fill="white" d="M35 25 L35 75 L75 50 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 421 B |
+407
-192
@@ -2,9 +2,17 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<title>Media Server</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Cdefs%3E%3ClinearGradient id='grad' x1='0%25' y1='0%25' x2='100%25' y2='100%25'%3E%3Cstop offset='0%25' style='stop-color:%231db954;stop-opacity:1' /%3E%3Cstop offset='100%25' style='stop-color:%231ed760;stop-opacity:1' /%3E%3C/linearGradient%3E%3C/defs%3E%3Ccircle cx='50' cy='50' r='45' fill='url(%23grad)'/%3E%3Cpath fill='white' d='M35 25 L35 75 L75 50 Z'/%3E%3C/svg%3E">
|
||||
<meta name="description" content="Remote media player control and file browser">
|
||||
<meta name="theme-color" content="#121212">
|
||||
<meta name="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">
|
||||
<link rel="manifest" href="/static/manifest.json">
|
||||
<link rel="icon" type="image/svg+xml" href="/static/icons/icon.svg">
|
||||
<link rel="apple-touch-icon" href="/static/icons/icon.svg">
|
||||
<link rel="stylesheet" href="/static/css/styles.css">
|
||||
</head>
|
||||
<body class="loading-translations">
|
||||
@@ -18,11 +26,17 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="mini-controls">
|
||||
<button class="mini-control-btn mini-nav-btn" onclick="previousTrack()" data-i18n-title="player.previous" title="Previous">
|
||||
<svg viewBox="0 0 24 24"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
|
||||
</button>
|
||||
<button class="mini-control-btn" onclick="togglePlayPause()" id="mini-btn-play-pause" title="Play/Pause">
|
||||
<svg viewBox="0 0 24 24" id="mini-play-pause-icon">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="mini-control-btn mini-nav-btn" onclick="nextTrack()" data-i18n-title="player.next" title="Next">
|
||||
<svg viewBox="0 0 24 24"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mini-progress-container">
|
||||
<div class="mini-time-display">
|
||||
@@ -44,6 +58,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dynamic Background -->
|
||||
<canvas id="bg-shader-canvas" class="bg-shader-canvas"></canvas>
|
||||
|
||||
<!-- Auth Modal -->
|
||||
<div id="auth-overlay" class="hidden">
|
||||
<div class="auth-modal">
|
||||
@@ -59,21 +76,38 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Folio marks at page corners -->
|
||||
<span class="folio tl"><span class="status-dot" id="status-dot" aria-live="polite"></span><span data-i18n="header.connected">Connected</span> · <span id="folio-host">Local 8765</span></span>
|
||||
<span class="folio tr"><span data-i18n="header.volume">Vol. I</span> — <span data-i18n="header.edition">Studio Reference</span> · <span id="version-label"></span></span>
|
||||
|
||||
<div class="container">
|
||||
<header>
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<span class="status-dot" id="status-dot" aria-live="polite"></span>
|
||||
<span class="version-label" id="version-label"></span>
|
||||
<div class="brand">
|
||||
<span class="brand-name" data-i18n="app.title">Media Server</span>
|
||||
<span class="brand-sub" data-i18n="header.edition_sub">Studio Reference Edition</span>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<div class="header-toolbar">
|
||||
<div id="headerLinks" class="header-links"></div>
|
||||
<a class="header-btn" href="/docs" target="_blank" title="API Documentation" aria-label="API Documentation">
|
||||
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm-1 2l5 5h-5V4zM6 20V4h5v7h7v9H6zm2-4h8v2H8v-2zm0-3h8v2H8v-2z"/></svg>
|
||||
</a>
|
||||
<button class="header-btn" onclick="showAboutDialog()" data-i18n-title="about.button_title" title="About" aria-label="About">
|
||||
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M11 7h2v2h-2V7zm0 4h2v6h-2v-6zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/></svg>
|
||||
</button>
|
||||
<div class="accent-picker">
|
||||
<button class="accent-picker-btn" onclick="toggleAccentPicker()" title="Accent color">
|
||||
<button class="header-btn" onclick="toggleAccentPicker()" title="Accent color" aria-label="Accent color">
|
||||
<span class="accent-dot" id="accentDot"></span>
|
||||
</button>
|
||||
<div class="accent-picker-dropdown" id="accentDropdown"></div>
|
||||
</div>
|
||||
<button class="theme-toggle" onclick="toggleTheme()" data-i18n-title="player.theme" title="Toggle theme" id="theme-toggle">
|
||||
<button class="header-btn" onclick="toggleDynamicBackground()" data-i18n-title="player.background" title="Dynamic background" aria-label="Dynamic background" id="bgToggle">
|
||||
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M12 3v10.55A4 4 0 1 0 14 17V7h4V3h-6z"/></svg>
|
||||
</button>
|
||||
<button class="header-btn" onclick="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"/>
|
||||
</svg>
|
||||
@@ -81,119 +115,198 @@
|
||||
<path d="M9 2c-1.05 0-2.05.16-3 .46 4.06 1.27 7 5.06 7 9.54 0 4.48-2.94 8.27-7 9.54.95.3 1.95.46 3 .46 5.52 0 10-4.48 10-10S14.52 2 9 2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<select id="locale-select" onchange="changeLocale()" title="Change language">
|
||||
<option value="en">English</option>
|
||||
<option value="ru">Русский</option>
|
||||
<select id="locale-select" class="header-locale" onchange="changeLocale()" title="Change language">
|
||||
<option value="en">EN</option>
|
||||
<option value="ru">RU</option>
|
||||
</select>
|
||||
<button class="clear-token-btn" onclick="clearToken()" data-i18n-title="auth.logout.title" data-i18n="auth.logout" title="Clear saved token">Logout</button>
|
||||
<span class="header-toolbar-sep"></span>
|
||||
<button class="header-btn header-btn-logout" onclick="clearToken()" data-i18n-title="auth.logout.title" title="Clear saved token" aria-label="Logout">
|
||||
<svg viewBox="0 0 24 24"><path d="M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5zM4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4V5z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Update Banner -->
|
||||
<div class="update-banner hidden" id="updateBanner">
|
||||
<span id="updateBannerText"></span>
|
||||
<a id="updateBannerLink" href="#" target="_blank" rel="noopener noreferrer" data-i18n="update.view_release">View Release</a>
|
||||
<button class="update-banner-close" id="updateBannerClose">×</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 data-i18n="tab.quick_actions">Actions</span>
|
||||
<span class="tab-num">04</span>
|
||||
<span data-i18n="tab.quick_access">Quick Access</span>
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="scripts" onclick="switchTab('scripts')" role="tab" aria-selected="false" aria-controls="panel-scripts" tabindex="-1">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z"/></svg>
|
||||
<span data-i18n="tab.scripts">Scripts</span>
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="callbacks" onclick="switchTab('callbacks')" role="tab" aria-selected="false" aria-controls="panel-callbacks" tabindex="-1">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>
|
||||
<span data-i18n="tab.callbacks">Callbacks</span>
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="links" onclick="switchTab('links')" role="tab" aria-selected="false" aria-controls="panel-links" tabindex="-1">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>
|
||||
<span data-i18n="tab.links">Links</span>
|
||||
<button class="tab-btn" data-tab="settings" onclick="switchTab('settings')" role="tab" aria-selected="false" aria-controls="panel-settings" tabindex="-1">
|
||||
<span class="tab-num">05</span>
|
||||
<span data-i18n="tab.settings">Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="player-container" data-tab-content="player" role="tabpanel" id="panel-player">
|
||||
<div class="player-layout">
|
||||
<div class="album-art-container">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-container">
|
||||
<div class="time-display">
|
||||
<span id="current-time">0:00</span>
|
||||
<span 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 data-i18n="player.source">Source:</span> <span id="source" data-i18n="player.unknown_source">Unknown</span>
|
||||
<button class="vinyl-toggle-btn" onclick="toggleVinylMode()" id="vinylToggle" data-i18n-title="player.vinyl" title="Vinyl mode">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="12" cy="12" r="3" fill="currentColor"/><circle cx="12" cy="12" r="6.5" fill="none" stroke="currentColor" stroke-width="0.5" opacity="0.5"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<!-- 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">
|
||||
<div class="sleeve">
|
||||
<img id="album-art" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Cpath fill='%236a6a6a' opacity='0.35' d='M150 80c-38.66 0-70 31.34-70 70s31.34 70 70 70 70-31.34 70-70-31.34-70-70-70zm0 20c27.614 0 50 22.386 50 50s-22.386 50-50 50-50-22.386-50-50 22.386-50 50-50zm0 30a20 20 0 100 40 20 20 0 000-40z'/%3E%3C/svg%3E" alt="Album Art">
|
||||
<div class="sleeve-grain" aria-hidden="true"></div>
|
||||
<div class="sleeve-corner" aria-hidden="true"></div>
|
||||
</div>
|
||||
<div class="vinyl-wrap">
|
||||
<div class="vinyl">
|
||||
<div class="vinyl-label">
|
||||
<!-- Stylised record-label catalogue mark, not user-facing
|
||||
copy — intentionally not in the i18n bundle. -->
|
||||
<span class="vinyl-label-text">REF · 24</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<svg class="tonearm" viewBox="0 0 200 200" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="armGrad" x1="0" x2="1">
|
||||
<stop offset="0" stop-color="#6d5f44"/>
|
||||
<stop offset="0.5" stop-color="#d8c39a"/>
|
||||
<stop offset="1" stop-color="#8a7a5a"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="176" cy="24" r="14" fill="#2a241c" stroke="#9C835A" stroke-width="1.5"/>
|
||||
<circle cx="176" cy="24" r="6" fill="#5C5447"/>
|
||||
<circle cx="176" cy="24" r="2.5" fill="#E08038"/>
|
||||
<line x1="176" y1="24" x2="64" y2="136" stroke="url(#armGrad)" stroke-width="5" stroke-linecap="round"/>
|
||||
<rect x="180" y="14" width="14" height="20" fill="#3A3528" stroke="#9C835A" stroke-width="1"/>
|
||||
<rect x="56" y="128" width="22" height="18" rx="2" fill="#3A3528" stroke="#9C835A" stroke-width="1" transform="rotate(-45 67 137)"/>
|
||||
<circle cx="62" cy="138" r="3.5" fill="#E08038" opacity="0.95"/>
|
||||
<circle cx="62" cy="138" r="7" fill="none" stroke="#E08038" stroke-width="0.8" opacity="0.5"/>
|
||||
</svg>
|
||||
<canvas id="spectrogram-canvas" class="spectrogram-canvas" width="300" height="64"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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="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="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>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Media Browser Section -->
|
||||
@@ -260,6 +373,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>
|
||||
|
||||
@@ -268,100 +382,157 @@
|
||||
<div class="scripts-grid" id="scripts-grid">
|
||||
<div class="scripts-empty empty-state-illustration">
|
||||
<svg viewBox="0 0 64 64"><path d="M20 8l-8 48"/><path d="M44 8l8 48"/><path d="M10 24h44"/><path d="M8 40h44"/></svg>
|
||||
<p data-i18n="scripts.no_scripts">No scripts configured</p>
|
||||
</div>
|
||||
<div class="script-btn add-card-grid" onclick="showAddScriptDialog()">
|
||||
<span class="add-card-icon">+</span>
|
||||
<p data-i18n="quick_access.no_items">No quick actions or links configured</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Script Management Section -->
|
||||
<div class="script-management" data-tab-content="scripts" role="tabpanel" id="panel-scripts">
|
||||
<table class="scripts-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="scripts.table.name">Name</th>
|
||||
<th data-i18n="scripts.table.label">Label</th>
|
||||
<th data-i18n="scripts.table.command">Command</th>
|
||||
<th data-i18n="scripts.table.timeout">Timeout</th>
|
||||
<th data-i18n="scripts.table.actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="scriptsTableBody">
|
||||
<tr>
|
||||
<td colspan="5" class="empty-state">
|
||||
<div class="empty-state-illustration">
|
||||
<svg viewBox="0 0 64 64"><rect x="8" y="4" width="48" height="56" rx="4"/><path d="M20 20h24M20 32h16M20 44h20"/></svg>
|
||||
<p data-i18n="scripts.empty">No scripts configured. Click "Add" to create one.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="add-card" onclick="showAddScriptDialog()">
|
||||
<span class="add-card-icon">+</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Settings Section (Scripts, Callbacks, Links management) -->
|
||||
<div class="settings-container" data-tab-content="settings" role="tabpanel" id="panel-settings">
|
||||
<details class="settings-section" open id="audioDeviceSection" style="display: none;">
|
||||
<summary data-i18n="settings.section.audio">Audio</summary>
|
||||
<div class="settings-section-content">
|
||||
<p class="settings-section-description" data-i18n="settings.audio.description">
|
||||
Select which audio output device to capture for the visualizer.
|
||||
</p>
|
||||
<div class="audio-device-selector">
|
||||
<label>
|
||||
<span data-i18n="settings.audio.device">Loopback Device</span>
|
||||
<select id="audioDeviceSelect" onchange="onAudioDeviceChanged()">
|
||||
<option value="" data-i18n="settings.audio.auto">Auto-detect</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="audio-device-status" id="audioDeviceStatus"></div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Callback Management Section -->
|
||||
<div class="script-management" data-tab-content="callbacks" role="tabpanel" id="panel-callbacks">
|
||||
<p style="color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 1rem;" data-i18n="callbacks.description">
|
||||
Callbacks are scripts triggered automatically by media control events (play, pause, stop, etc.)
|
||||
</p>
|
||||
<table class="scripts-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="callbacks.table.event">Event</th>
|
||||
<th data-i18n="callbacks.table.command">Command</th>
|
||||
<th data-i18n="callbacks.table.timeout">Timeout</th>
|
||||
<th data-i18n="callbacks.table.actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="callbacksTableBody">
|
||||
<tr>
|
||||
<td colspan="4" class="empty-state">
|
||||
<div class="empty-state-illustration">
|
||||
<svg viewBox="0 0 64 64"><circle cx="32" cy="32" r="24"/><path d="M32 20v12l8 8"/></svg>
|
||||
<p>No callbacks configured. Click "Add" to create one.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="add-card" onclick="showAddCallbackDialog()">
|
||||
<span class="add-card-icon">+</span>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<!-- Links Management Section -->
|
||||
<div class="script-management" data-tab-content="links" role="tabpanel" id="panel-links">
|
||||
<p style="color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 1rem;" data-i18n="links.description">
|
||||
Quick links displayed as icons in the header bar. Click an icon to open the URL in a new tab.
|
||||
</p>
|
||||
<table class="scripts-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="links.table.name">Name</th>
|
||||
<th data-i18n="links.table.url">URL</th>
|
||||
<th data-i18n="links.table.label">Label</th>
|
||||
<th data-i18n="links.table.actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="linksTableBody">
|
||||
<tr>
|
||||
<td colspan="4" class="empty-state">
|
||||
<div class="empty-state-illustration">
|
||||
<svg viewBox="0 0 64 64"><path d="M26 20a10 10 0 010 14l-6 6a10 10 0 01-14-14l6-6a10 10 0 0114 0" fill="none" stroke="currentColor" stroke-width="2"/><path d="M38 44a10 10 0 010-14l6-6a10 10 0 0114 14l-6 6a10 10 0 01-14 0" fill="none" stroke="currentColor" stroke-width="2"/><path d="M24 40l16-16" stroke="currentColor" stroke-width="2"/></svg>
|
||||
<p data-i18n="links.empty">No links configured. Click "Add" to create one.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="add-card" onclick="showAddLinkDialog()">
|
||||
<span class="add-card-icon">+</span>
|
||||
</div>
|
||||
<details class="settings-section" open>
|
||||
<summary data-i18n="settings.section.scripts">Scripts</summary>
|
||||
<div class="settings-section-content">
|
||||
<table class="scripts-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="scripts.table.name">Name</th>
|
||||
<th data-i18n="scripts.table.label">Label</th>
|
||||
<th data-i18n="scripts.table.command">Command</th>
|
||||
<th data-i18n="scripts.table.timeout">Timeout</th>
|
||||
<th data-i18n="scripts.table.actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="scriptsTableBody">
|
||||
<tr>
|
||||
<td colspan="5" class="empty-state">
|
||||
<div class="empty-state-illustration">
|
||||
<svg viewBox="0 0 64 64"><rect x="8" y="4" width="48" height="56" rx="4"/><path d="M20 20h24M20 32h16M20 44h20"/></svg>
|
||||
<p data-i18n="scripts.empty">No scripts configured. Click "Add" to create one.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="add-card" onclick="showAddScriptDialog()">
|
||||
<span class="add-card-icon">+</span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="settings-section" open>
|
||||
<summary data-i18n="settings.section.links">Links</summary>
|
||||
<div class="settings-section-content">
|
||||
<p class="settings-section-description" data-i18n="links.description">
|
||||
Quick links displayed as icons in the header bar. Click an icon to open the URL in a new tab.
|
||||
</p>
|
||||
<table class="scripts-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="links.table.name">Name</th>
|
||||
<th data-i18n="links.table.url">URL</th>
|
||||
<th data-i18n="links.table.label">Label</th>
|
||||
<th data-i18n="links.table.actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="linksTableBody">
|
||||
<tr>
|
||||
<td colspan="4" class="empty-state">
|
||||
<div class="empty-state-illustration">
|
||||
<svg viewBox="0 0 64 64"><path d="M26 20a10 10 0 010 14l-6 6a10 10 0 01-14-14l6-6a10 10 0 0114 0" fill="none" stroke="currentColor" stroke-width="2"/><path d="M38 44a10 10 0 010-14l6-6a10 10 0 0114 14l-6 6a10 10 0 01-14 0" fill="none" stroke="currentColor" stroke-width="2"/><path d="M24 40l16-16" stroke="currentColor" stroke-width="2"/></svg>
|
||||
<p data-i18n="links.empty">No links configured. Click "Add" to create one.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="add-card" onclick="showAddLinkDialog()">
|
||||
<span class="add-card-icon">+</span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="settings-section" open>
|
||||
<summary data-i18n="settings.section.callbacks">Callbacks</summary>
|
||||
<div class="settings-section-content">
|
||||
<p class="settings-section-description" data-i18n="callbacks.description">
|
||||
Callbacks are scripts triggered automatically by media control events (play, pause, stop, etc.)
|
||||
</p>
|
||||
<table class="scripts-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="callbacks.table.event">Event</th>
|
||||
<th data-i18n="callbacks.table.command">Command</th>
|
||||
<th data-i18n="callbacks.table.timeout">Timeout</th>
|
||||
<th data-i18n="callbacks.table.actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="callbacksTableBody">
|
||||
<tr>
|
||||
<td colspan="4" class="empty-state">
|
||||
<div class="empty-state-illustration">
|
||||
<svg viewBox="0 0 64 64"><circle cx="32" cy="32" r="24"/><path d="M32 20v12l8 8"/></svg>
|
||||
<p>No callbacks configured. Click "Add" to create one.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="add-card" onclick="showAddCallbackDialog()">
|
||||
<span class="add-card-icon">+</span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Display Control Section -->
|
||||
@@ -418,6 +589,14 @@
|
||||
<span data-i18n="scripts.field.timeout">Timeout (seconds)</span>
|
||||
<input type="number" id="scriptTimeout" value="30" min="1" max="300">
|
||||
</label>
|
||||
|
||||
<div class="params-section">
|
||||
<div class="params-header">
|
||||
<span data-i18n="scripts.field.parameters">Parameters</span>
|
||||
<button type="button" class="btn-small" onclick="addParameterRow()" data-i18n="scripts.params.add">+ Add</button>
|
||||
</div>
|
||||
<div id="scriptParamsContainer"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button type="button" class="btn-secondary" onclick="closeScriptDialog()" data-i18n="scripts.button.cancel">Cancel</button>
|
||||
@@ -426,6 +605,22 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Script Parameters Input Dialog (shown before executing scripts with params) -->
|
||||
<dialog id="scriptParamsDialog">
|
||||
<div class="dialog-header">
|
||||
<h3 id="scriptParamsDialogTitle">Execute Script</h3>
|
||||
</div>
|
||||
<form id="scriptParamsForm" onsubmit="submitScriptWithParams(event)">
|
||||
<div class="dialog-body">
|
||||
<div id="scriptParamsInputs"></div>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button type="button" class="btn-secondary" onclick="closeScriptParamsDialog()" data-i18n="scripts.button.cancel">Cancel</button>
|
||||
<button type="submit" class="btn-primary" data-i18n="scripts.params.execute">Execute</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Add/Edit Callback Dialog -->
|
||||
<dialog id="callbackDialog">
|
||||
<div class="dialog-header">
|
||||
@@ -508,6 +703,11 @@
|
||||
<span data-i18n="links.field.label">Label</span>
|
||||
<input type="text" id="linkLabel" data-i18n-placeholder="links.placeholder.label" placeholder="Tooltip text">
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span data-i18n="links.field.description">Description</span>
|
||||
<textarea id="linkDescription" data-i18n-placeholder="links.placeholder.description" placeholder="What does this link point to?"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button type="button" class="btn-secondary" onclick="closeLinkDialog()" data-i18n="links.button.cancel">Cancel</button>
|
||||
@@ -591,17 +791,32 @@
|
||||
<!-- Toast Notifications -->
|
||||
<div class="toast-container" id="toast-container"></div>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer>
|
||||
<div>
|
||||
<span data-i18n="footer.created_by">Created by</span> <strong>Alexei Dolgolyov</strong>
|
||||
<span class="separator">•</span>
|
||||
<a href="mailto:dolgolyov.alexei@gmail.com">dolgolyov.alexei@gmail.com</a>
|
||||
<span class="separator">•</span>
|
||||
<a href="https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server" target="_blank" rel="noopener noreferrer" data-i18n="footer.source_code">Source Code</a>
|
||||
<!-- About Dialog -->
|
||||
<dialog id="aboutDialog" class="about-dialog">
|
||||
<div class="dialog-header">
|
||||
<h3 data-i18n="about.title">About</h3>
|
||||
</div>
|
||||
</footer>
|
||||
<div class="dialog-body">
|
||||
<p class="about-credit">
|
||||
<span data-i18n="about.created_by">Created by</span>
|
||||
<strong>Alexei Dolgolyov</strong>
|
||||
</p>
|
||||
<ul class="about-links">
|
||||
<li>
|
||||
<span class="about-links-label" data-i18n="about.email">Email</span>
|
||||
<a href="mailto:dolgolyov.alexei@gmail.com">dolgolyov.alexei@gmail.com</a>
|
||||
</li>
|
||||
<li>
|
||||
<span class="about-links-label" data-i18n="about.repository">Repository</span>
|
||||
<a href="https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server" target="_blank" rel="noopener noreferrer" data-i18n="about.source_code">Source Code</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button type="button" class="btn-secondary" onclick="closeAboutDialog()" data-i18n="dialog.close">Close</button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script src="/static/js/app.js"></script>
|
||||
<script src="/static/dist/app.bundle.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+496
-3280
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,342 @@
|
||||
// ============================================================
|
||||
// Background: WebGL shader-based dynamic background
|
||||
// ============================================================
|
||||
|
||||
import { frequencyData } from './player.js';
|
||||
|
||||
let bgCanvas = null;
|
||||
let bgGL = null;
|
||||
let bgProgram = null;
|
||||
let bgUniforms = null; // Cached uniform locations
|
||||
let bgAnimFrame = null;
|
||||
let bgEnabled = localStorage.getItem('dynamicBackground') === 'true';
|
||||
let bgStartTime = 0;
|
||||
let bgSmoothedBands = new Float32Array(16);
|
||||
let bgSmoothedBass = 0;
|
||||
let bgAccentRGB = [0.114, 0.725, 0.329]; // Cached accent color (default green)
|
||||
let bgBgColorRGB = [0.071, 0.071, 0.071]; // Cached page background (#121212)
|
||||
|
||||
const BG_BAND_COUNT = 16;
|
||||
const BG_SMOOTHING = 0.12;
|
||||
|
||||
// ---- Shaders ----
|
||||
|
||||
const BG_VERT_SRC = `
|
||||
attribute vec2 a_position;
|
||||
void main() {
|
||||
gl_Position = vec4(a_position, 0.0, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const BG_FRAG_SRC = `
|
||||
precision mediump float;
|
||||
|
||||
uniform vec2 u_resolution;
|
||||
uniform float u_time;
|
||||
uniform float u_bass;
|
||||
uniform float u_bands[16];
|
||||
uniform vec3 u_accent;
|
||||
uniform vec3 u_bgColor;
|
||||
|
||||
// Smooth noise
|
||||
float hash(vec2 p) {
|
||||
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
|
||||
}
|
||||
|
||||
float noise(vec2 p) {
|
||||
vec2 i = floor(p);
|
||||
vec2 f = fract(p);
|
||||
f = f * f * (3.0 - 2.0 * f);
|
||||
float a = hash(i);
|
||||
float b = hash(i + vec2(1.0, 0.0));
|
||||
float c = hash(i + vec2(0.0, 1.0));
|
||||
float d = hash(i + vec2(1.0, 1.0));
|
||||
return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 uv = gl_FragCoord.xy / u_resolution;
|
||||
float aspect = u_resolution.x / u_resolution.y;
|
||||
|
||||
// Center coordinates for radial effects
|
||||
vec2 center = (uv - 0.5) * vec2(aspect, 1.0);
|
||||
float dist = length(center);
|
||||
float angle = atan(center.y, center.x);
|
||||
|
||||
// Slow base animation
|
||||
float t = u_time * 0.15;
|
||||
|
||||
// === Layer 1: Flowing wave field ===
|
||||
float waves = 0.0;
|
||||
for (int i = 0; i < 5; i++) {
|
||||
float fi = float(i);
|
||||
float freq = 1.5 + fi * 0.8;
|
||||
float speed = t * (0.6 + fi * 0.15);
|
||||
// Sample a band for this wave layer
|
||||
int bandIdx = i * 3;
|
||||
float bandVal = 0.0;
|
||||
// Manual indexing (GLSL ES doesn't allow variable array index in some drivers)
|
||||
for (int j = 0; j < 16; j++) {
|
||||
if (j == bandIdx) bandVal = u_bands[j];
|
||||
}
|
||||
float amp = 0.015 + bandVal * 0.06;
|
||||
waves += amp * sin(uv.x * freq * 6.2832 + speed + sin(uv.y * 3.0 + t) * 2.0);
|
||||
waves += amp * 0.5 * sin(uv.y * freq * 4.0 - speed * 0.7 + cos(uv.x * 2.5 + t) * 1.5);
|
||||
}
|
||||
|
||||
// === Layer 2: Radial pulse (bass-driven) ===
|
||||
float pulse = smoothstep(0.6 + u_bass * 0.3, 0.0, dist) * (0.08 + u_bass * 0.15);
|
||||
|
||||
// === Layer 3: Frequency ring arcs ===
|
||||
float rings = 0.0;
|
||||
for (int i = 0; i < 8; i++) {
|
||||
float fi = float(i);
|
||||
float bandVal = 0.0;
|
||||
for (int j = 0; j < 16; j++) {
|
||||
if (j == i * 2) bandVal = u_bands[j];
|
||||
}
|
||||
float radius = 0.15 + fi * 0.1;
|
||||
float ringWidth = 0.008 + bandVal * 0.025;
|
||||
float ring = smoothstep(ringWidth, 0.0, abs(dist - radius - bandVal * 0.05));
|
||||
// Fade ring by angle sector for variety
|
||||
float angleFade = 0.5 + 0.5 * sin(angle * (2.0 + fi) + t * (1.0 + fi * 0.3));
|
||||
rings += ring * angleFade * (0.3 + bandVal * 0.7);
|
||||
}
|
||||
|
||||
// === Layer 4: Subtle noise texture ===
|
||||
float n = noise(uv * 4.0 + t * 0.5) * 0.03;
|
||||
|
||||
// Combine layers
|
||||
float intensity = waves + pulse + rings * 0.5 + n;
|
||||
|
||||
// Color: accent color with varying brightness
|
||||
vec3 col = u_accent * intensity;
|
||||
|
||||
// Subtle secondary hue shift for depth
|
||||
vec3 shifted = u_accent.gbr; // Rotated accent
|
||||
col += shifted * rings * 0.15;
|
||||
|
||||
// Vignette
|
||||
float vignette = 1.0 - smoothstep(0.3, 1.2, dist);
|
||||
col *= vignette;
|
||||
|
||||
// Blend over page background
|
||||
col = clamp(col, 0.0, 1.0);
|
||||
float colBright = (col.r + col.g + col.b) / 3.0;
|
||||
float bgLum = dot(u_bgColor, vec3(0.299, 0.587, 0.114));
|
||||
// Dark bg: add accent light. Light bg: tint white toward accent via multiply.
|
||||
vec3 darkResult = u_bgColor + col;
|
||||
vec3 lightResult = u_bgColor * mix(vec3(1.0), u_accent, colBright * 2.0);
|
||||
vec3 finalColor = clamp(mix(darkResult, lightResult, bgLum), 0.0, 1.0);
|
||||
|
||||
gl_FragColor = vec4(finalColor, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
// ---- WebGL setup ----
|
||||
|
||||
function initBackgroundGL() {
|
||||
bgCanvas = document.getElementById('bg-shader-canvas');
|
||||
if (!bgCanvas) return false;
|
||||
|
||||
bgGL = bgCanvas.getContext('webgl', { alpha: false, antialias: false, depth: false, stencil: false });
|
||||
if (!bgGL) {
|
||||
console.warn('WebGL not available for background shader');
|
||||
return false;
|
||||
}
|
||||
|
||||
const gl = bgGL;
|
||||
|
||||
// Compile shaders
|
||||
const vs = gl.createShader(gl.VERTEX_SHADER);
|
||||
gl.shaderSource(vs, BG_VERT_SRC);
|
||||
gl.compileShader(vs);
|
||||
if (!gl.getShaderParameter(vs, gl.COMPILE_STATUS)) {
|
||||
console.error('BG vertex shader:', gl.getShaderInfoLog(vs));
|
||||
return false;
|
||||
}
|
||||
|
||||
const fs = gl.createShader(gl.FRAGMENT_SHADER);
|
||||
gl.shaderSource(fs, BG_FRAG_SRC);
|
||||
gl.compileShader(fs);
|
||||
if (!gl.getShaderParameter(fs, gl.COMPILE_STATUS)) {
|
||||
console.error('BG fragment shader:', gl.getShaderInfoLog(fs));
|
||||
return false;
|
||||
}
|
||||
|
||||
bgProgram = gl.createProgram();
|
||||
gl.attachShader(bgProgram, vs);
|
||||
gl.attachShader(bgProgram, fs);
|
||||
gl.linkProgram(bgProgram);
|
||||
if (!gl.getProgramParameter(bgProgram, gl.LINK_STATUS)) {
|
||||
console.error('BG program link:', gl.getProgramInfoLog(bgProgram));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fullscreen quad
|
||||
const buf = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
|
||||
-1, -1, 1, -1, -1, 1,
|
||||
-1, 1, 1, -1, 1, 1
|
||||
]), gl.STATIC_DRAW);
|
||||
|
||||
const aPos = gl.getAttribLocation(bgProgram, 'a_position');
|
||||
gl.enableVertexAttribArray(aPos);
|
||||
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
gl.useProgram(bgProgram);
|
||||
|
||||
// Cache uniform locations once (avoids per-frame lookups)
|
||||
bgUniforms = {
|
||||
resolution: gl.getUniformLocation(bgProgram, 'u_resolution'),
|
||||
time: gl.getUniformLocation(bgProgram, 'u_time'),
|
||||
bass: gl.getUniformLocation(bgProgram, 'u_bass'),
|
||||
bands: gl.getUniformLocation(bgProgram, 'u_bands'),
|
||||
accent: gl.getUniformLocation(bgProgram, 'u_accent'),
|
||||
bgColor: gl.getUniformLocation(bgProgram, 'u_bgColor'),
|
||||
};
|
||||
|
||||
bgStartTime = performance.now() / 1000;
|
||||
updateBackgroundColors();
|
||||
resizeBackgroundCanvas();
|
||||
window.addEventListener('resize', resizeBackgroundCanvas);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function resizeBackgroundCanvas() {
|
||||
if (!bgCanvas) return;
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 1.5); // Cap DPR for performance
|
||||
const w = Math.floor(window.innerWidth * dpr);
|
||||
const h = Math.floor(window.innerHeight * dpr);
|
||||
if (bgCanvas.width !== w || bgCanvas.height !== h) {
|
||||
bgCanvas.width = w;
|
||||
bgCanvas.height = h;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Cached color/theme updates (called on accent or theme change, not per-frame) ----
|
||||
|
||||
export function updateBackgroundColors() {
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
const accentHex = style.getPropertyValue('--accent').trim();
|
||||
if (accentHex && accentHex.length >= 7) {
|
||||
bgAccentRGB[0] = parseInt(accentHex.slice(1, 3), 16) / 255;
|
||||
bgAccentRGB[1] = parseInt(accentHex.slice(3, 5), 16) / 255;
|
||||
bgAccentRGB[2] = parseInt(accentHex.slice(5, 7), 16) / 255;
|
||||
}
|
||||
const bgHex = style.getPropertyValue('--bg-primary').trim();
|
||||
if (bgHex && bgHex.length >= 7) {
|
||||
bgBgColorRGB[0] = parseInt(bgHex.slice(1, 3), 16) / 255;
|
||||
bgBgColorRGB[1] = parseInt(bgHex.slice(3, 5), 16) / 255;
|
||||
bgBgColorRGB[2] = parseInt(bgHex.slice(5, 7), 16) / 255;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Render loop ----
|
||||
|
||||
// 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;
|
||||
|
||||
// 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).
|
||||
// Backend may send float bins (legacy) or int×1000 (new); .scale tells us which.
|
||||
if (frequencyData && frequencyData.frequencies) {
|
||||
const bins = frequencyData.frequencies;
|
||||
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++) {
|
||||
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) * scale;
|
||||
bgSmoothedBass += (targetBass - bgSmoothedBass) * (1 - BG_SMOOTHING);
|
||||
} else {
|
||||
// Gentle decay when no audio
|
||||
for (let i = 0; i < BG_BAND_COUNT; i++) {
|
||||
bgSmoothedBands[i] *= 0.95;
|
||||
}
|
||||
bgSmoothedBass *= 0.95;
|
||||
}
|
||||
|
||||
// Set uniforms (locations cached at init, colors cached on change)
|
||||
gl.uniform1f(bgUniforms.time, time);
|
||||
gl.uniform1f(bgUniforms.bass, bgSmoothedBass);
|
||||
gl.uniform1fv(bgUniforms.bands, bgSmoothedBands);
|
||||
gl.uniform3f(bgUniforms.accent, bgAccentRGB[0], bgAccentRGB[1], bgAccentRGB[2]);
|
||||
gl.uniform3f(bgUniforms.bgColor, bgBgColorRGB[0], bgBgColorRGB[1], bgBgColorRGB[2]);
|
||||
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||
}
|
||||
|
||||
function startBackground() {
|
||||
if (bgAnimFrame) return;
|
||||
if (!bgGL && !initBackgroundGL()) return;
|
||||
bgCanvas.classList.add('visible');
|
||||
document.body.classList.add('dynamic-bg-active');
|
||||
renderBackgroundFrame();
|
||||
}
|
||||
|
||||
function stopBackground() {
|
||||
if (bgAnimFrame) {
|
||||
cancelAnimationFrame(bgAnimFrame);
|
||||
bgAnimFrame = null;
|
||||
}
|
||||
if (bgCanvas) {
|
||||
bgCanvas.classList.remove('visible');
|
||||
}
|
||||
document.body.classList.remove('dynamic-bg-active');
|
||||
}
|
||||
|
||||
// ---- Public API ----
|
||||
|
||||
export function toggleDynamicBackground() {
|
||||
bgEnabled = !bgEnabled;
|
||||
localStorage.setItem('dynamicBackground', bgEnabled);
|
||||
applyDynamicBackground();
|
||||
}
|
||||
|
||||
export function applyDynamicBackground() {
|
||||
const btn = document.getElementById('bgToggle');
|
||||
if (bgEnabled) {
|
||||
startBackground();
|
||||
if (btn) btn.classList.add('active');
|
||||
} else {
|
||||
stopBackground();
|
||||
if (btn) btn.classList.remove('active');
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,232 @@
|
||||
// ============================================================
|
||||
// Callbacks: CRUD management
|
||||
// ============================================================
|
||||
|
||||
import { t, showToast, escapeHtml, closeDialog, showConfirm, getAuthHeaders, hasCredentials } from './core.js';
|
||||
import { IconSelect } from './icon-select.js';
|
||||
import { callbackEventIcons } from './icons.js';
|
||||
|
||||
export let callbackFormDirty = false;
|
||||
export function setCallbackFormDirty(value) { callbackFormDirty = value; }
|
||||
|
||||
let _callbackEventIconSelect = null;
|
||||
|
||||
function _ensureCallbackEventIconSelect() {
|
||||
if (_callbackEventIconSelect) return;
|
||||
const select = document.getElementById('callbackName');
|
||||
if (!select) return;
|
||||
|
||||
const items = Object.entries(callbackEventIcons).map(([value, icon]) => ({
|
||||
value,
|
||||
icon,
|
||||
label: value,
|
||||
}));
|
||||
|
||||
_callbackEventIconSelect = new IconSelect({
|
||||
target: select,
|
||||
items,
|
||||
columns: 3,
|
||||
placeholder: t('callbacks.placeholder.event'),
|
||||
onChange: () => { callbackFormDirty = true; },
|
||||
});
|
||||
}
|
||||
|
||||
let _loadCallbacksPromise = null;
|
||||
export async function loadCallbacksTable() {
|
||||
if (_loadCallbacksPromise) return _loadCallbacksPromise;
|
||||
_loadCallbacksPromise = _loadCallbacksTableImpl();
|
||||
_loadCallbacksPromise.finally(() => { _loadCallbacksPromise = null; });
|
||||
return _loadCallbacksPromise;
|
||||
}
|
||||
|
||||
async function _loadCallbacksTableImpl() {
|
||||
const tbody = document.getElementById('callbacksTableBody');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/callbacks/list', {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch callbacks');
|
||||
}
|
||||
|
||||
const callbacksList = await response.json();
|
||||
|
||||
if (callbacksList.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="empty-state"><div class="empty-state-illustration"><svg viewBox="0 0 64 64"><circle cx="32" cy="32" r="24"/><path d="M32 20v12l8 8"/></svg><p>' + t('callbacks.empty') + '</p></div></td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = callbacksList.map(callback => `
|
||||
<tr>
|
||||
<td><code>${escapeHtml(callback.name)}</code></td>
|
||||
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"
|
||||
title="${escapeHtml(callback.command)}">${escapeHtml(callback.command)}</td>
|
||||
<td>${callback.timeout}s</td>
|
||||
<td>
|
||||
<div class="action-buttons">
|
||||
<button class="action-btn execute" data-action="execute" data-callback-name="${escapeHtml(callback.name)}" title="Execute callback">
|
||||
<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
</button>
|
||||
<button class="action-btn" data-action="edit" data-callback-name="${escapeHtml(callback.name)}" title="Edit callback">
|
||||
<svg viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
|
||||
</button>
|
||||
<button class="action-btn delete" data-action="delete" data-callback-name="${escapeHtml(callback.name)}" title="Delete callback">
|
||||
<svg viewBox="0 0 24 24"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
} catch (error) {
|
||||
console.error('Error loading callbacks:', error);
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="empty-state" style="color: var(--error);">Failed to load callbacks</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
export function showAddCallbackDialog() {
|
||||
const dialog = document.getElementById('callbackDialog');
|
||||
const form = document.getElementById('callbackForm');
|
||||
const title = document.getElementById('callbackDialogTitle');
|
||||
|
||||
form.reset();
|
||||
document.getElementById('callbackIsEdit').value = 'false';
|
||||
document.getElementById('callbackName').disabled = false;
|
||||
title.textContent = t('callbacks.dialog.add');
|
||||
|
||||
_ensureCallbackEventIconSelect();
|
||||
if (_callbackEventIconSelect) _callbackEventIconSelect.setValue('', false);
|
||||
|
||||
callbackFormDirty = false;
|
||||
|
||||
document.body.classList.add('dialog-open');
|
||||
dialog.showModal();
|
||||
}
|
||||
|
||||
export async function showEditCallbackDialog(callbackName) {
|
||||
const dialog = document.getElementById('callbackDialog');
|
||||
const title = document.getElementById('callbackDialogTitle');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/callbacks/list', {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch callback details');
|
||||
}
|
||||
|
||||
const callbacksList = await response.json();
|
||||
const callback = callbacksList.find(c => c.name === callbackName);
|
||||
|
||||
if (!callback) {
|
||||
showToast('Callback not found', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('callbackIsEdit').value = 'true';
|
||||
document.getElementById('callbackName').value = callbackName;
|
||||
document.getElementById('callbackName').disabled = true;
|
||||
|
||||
_ensureCallbackEventIconSelect();
|
||||
if (_callbackEventIconSelect) _callbackEventIconSelect.setValue(callbackName, false);
|
||||
document.getElementById('callbackCommand').value = callback.command;
|
||||
document.getElementById('callbackTimeout').value = callback.timeout;
|
||||
document.getElementById('callbackWorkingDir').value = callback.working_dir || '';
|
||||
|
||||
title.textContent = t('callbacks.dialog.edit');
|
||||
callbackFormDirty = false;
|
||||
|
||||
document.body.classList.add('dialog-open');
|
||||
dialog.showModal();
|
||||
} catch (error) {
|
||||
console.error('Error loading callback for edit:', error);
|
||||
showToast('Failed to load callback details', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeCallbackDialog() {
|
||||
if (callbackFormDirty) {
|
||||
if (!await showConfirm(t('callbacks.confirm.unsaved'))) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const dialog = document.getElementById('callbackDialog');
|
||||
callbackFormDirty = false;
|
||||
closeDialog(dialog);
|
||||
document.body.classList.remove('dialog-open');
|
||||
}
|
||||
|
||||
export async function saveCallback(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const submitBtn = event.target.querySelector('button[type="submit"]');
|
||||
if (submitBtn) submitBtn.disabled = true;
|
||||
|
||||
const isEdit = document.getElementById('callbackIsEdit').value === 'true';
|
||||
const callbackName = document.getElementById('callbackName').value;
|
||||
|
||||
const data = {
|
||||
command: document.getElementById('callbackCommand').value,
|
||||
timeout: parseInt(document.getElementById('callbackTimeout').value) || 30,
|
||||
working_dir: document.getElementById('callbackWorkingDir').value || null,
|
||||
shell: true
|
||||
};
|
||||
|
||||
const endpoint = isEdit ?
|
||||
`/api/callbacks/update/${callbackName}` :
|
||||
`/api/callbacks/create/${callbackName}`;
|
||||
|
||||
const method = isEdit ? 'PUT' : 'POST';
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
showToast(`Callback ${isEdit ? 'updated' : 'created'} successfully`, 'success');
|
||||
callbackFormDirty = false;
|
||||
closeCallbackDialog();
|
||||
loadCallbacksTable();
|
||||
} else {
|
||||
showToast(result.detail || `Failed to ${isEdit ? 'update' : 'create'} callback`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving callback:', error);
|
||||
showToast(`Error ${isEdit ? 'updating' : 'creating'} callback`, 'error');
|
||||
} finally {
|
||||
if (submitBtn) submitBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteCallbackConfirm(callbackName) {
|
||||
if (!await showConfirm(t('callbacks.confirm.delete').replace('{name}', callbackName))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/callbacks/delete/${callbackName}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
showToast('Callback deleted successfully', 'success');
|
||||
loadCallbacksTable();
|
||||
} else {
|
||||
showToast(result.detail || 'Failed to delete callback', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting callback:', error);
|
||||
showToast('Error deleting callback', 'error');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,591 @@
|
||||
// ============================================================
|
||||
// Core: Shared state, constants, utilities, i18n, API commands
|
||||
// ============================================================
|
||||
|
||||
// SVG path constants (avoid rebuilding innerHTML on every state update)
|
||||
export const SVG_PLAY = '<path d="M8 5v14l11-7z"/>';
|
||||
export const SVG_PAUSE = '<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>';
|
||||
export const SVG_STOP = '<path d="M6 6h12v12H6z"/>';
|
||||
export const SVG_IDLE = '<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"/>';
|
||||
export const SVG_MUTED = '<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>';
|
||||
export const SVG_UNMUTED = '<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>';
|
||||
|
||||
// Empty state illustration SVGs
|
||||
export const EMPTY_SVG_FOLDER = '<svg viewBox="0 0 64 64"><path d="M8 16h20l4-6h20a4 4 0 014 4v36a4 4 0 01-4 4H8a4 4 0 01-4-4V20a4 4 0 014-4z"/><path d="M4 24h56" stroke-dasharray="4 3" opacity="0.4"/></svg>';
|
||||
export const EMPTY_SVG_FILE = '<svg viewBox="0 0 64 64"><path d="M16 4h22l14 14v38a4 4 0 01-4 4H16a4 4 0 01-4-4V8a4 4 0 014-4z"/><path d="M38 4v14h14"/><path d="M22 32h20M22 40h14" opacity="0.5"/></svg>';
|
||||
export function emptyStateHtml(svgStr, text) {
|
||||
return `<div class="empty-state-illustration">${svgStr}<p>${text}</p></div>`;
|
||||
}
|
||||
|
||||
// Media source registry: substring key → { name, icon }
|
||||
export const MEDIA_SOURCES = {
|
||||
'spotify': {
|
||||
name: 'Spotify',
|
||||
icon: '<svg viewBox="0 0 24 24"><path fill="#1DB954" d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z"/></svg>'
|
||||
},
|
||||
'yandex music': {
|
||||
name: 'Yandex Music',
|
||||
icon: '<svg viewBox="0 0 24 24"><path fill="#FFCC00" d="M12 0C5.376 0 0 5.376 0 12s5.376 12 12 12 12-5.376 12-12S18.624 0 12 0zm0 2.4a9.6 9.6 0 110 19.2 9.6 9.6 0 010-19.2z"/><path fill="#FFCC00" d="M13.2 6h-2.4v7.2L7.2 6H4.8l5.4 12h1.2l.6-1.35V6z"/></svg>'
|
||||
},
|
||||
'яндекс музыка': {
|
||||
name: 'Яндекс Музыка',
|
||||
icon: '<svg viewBox="0 0 24 24"><path fill="#FFCC00" d="M12 0C5.376 0 0 5.376 0 12s5.376 12 12 12 12-5.376 12-12S18.624 0 12 0zm0 2.4a9.6 9.6 0 110 19.2 9.6 9.6 0 010-19.2z"/><path fill="#FFCC00" d="M13.2 6h-2.4v7.2L7.2 6H4.8l5.4 12h1.2l.6-1.35V6z"/></svg>'
|
||||
},
|
||||
'chrome': {
|
||||
name: 'Google Chrome',
|
||||
icon: '<svg viewBox="0 0 24 24"><circle fill="#4587F3" cx="12" cy="12" r="11"/><path fill="#DB4437" d="M12 1C7.2 1 3.1 3.8 1.3 7.9L7.7 12l1.8-3.1c.7-1.1 1.9-1.9 3.3-1.9h9.7C21 3.5 16.9 1 12 1z"/><path fill="#0F9D58" d="M7.7 12L1.3 7.9C.5 9.2 0 10.6 0 12c0 4.5 2.8 8.4 6.8 10l3.8-6.6L7.7 12z"/><path fill="#FFCD40" d="M6.8 22c2.7 1.5 6.4 1.7 9.4.2 2.8-1.4 4.9-3.9 5.8-6.8l-6.5-3.4-1.8 3.1c-.7 1.1-1.9 1.9-3.3 1.9-.9 0-1.7-.3-2.4-.7L6.8 22z"/><circle fill="#F1F1F1" cx="12" cy="12" r="4.8"/><circle fill="#4587F3" cx="12" cy="12" r="3.8"/></svg>'
|
||||
},
|
||||
'msedge': {
|
||||
name: 'Microsoft Edge',
|
||||
icon: '<svg viewBox="0 0 24 24"><path fill="#0078D4" d="M21.86 17.86q.14 0 .25-.12.1-.13.1-.25 0-.06 0-.13-.12-.76-.39-1.49-.26-.72-.65-1.39-.4-.66-.92-1.25-.53-.58-1.15-1.06-.61-.48-1.3-.85-.69-.37-1.44-.6-.75-.22-1.53-.3-.8-.07-1.6 0h-.04q-.51.03-1.03.14-.5.12-1 .31-.49.2-.95.46-.46.27-.89.6-.42.32-.8.7-.37.4-.69.83-.31.44-.57.92-.25.49-.44 1 .09-.14.21-.28.12-.14.26-.27.14-.12.3-.23.16-.1.33-.18.18-.08.37-.14.18-.06.38-.08.2-.02.4-.01.21.01.41.06.28.07.53.2.25.12.47.3.21.18.39.4.18.21.32.45.14.25.23.52.1.26.14.54.04.28.02.56-.02.36-.12.72-.1.35-.27.68-.17.33-.4.62-.24.3-.52.56-.28.25-.6.46-.32.2-.67.35.44.1.9.14.44.03.89-.02.45-.05.88-.17.44-.12.85-.3.41-.2.79-.44.37-.25.71-.55.34-.3.63-.65.3-.35.54-.73.24-.39.42-.8.18-.42.3-.86.12-.43.18-.88.06-.45.06-.9 0-.48-.07-.95-.07-.47-.22-.93z"/><path fill="#50E6FF" d="M11.89.03Q10.03.17 8.3.88 6.57 1.59 5.1 2.77 3.65 3.94 2.55 5.5 1.44 7.06.79 8.88.14 10.7 0 12.65q.01.22.02.45 0 .22.03.44.04.42.12.83.08.42.2.83.12.4.28.79.16.39.36.76.2.37.43.72.24.34.51.66.27.32.57.6.3.29.63.54.33.25.68.46.35.21.72.38.38.17.77.28.39.12.79.18.41.06.82.05.41 0 .82-.07.41-.08.79-.22.39-.14.74-.34.36-.2.68-.44.33-.25.6-.54.28-.3.5-.63.23-.33.4-.7.17-.36.27-.75-1.1.9-2.44 1.36-1.33.46-2.77.46-1.26 0-2.44-.39-1.18-.39-2.17-1.08-1-1.08-1.6-2.02-.6-.94-.87-2-.27-1.07-.25-2.2.02-.55.12-1.08.1-.54.29-1.05.18-.52.44-1 .27-.49.6-.94.34-.44.74-.83.4-.38.85-.71.45-.32.94-.57.49-.25 1.02-.42.52-.16 1.07-.24.55-.07 1.1-.05.81.04 1.57.25.77.2 1.46.56.7.36 1.29.85.6.5 1.07 1.1.48.6.82 1.29.34.69.54 1.44.2.76.24 1.55.04.79-.08 1.57-.11.78-.37 1.52-.26.74-.66 1.4-.39.67-.91 1.24-.52.57-1.14 1.02-.62.44-1.32.76-.7.32-1.45.49-.75.16-1.52.18z"/></svg>'
|
||||
},
|
||||
'firefox': {
|
||||
name: 'Firefox',
|
||||
icon: '<svg viewBox="0 0 24 24"><path fill="#FF7139" d="M12 0C5.37 0 0 5.37 0 12s5.37 12 12 12 12-5.37 12-12S18.63 0 12 0zm6.73 7.27c-.47-.77-1.22-1.6-1.7-1.87.54.97.86 2.07.93 3.15 0 0-.02.03-.02.05-.38-1.34-1.14-2.15-1.78-3.05-.03-.05-.06-.1-.1-.15-.03-.05-.05-.1-.06-.15 0-.02-.01-.04-.02-.05l-.01.02c-.02.03-.03.05-.04.08 0 0 0 .01-.01.02l.01-.02c-.64 1.07-1.72 2.2-2.1 3.56-.46.01-.9.09-1.32.23l-.06.03c-.03-.2-.04-.4-.04-.6 0-.67.15-1.3.4-1.87-1.08.4-1.93 1.12-2.53 1.72-.33-.36-.36-1.56-.34-1.8-.01 0-.03.02-.04.02-.27.2-.52.42-.75.66-.28.3-.53.62-.76.96-.12.2-.24.4-.34.6-.15.32-.27.66-.36 1-.02.07-.03.14-.05.21v.03c-.06.3-.1.6-.12.9v.1c0 .07 0 .14-.01.21C7.3 13.8 7.52 16.37 9 18.26l.04.05c-1.55-1-2.57-2.64-2.87-4.42-.04.2-.06.4-.07.6-.01.2-.02.4-.01.6.02.6.13 1.2.3 1.77.2.57.46 1.12.8 1.62.17.25.36.48.56.7.2.22.42.43.66.62 1.83 1.47 4.17 1.87 6.34 1.21.26-.08.5-.17.74-.28 1.1-.5 2.06-1.27 2.78-2.23.03-.03.05-.07.07-.1.08-.1.15-.2.22-.32.5-.77.84-1.62 1.02-2.5.02-.1.04-.2.05-.3.1-.57.14-1.15.12-1.73 0-.1-.01-.19-.02-.29.06-1.2-.15-2.42-.63-3.53-.1-.23-.2-.45-.32-.67z"/></svg>'
|
||||
},
|
||||
'opera': {
|
||||
name: 'Opera',
|
||||
icon: '<svg viewBox="0 0 24 24"><path fill="#FF1B2D" d="M12 0C5.37 0 0 5.37 0 12s5.37 12 12 12c2.75 0 5.28-.93 7.3-2.49-1.24.77-2.68 1.22-4.22 1.22-2.2 0-4.17-1.1-5.55-2.83C8.1 18.1 7.2 15.22 7.2 12s.9-6.1 2.33-7.9C10.91 2.37 12.88 1.27 15.08 1.27c1.54 0 2.98.45 4.22 1.22C17.28.93 14.75 0 12 0z"/><path fill="#FF1B2D" d="M15.08 1.27c-2.2 0-4.17 1.1-5.55 2.83C8.1 5.9 7.2 8.78 7.2 12s.9 6.1 2.33 7.9c1.38 1.73 3.35 2.83 5.55 2.83 2.2 0 4.17-1.1 5.55-2.83C22.06 18.1 22.96 15.22 22.96 12s-.9-6.1-2.33-7.9c-1.38-1.73-3.35-2.83-5.55-2.83z" opacity=".75"/></svg>'
|
||||
},
|
||||
'brave': {
|
||||
name: 'Brave',
|
||||
icon: '<svg viewBox="0 0 24 24"><path fill="#FB542B" d="M12 0L3.6 4.8v9.6L12 24l8.4-9.6V4.8L12 0zm5.7 14.1l-1.2 1.8c-.3.3-.6.6-.9.9l-2.1 1.5-1.5.9-1.5-.9-2.1-1.5c-.3-.3-.6-.6-.9-.9l-1.2-1.8c-.3-.6-.3-1.2 0-1.5l.6-1.5.6-1.2.6-1.2.3-.6c.15-.3.45-.3.6 0l.6.9c.15.3.45.3.6 0l.6-.9.6-.9c.15-.3.45-.3.6 0l.6.9.6.9c.15.3.45.3.6 0l.6-.9c.15-.3.45-.3.6 0l.3.6.6 1.2.6 1.2.6 1.5c.3.3.3.9 0 1.5z"/></svg>'
|
||||
},
|
||||
'yandex': {
|
||||
name: 'Yandex Browser',
|
||||
icon: '<svg viewBox="0 0 24 24"><path fill="#FF0000" d="M12 0C5.37 0 0 5.37 0 12s5.37 12 12 12 12-5.37 12-12S18.63 0 12 0z"/><path fill="#FFF" d="M13.5 5h-2.1l-3.9 8.1V5H5.4v14h2.1l4.05-8.55V19h2.1V5z"/></svg>'
|
||||
},
|
||||
'vlc': {
|
||||
name: 'VLC',
|
||||
icon: '<svg viewBox="0 0 24 24"><path fill="#FF8800" d="M12 1.5L7.5 16h9L12 1.5z"/><path fill="#FF5722" d="M6 18.5c-1.5 0-2.5.5-2.5 1.5s2.5 2.5 8.5 2.5 8.5-1.5 8.5-2.5-1-1.5-2.5-1.5H6z"/><path fill="#FF8800" d="M6 18.5h12l-1.5-2.5h-9L6 18.5z"/></svg>'
|
||||
},
|
||||
'aimp': {
|
||||
name: 'AIMP',
|
||||
icon: '<svg viewBox="0 0 24 24"><path fill="#F7A600" d="M12 0C5.37 0 0 5.37 0 12s5.37 12 12 12 12-5.37 12-12S18.63 0 12 0z"/><path fill="#FFF" d="M12 4l-7 14h3l1.5-3h5l1.5 3h3L12 4zm0 5l1.75 3.5h-3.5L12 9z"/></svg>'
|
||||
},
|
||||
'foobar': {
|
||||
name: 'foobar2000',
|
||||
icon: '<svg viewBox="0 0 24 24"><rect fill="#1F1A17" width="24" height="24" rx="4"/><path fill="#D89B2B" d="M6 6h3v12H6V6zm4.5 0H13v12h-2.5V6zm4 0H17v12h-2.5V6z"/></svg>'
|
||||
},
|
||||
'music.ui': {
|
||||
name: 'Groove Music',
|
||||
icon: '<svg viewBox="0 0 24 24"><circle fill="#7B83EB" cx="12" cy="12" r="11"/><path fill="#FFF" d="M15 7v7a3 3 0 11-2-2.83V7h2z"/></svg>'
|
||||
},
|
||||
'itunes': {
|
||||
name: 'iTunes',
|
||||
icon: '<svg viewBox="0 0 24 24"><path fill="#EA4CC0" d="M12 0C5.37 0 0 5.37 0 12s5.37 12 12 12 12-5.37 12-12S18.63 0 12 0z"/><path fill="#FFF" d="M16.5 6.5l-7 1.75v7.25a2.5 2.5 0 11-1.5-2.29V9.5l7-1.75v4.75a2.5 2.5 0 11-1.5-2.29V6.5z" opacity=".9"/></svg>'
|
||||
},
|
||||
'apple music': {
|
||||
name: 'Apple Music',
|
||||
icon: '<svg viewBox="0 0 24 24"><path fill="#FC3C44" d="M12 0C5.37 0 0 5.37 0 12s5.37 12 12 12 12-5.37 12-12S18.63 0 12 0z"/><path fill="#FFF" d="M16.5 6.5l-7 1.75v7.25a2.5 2.5 0 11-1.5-2.29V9.5l7-1.75v4.75a2.5 2.5 0 11-1.5-2.29V6.5z" opacity=".9"/></svg>'
|
||||
},
|
||||
'deezer': {
|
||||
name: 'Deezer',
|
||||
icon: '<svg viewBox="0 0 24 24"><rect fill="#000" width="24" height="24" rx="4"/><g fill="#A238FF"><rect x="2" y="16" width="3" height="2" rx=".5"/><rect x="6.5" y="14" width="3" height="4" rx=".5"/><rect x="11" y="10" width="3" height="8" rx=".5"/><rect x="15.5" y="12" width="3" height="6" rx=".5"/><rect x="19" y="8" width="3" height="10" rx=".5"/></g></svg>'
|
||||
},
|
||||
'tidal': {
|
||||
name: 'TIDAL',
|
||||
icon: '<svg viewBox="0 0 24 24"><path fill="#000" d="M12 4.8L8 8.8l4 4-4 4-4-4 4-4-4-4 4-4 4 4zm4 0l4 4-4 4-4-4 4-4z"/></svg>'
|
||||
},
|
||||
};
|
||||
|
||||
export function resolveMediaSource(raw) {
|
||||
if (!raw) return null;
|
||||
const lower = raw.toLowerCase();
|
||||
for (const [key, info] of Object.entries(MEDIA_SOURCES)) {
|
||||
if (lower.includes(key)) return info;
|
||||
}
|
||||
return { name: raw.replace(/\.exe$/i, ''), icon: null };
|
||||
}
|
||||
|
||||
// Cached DOM references (populated once after DOMContentLoaded)
|
||||
export const dom = {};
|
||||
export function cacheDom() {
|
||||
dom.trackTitle = document.getElementById('track-title');
|
||||
dom.artist = document.getElementById('artist');
|
||||
dom.album = document.getElementById('album');
|
||||
dom.miniTrackTitle = document.getElementById('mini-track-title');
|
||||
dom.miniArtist = document.getElementById('mini-artist');
|
||||
dom.albumArt = document.getElementById('album-art');
|
||||
dom.albumArtGlow = document.getElementById('album-art-glow');
|
||||
dom.miniAlbumArt = document.getElementById('mini-album-art');
|
||||
dom.volumeSlider = document.getElementById('volume-slider');
|
||||
dom.volumeDisplay = document.getElementById('volume-display');
|
||||
dom.miniVolumeSlider = document.getElementById('mini-volume-slider');
|
||||
dom.miniVolumeDisplay = document.getElementById('mini-volume-display');
|
||||
dom.progressFill = document.getElementById('progress-fill');
|
||||
dom.currentTime = document.getElementById('current-time');
|
||||
dom.totalTime = document.getElementById('total-time');
|
||||
dom.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');
|
||||
dom.miniTotalTime = document.getElementById('mini-total-time');
|
||||
dom.playbackState = document.getElementById('playback-state');
|
||||
dom.stateIcon = document.getElementById('state-icon');
|
||||
dom.playPauseIcon = document.getElementById('play-pause-icon');
|
||||
dom.miniPlayPauseIcon = document.getElementById('mini-play-pause-icon');
|
||||
dom.muteIcon = document.getElementById('mute-icon');
|
||||
dom.miniMuteIcon = document.getElementById('mini-mute-icon');
|
||||
dom.statusDot = document.getElementById('status-dot');
|
||||
dom.source = document.getElementById('source');
|
||||
dom.sourceIcon = document.getElementById('sourceIcon');
|
||||
dom.btnPlayPause = document.getElementById('btn-play-pause');
|
||||
dom.btnNext = document.getElementById('btn-next');
|
||||
dom.btnPrevious = document.getElementById('btn-previous');
|
||||
dom.miniBtnPlayPause = document.getElementById('mini-btn-play-pause');
|
||||
dom.miniPlayer = document.getElementById('mini-player');
|
||||
}
|
||||
|
||||
// Timing constants
|
||||
export const VOLUME_THROTTLE_MS = 16;
|
||||
// 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;
|
||||
export const WS_BACKOFF_MAX_MS = 30000;
|
||||
export const WS_MAX_RECONNECT_ATTEMPTS = 20;
|
||||
export const WS_PING_INTERVAL_MS = 30000;
|
||||
export const VOLUME_RELEASE_DELAY_MS = 500;
|
||||
|
||||
// Shared state (accessed across multiple modules)
|
||||
export let ws = null;
|
||||
export function setWs(value) { ws = value; }
|
||||
export let currentState = 'idle';
|
||||
export function setCurrentState(value) { currentState = value; }
|
||||
export let currentDuration = 0;
|
||||
export function setCurrentDuration(value) { currentDuration = value; }
|
||||
export let currentPosition = 0;
|
||||
export function setCurrentPosition(value) { currentPosition = value; }
|
||||
export let isUserAdjustingVolume = false;
|
||||
export function setIsUserAdjustingVolume(value) { isUserAdjustingVolume = value; }
|
||||
export let volumeUpdateTimer = null;
|
||||
export function setVolumeUpdateTimer(value) { volumeUpdateTimer = value; }
|
||||
export let scripts = [];
|
||||
export function setScripts(value) { scripts = value; }
|
||||
export let lastStatus = null;
|
||||
export function setLastStatus(value) { lastStatus = value; }
|
||||
export let currentPlayState = 'idle';
|
||||
export function setCurrentPlayState(value) { currentPlayState = value; }
|
||||
|
||||
// ============================================================
|
||||
// Internationalization (i18n)
|
||||
// ============================================================
|
||||
|
||||
let currentLocale = 'en';
|
||||
let translations = {};
|
||||
const supportedLocales = {
|
||||
'en': 'English',
|
||||
'ru': 'Русский'
|
||||
};
|
||||
|
||||
// Minimal inline fallback for critical UI elements
|
||||
const fallbackTranslations = {
|
||||
'app.title': 'Media Server',
|
||||
'auth.connect': 'Connect',
|
||||
'auth.placeholder': 'Enter API Token',
|
||||
'player.status.connected': 'Connected',
|
||||
'player.status.disconnected': 'Disconnected'
|
||||
};
|
||||
|
||||
export function t(key, params = {}) {
|
||||
let text = translations[key] || fallbackTranslations[key] || key;
|
||||
Object.keys(params).forEach(param => {
|
||||
text = text.replace(new RegExp(`\\{${param}\\}`, 'g'), params[param]);
|
||||
});
|
||||
return text;
|
||||
}
|
||||
|
||||
async function loadTranslations(locale) {
|
||||
try {
|
||||
const response = await fetch(`/static/locales/${locale}.json`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load ${locale}.json`);
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error(`Error loading translations for ${locale}:`, error);
|
||||
if (locale !== 'en') {
|
||||
return await loadTranslations('en');
|
||||
}
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function detectBrowserLocale() {
|
||||
const browserLang = navigator.language || navigator.languages?.[0] || 'en';
|
||||
const langCode = browserLang.split('-')[0];
|
||||
return supportedLocales[langCode] ? langCode : 'en';
|
||||
}
|
||||
|
||||
export async function initLocale() {
|
||||
const savedLocale = localStorage.getItem('locale') || detectBrowserLocale();
|
||||
await setLocale(savedLocale);
|
||||
}
|
||||
|
||||
async function setLocale(locale) {
|
||||
if (!supportedLocales[locale]) {
|
||||
locale = 'en';
|
||||
}
|
||||
translations = await loadTranslations(locale);
|
||||
currentLocale = locale;
|
||||
document.documentElement.setAttribute('data-locale', locale);
|
||||
document.documentElement.setAttribute('lang', locale);
|
||||
localStorage.setItem('locale', locale);
|
||||
updateAllText();
|
||||
updateLocaleSelect();
|
||||
document.body.classList.remove('loading-translations');
|
||||
document.body.classList.add('translations-loaded');
|
||||
}
|
||||
|
||||
export function changeLocale() {
|
||||
const select = document.getElementById('locale-select');
|
||||
const newLocale = select.value;
|
||||
if (newLocale && newLocale !== currentLocale) {
|
||||
localStorage.setItem('locale', newLocale);
|
||||
setLocale(newLocale);
|
||||
}
|
||||
}
|
||||
|
||||
function updateLocaleSelect() {
|
||||
const select = document.getElementById('locale-select');
|
||||
if (select) {
|
||||
select.value = currentLocale;
|
||||
}
|
||||
}
|
||||
|
||||
// Note: updateAllText calls functions from other modules via late-bound references.
|
||||
// These are set from app.js after all modules are loaded.
|
||||
let _updatePlaybackState = null;
|
||||
let _updateConnectionStatus = null;
|
||||
let _loadScriptsTable = null;
|
||||
let _loadCallbacksTable = null;
|
||||
let _loadLinksTable = null;
|
||||
let _displayQuickAccess = null;
|
||||
let _renderAccentSwatches = null;
|
||||
|
||||
export function registerUpdateCallbacks(callbacks) {
|
||||
_updatePlaybackState = callbacks.updatePlaybackState;
|
||||
_updateConnectionStatus = callbacks.updateConnectionStatus;
|
||||
_loadScriptsTable = callbacks.loadScriptsTable;
|
||||
_loadCallbacksTable = callbacks.loadCallbacksTable;
|
||||
_loadLinksTable = callbacks.loadLinksTable;
|
||||
_displayQuickAccess = callbacks.displayQuickAccess;
|
||||
_renderAccentSwatches = callbacks.renderAccentSwatches;
|
||||
}
|
||||
|
||||
function updateAllText() {
|
||||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n');
|
||||
el.textContent = t(key);
|
||||
});
|
||||
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n-placeholder');
|
||||
el.placeholder = t(key);
|
||||
});
|
||||
document.querySelectorAll('[data-i18n-title]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n-title');
|
||||
el.title = t(key);
|
||||
});
|
||||
|
||||
// Re-apply dynamic content with new translations
|
||||
if (_updatePlaybackState) _updatePlaybackState(currentState);
|
||||
const connected = ws && ws.readyState === WebSocket.OPEN;
|
||||
if (_updateConnectionStatus) _updateConnectionStatus(connected);
|
||||
|
||||
if (lastStatus) {
|
||||
const fallbackTitle = lastStatus.state === 'idle' ? t('player.no_media') : t('player.title_unavailable');
|
||||
document.getElementById('track-title').textContent = lastStatus.title || fallbackTitle;
|
||||
const initSrc = resolveMediaSource(lastStatus.source);
|
||||
document.getElementById('source').textContent = initSrc ? initSrc.name : t('player.unknown_source');
|
||||
document.getElementById('sourceIcon').innerHTML = initSrc?.icon || '';
|
||||
}
|
||||
|
||||
if (hasCredentials()) {
|
||||
if (_loadScriptsTable) _loadScriptsTable();
|
||||
if (_loadCallbacksTable) _loadCallbacksTable();
|
||||
if (_loadLinksTable) _loadLinksTable();
|
||||
if (_displayQuickAccess) _displayQuickAccess();
|
||||
}
|
||||
if (_renderAccentSwatches) _renderAccentSwatches();
|
||||
}
|
||||
|
||||
export async function fetchVersion() {
|
||||
try {
|
||||
const response = await fetch('/api/health');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const label = document.getElementById('version-label');
|
||||
if (data.version) {
|
||||
label.textContent = `v${data.version}`;
|
||||
const folioVersion = document.getElementById('folio-version');
|
||||
if (folioVersion) folioVersion.textContent = `v${data.version}`;
|
||||
}
|
||||
if (data.update_available) {
|
||||
showUpdateBanner(data.update_available);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching version:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export function showUpdateBanner(update) {
|
||||
const dismissed = sessionStorage.getItem('update_dismissed');
|
||||
if (dismissed === update.latest) return;
|
||||
|
||||
const banner = document.getElementById('updateBanner');
|
||||
const text = document.getElementById('updateBannerText');
|
||||
const link = document.getElementById('updateBannerLink');
|
||||
const closeBtn = document.getElementById('updateBannerClose');
|
||||
|
||||
text.textContent = t('update.available', { version: update.latest });
|
||||
link.href = update.url;
|
||||
link.textContent = t('update.view_release');
|
||||
banner.classList.remove('hidden');
|
||||
|
||||
closeBtn.onclick = () => {
|
||||
banner.classList.add('hidden');
|
||||
sessionStorage.setItem('update_dismissed', update.latest);
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Shared Utilities
|
||||
// ============================================================
|
||||
|
||||
export function formatTime(seconds) {
|
||||
if (!seconds || seconds < 0) return '0:00';
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
export function showToast(message, type = 'success') {
|
||||
const container = document.getElementById('toast-container');
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast ${type}`;
|
||||
toast.textContent = message;
|
||||
container.appendChild(toast);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
toast.classList.add('show');
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('show');
|
||||
toast.addEventListener('transitionend', () => toast.remove(), { once: true });
|
||||
setTimeout(() => { if (toast.parentNode) toast.remove(); }, 500);
|
||||
}, TOAST_DURATION_MS);
|
||||
}
|
||||
|
||||
export function closeDialog(dialog) {
|
||||
dialog.classList.add('dialog-closing');
|
||||
dialog.addEventListener('animationend', () => {
|
||||
dialog.classList.remove('dialog-closing');
|
||||
dialog.close();
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
export function showAboutDialog() {
|
||||
const dialog = document.getElementById('aboutDialog');
|
||||
if (dialog) dialog.showModal();
|
||||
}
|
||||
|
||||
export function closeAboutDialog() {
|
||||
const dialog = document.getElementById('aboutDialog');
|
||||
if (dialog) closeDialog(dialog);
|
||||
}
|
||||
|
||||
export function showConfirm(message) {
|
||||
return new Promise((resolve) => {
|
||||
const dialog = document.getElementById('confirmDialog');
|
||||
const msg = document.getElementById('confirmDialogMessage');
|
||||
const btnCancel = document.getElementById('confirmDialogCancel');
|
||||
const btnConfirm = document.getElementById('confirmDialogConfirm');
|
||||
|
||||
msg.textContent = message;
|
||||
|
||||
function cleanup() {
|
||||
btnCancel.removeEventListener('click', onCancel);
|
||||
btnConfirm.removeEventListener('click', onConfirm);
|
||||
dialog.removeEventListener('close', onClose);
|
||||
closeDialog(dialog);
|
||||
}
|
||||
|
||||
function onCancel() { cleanup(); resolve(false); }
|
||||
function onConfirm() { cleanup(); resolve(true); }
|
||||
function onClose() { cleanup(); resolve(false); }
|
||||
|
||||
btnCancel.addEventListener('click', onCancel);
|
||||
btnConfirm.addEventListener('click', onConfirm);
|
||||
dialog.addEventListener('close', onClose);
|
||||
|
||||
dialog.showModal();
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Auth Helpers
|
||||
// ============================================================
|
||||
|
||||
// Set to false when server reports auth_required: false
|
||||
export let authRequired = true;
|
||||
export function setAuthRequired(value) { authRequired = value; }
|
||||
|
||||
/**
|
||||
* Build Authorization headers for API requests.
|
||||
* Returns empty object when auth is disabled or no token is stored.
|
||||
*/
|
||||
export function getAuthHeaders() {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
return token ? { 'Authorization': `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we have sufficient credentials to call the API.
|
||||
* True when auth is disabled OR a token is stored.
|
||||
*/
|
||||
export function hasCredentials() {
|
||||
return !authRequired || !!localStorage.getItem('media_server_token');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// API Commands
|
||||
// ============================================================
|
||||
|
||||
export async function sendCommand(endpoint, body = null) {
|
||||
const options = {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
||||
};
|
||||
|
||||
if (body) {
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/media/${endpoint}`, options);
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
console.error(`Command ${endpoint} failed:`, response.status);
|
||||
showToast(data.detail || `Command failed: ${endpoint}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error sending command ${endpoint}:`, error);
|
||||
showToast(`Connection error: ${endpoint}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export function togglePlayPause() {
|
||||
if (currentState === 'playing') {
|
||||
sendCommand('pause');
|
||||
} else {
|
||||
sendCommand('play');
|
||||
}
|
||||
}
|
||||
|
||||
export function nextTrack() {
|
||||
sendCommand('next');
|
||||
}
|
||||
|
||||
export function previousTrack() {
|
||||
sendCommand('previous');
|
||||
}
|
||||
|
||||
let lastSentVolume = -1;
|
||||
export function setVolume(volume) {
|
||||
if (volume === lastSentVolume) return;
|
||||
lastSentVolume = volume;
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'volume', volume: volume }));
|
||||
} else {
|
||||
sendCommand('volume', { volume: volume });
|
||||
}
|
||||
}
|
||||
|
||||
export function toggleMute() {
|
||||
sendCommand('mute');
|
||||
}
|
||||
|
||||
export function seek(position) {
|
||||
sendCommand('seek', { position: position });
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// MDI Icon System
|
||||
// ============================================================
|
||||
|
||||
const mdiIconCache = (() => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem('mdiIconCache') || '{}');
|
||||
} catch { return {}; }
|
||||
})();
|
||||
|
||||
function _persistMdiCache() {
|
||||
try { localStorage.setItem('mdiIconCache', JSON.stringify(mdiIconCache)); } catch {}
|
||||
}
|
||||
|
||||
export async function fetchMdiIcon(iconName) {
|
||||
const name = iconName.replace(/^mdi:/, '');
|
||||
if (mdiIconCache[name]) return mdiIconCache[name];
|
||||
|
||||
try {
|
||||
const response = await fetch(`https://api.iconify.design/mdi/${name}.svg?width=16&height=16`);
|
||||
if (response.ok) {
|
||||
const svg = await response.text();
|
||||
mdiIconCache[name] = svg;
|
||||
_persistMdiCache();
|
||||
return svg;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch MDI icon:', name, e);
|
||||
}
|
||||
|
||||
return '<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>';
|
||||
}
|
||||
|
||||
export async function resolveMdiIcons(container) {
|
||||
const els = container.querySelectorAll('[data-mdi-icon]');
|
||||
await Promise.all(Array.from(els).map(async (el) => {
|
||||
const icon = el.dataset.mdiIcon;
|
||||
if (icon) {
|
||||
el.innerHTML = await fetchMdiIcon(icon);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
export function setupIconPreview(inputId, previewId) {
|
||||
const input = document.getElementById(inputId);
|
||||
const preview = document.getElementById(previewId);
|
||||
if (!input || !preview) return;
|
||||
|
||||
let debounceTimer = null;
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
clearTimeout(debounceTimer);
|
||||
const value = input.value.trim();
|
||||
|
||||
if (!value) {
|
||||
preview.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
debounceTimer = setTimeout(async () => {
|
||||
const svg = await fetchMdiIcon(value);
|
||||
if (input.value.trim() === value) {
|
||||
preview.innerHTML = svg;
|
||||
}
|
||||
}, 400);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// ============================================================
|
||||
// IconSelect: visual icon-grid selector (replaces <select>)
|
||||
// Ported from wled-screen-controller (TypeScript → vanilla JS)
|
||||
//
|
||||
// Trigger replaces the <select> inline. Popup is absolutely
|
||||
// positioned inside a wrapper that sits next to the trigger.
|
||||
// Works inside <dialog showModal()> — dialog must have
|
||||
// overflow: visible.
|
||||
// ============================================================
|
||||
|
||||
const POPUP_CLASS = 'icon-select-popup';
|
||||
|
||||
let _globalListenerAdded = false;
|
||||
|
||||
export function closeAllIconSelects() {
|
||||
document.querySelectorAll(`.${POPUP_CLASS}.open`).forEach(p => {
|
||||
p.classList.remove('open');
|
||||
});
|
||||
}
|
||||
|
||||
function _ensureGlobalListener() {
|
||||
if (_globalListenerAdded) return;
|
||||
_globalListenerAdded = true;
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest(`.${POPUP_CLASS}`) && !e.target.closest('.icon-select-trigger')) {
|
||||
closeAllIconSelects();
|
||||
}
|
||||
});
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') closeAllIconSelects();
|
||||
});
|
||||
}
|
||||
|
||||
export class IconSelect {
|
||||
constructor({ target, items, onChange, columns = 2, placeholder = '', horizontal = false }) {
|
||||
_ensureGlobalListener();
|
||||
|
||||
this._select = target;
|
||||
this._items = items;
|
||||
this._onChange = onChange;
|
||||
this._columns = columns;
|
||||
this._placeholder = placeholder;
|
||||
this._horizontal = horizontal;
|
||||
|
||||
// Hide native select
|
||||
this._select.style.display = 'none';
|
||||
|
||||
// Trigger button (replaces select visually)
|
||||
this._trigger = document.createElement('button');
|
||||
this._trigger.type = 'button';
|
||||
this._trigger.className = 'icon-select-trigger';
|
||||
this._trigger.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this._toggle();
|
||||
});
|
||||
this._select.parentNode.insertBefore(this._trigger, this._select.nextSibling);
|
||||
|
||||
// Popup — absolutely positioned, appended to dialog (overflow:visible)
|
||||
// or body, escaping any scrollable ancestors
|
||||
this._popup = document.createElement('div');
|
||||
this._popup.className = POPUP_CLASS;
|
||||
this._popup.addEventListener('click', (e) => e.stopPropagation());
|
||||
this._popup.innerHTML = this._buildGrid();
|
||||
|
||||
const portal = this._select.closest('dialog') || document.body;
|
||||
portal.appendChild(this._popup);
|
||||
|
||||
this._bindCells();
|
||||
this._syncTrigger();
|
||||
}
|
||||
|
||||
_buildGrid() {
|
||||
const cells = this._items.map(item =>
|
||||
`<div class="icon-select-cell" data-value="${item.value}">
|
||||
<span class="icon-select-cell-icon">${item.icon}</span>
|
||||
<span class="icon-select-cell-label">${item.label}</span>
|
||||
${item.desc ? `<span class="icon-select-cell-desc">${item.desc}</span>` : ''}
|
||||
</div>`
|
||||
).join('');
|
||||
|
||||
const cls = 'icon-select-grid' + (this._horizontal ? ' icon-select-grid--horizontal' : '');
|
||||
return `<div class="${cls}" style="grid-template-columns:repeat(${this._columns},1fr)">${cells}</div>`;
|
||||
}
|
||||
|
||||
_bindCells() {
|
||||
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => {
|
||||
cell.addEventListener('click', () => {
|
||||
this.setValue(cell.dataset.value, true);
|
||||
this._popup.classList.remove('open');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_syncTrigger() {
|
||||
const val = this._select.value;
|
||||
const item = this._items.find(i => i.value === val);
|
||||
if (item) {
|
||||
this._trigger.innerHTML =
|
||||
`<span class="icon-select-trigger-icon">${item.icon}</span>` +
|
||||
`<span class="icon-select-trigger-label">${item.label}</span>` +
|
||||
`<span class="icon-select-trigger-arrow">▾</span>`;
|
||||
} else if (this._placeholder) {
|
||||
this._trigger.innerHTML =
|
||||
`<span class="icon-select-trigger-label">${this._placeholder}</span>` +
|
||||
`<span class="icon-select-trigger-arrow">▾</span>`;
|
||||
}
|
||||
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => {
|
||||
cell.classList.toggle('active', cell.dataset.value === val);
|
||||
});
|
||||
}
|
||||
|
||||
_positionPopup() {
|
||||
// Get trigger position relative to the popup's offset parent
|
||||
// (the dialog or body). Use getBoundingClientRect for both and
|
||||
// compute the offset.
|
||||
const triggerRect = this._trigger.getBoundingClientRect();
|
||||
const parentRect = this._popup.offsetParent
|
||||
? this._popup.offsetParent.getBoundingClientRect()
|
||||
: { left: 0, top: 0 };
|
||||
|
||||
const relTop = triggerRect.bottom - parentRect.top;
|
||||
const relLeft = triggerRect.left - parentRect.left;
|
||||
const popupW = Math.max(triggerRect.width, 200);
|
||||
|
||||
this._popup.style.left = relLeft + 'px';
|
||||
this._popup.style.top = (relTop + 4) + 'px';
|
||||
this._popup.style.width = popupW + 'px';
|
||||
}
|
||||
|
||||
_toggle() {
|
||||
const wasOpen = this._popup.classList.contains('open');
|
||||
closeAllIconSelects();
|
||||
if (!wasOpen) {
|
||||
this._positionPopup();
|
||||
this._popup.classList.add('open');
|
||||
}
|
||||
}
|
||||
|
||||
setValue(value, fireChange = false) {
|
||||
this._select.value = value;
|
||||
this._syncTrigger();
|
||||
if (fireChange) {
|
||||
this._select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
if (this._onChange) this._onChange(value);
|
||||
}
|
||||
}
|
||||
|
||||
updateItems(items) {
|
||||
this._items = items;
|
||||
this._popup.innerHTML = this._buildGrid();
|
||||
this._bindCells();
|
||||
this._syncTrigger();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._trigger.remove();
|
||||
this._popup.remove();
|
||||
this._select.style.display = '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// ============================================================
|
||||
// SVG icon library for icon-select grids
|
||||
// Simple inline SVGs (24x24 viewBox, fill="currentColor")
|
||||
// ============================================================
|
||||
|
||||
const _svg = (path) =>
|
||||
`<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">${path}</svg>`;
|
||||
|
||||
// Parameter types
|
||||
export const paramTypeIcons = {
|
||||
string: _svg('<path d="M3 7V5h18v2H3zm0 12v-2h12v2H3zm0-6v-2h18v2H3z"/>'),
|
||||
integer: _svg('<path d="M4 17V7h2v4h3V7h2v10h-2v-4H6v4H4zm10-1h2v1h2v-4h-2v1h-2V9h6v8h-6v-1z"/>'),
|
||||
float: _svg('<path d="M5 17V7h2v4h3V7h2v10H9v-4H7v4H5zm9.5 0v-2a1 1 0 1 1 0-2h1v-2h-1a3 3 0 0 0 0 6h1v2h-1zm3-6v2h1a1 1 0 1 1 0 2h-1v2h1a3 3 0 0 0 0-6h-1z"/>'),
|
||||
boolean: _svg('<path d="M17 7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h10c2.76 0 5-2.24 5-5s-2.24-5-5-5zm0 8c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3z"/>'),
|
||||
select: _svg('<path d="M3 5h18v2H3V5zm4 6h10v2H7v-2zm-4 6h18v2H3v-2z"/><path d="M7 7l5 5 5-5"/>'),
|
||||
};
|
||||
|
||||
// Callback events
|
||||
export const callbackEventIcons = {
|
||||
on_play: _svg('<path d="M8 5v14l11-7z"/>'),
|
||||
on_pause: _svg('<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>'),
|
||||
on_stop: _svg('<path d="M6 6h12v12H6z"/>'),
|
||||
on_next: _svg('<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/>'),
|
||||
on_previous: _svg('<path d="M6 6h2v12H6V6zm3.5 6l8.5 6V6l-8.5 6z"/>'),
|
||||
on_volume: _svg('<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 8.5v7a4.5 4.5 0 0 0 2.5-3.5zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>'),
|
||||
on_mute: _svg('<path d="M16.5 12A4.5 4.5 0 0 0 14 8.5v2.09l2.41 2.41c.06-.31.09-.65.09-1zM19 12c0 .94-.2 1.82-.54 2.64l1.51 1.51A8.796 8.796 0 0 0 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06a8.99 8.99 0 0 0 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>'),
|
||||
on_seek: _svg('<path d="M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/>'),
|
||||
on_turn_on: _svg('<path d="M13 3h-2v10h2V3zm4.83 2.17l-1.42 1.42A6.92 6.92 0 0 1 19 12c0 3.87-3.13 7-7 7s-7-3.13-7-7c0-2.27 1.08-4.28 2.76-5.56L6.34 5.02A8.96 8.96 0 0 0 3 12c0 4.97 4.03 9 9 9s9-4.03 9-9a8.96 8.96 0 0 0-3.17-6.83z"/>'),
|
||||
on_turn_off: _svg('<path d="M13 3h-2v10h2V3zm4.83 2.17l-1.42 1.42A6.92 6.92 0 0 1 19 12c0 3.87-3.13 7-7 7s-7-3.13-7-7c0-2.27 1.08-4.28 2.76-5.56L6.34 5.02A8.96 8.96 0 0 0 3 12c0 4.97 4.03 9 9 9s9-4.03 9-9a8.96 8.96 0 0 0-3.17-6.83z"/>'),
|
||||
on_toggle: _svg('<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>'),
|
||||
};
|
||||
@@ -0,0 +1,720 @@
|
||||
// ============================================================
|
||||
// Display Brightness, Power, Contrast, Input Source, Color Preset,
|
||||
// Picture Mode Control + Links Management
|
||||
// ============================================================
|
||||
|
||||
import { t, showToast, escapeHtml, closeDialog, showConfirm, resolveMdiIcons, fetchMdiIcon, getAuthHeaders, hasCredentials } from './core.js';
|
||||
import { IconSelect } from './icon-select.js';
|
||||
|
||||
let displayBrightnessTimers = {};
|
||||
let displayContrastTimers = {};
|
||||
let _displayIconSelects = [];
|
||||
const DISPLAY_THROTTLE_MS = 50;
|
||||
|
||||
// ─── Icon palette for the tuning IconSelects ───────────────────────────
|
||||
// All SVGs are 24x24 monochrome — IconSelect's CSS fills them with currentColor.
|
||||
|
||||
const ICON_PORT_GENERIC =
|
||||
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M4 8h16v8H4V8zm2 2v4h12v-4H6zm2 1h2v2H8v-2zm4 0h2v2h-2v-2zm-9 6h18v2H3v-2z"/></svg>';
|
||||
const ICON_PORT_HDMI =
|
||||
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M3 9l2-2h14l2 2v5l-2 2h-3l-1 1H9l-1-1H5l-2-2V9zm2.5.5v4l1 1h2l1 1h7l1-1h2l1-1v-4l-1-.5H6.5l-1 .5z"/></svg>';
|
||||
const ICON_PORT_DP =
|
||||
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M4 8l2-2h12l2 2v8l-2 2H6l-2-2V8zm2 .5V15l1 1h10l1-1V8.5L17 8H7l-1 .5zM8 10h2v4H8v-4zm6 0h2v4h-2v-4z"/></svg>';
|
||||
const ICON_PORT_DVI =
|
||||
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M3 8h18v8H3V8zm2 1.5v5h14v-5H5zM7 11h1.5v2H7v-2zm3 0h1.5v2H10v-2zm3 0h1.5v2H13v-2zm3 0h1.5v2H16v-2z"/></svg>';
|
||||
const ICON_PORT_VGA =
|
||||
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M5 7h14a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2V9a2 2 0 012-2zm0 2v6h14V9H5zm2 1.5a.75.75 0 100 1.5.75.75 0 000-1.5zm3 0a.75.75 0 100 1.5.75.75 0 000-1.5zm3 0a.75.75 0 100 1.5.75.75 0 000-1.5zm3 0a.75.75 0 100 1.5.75.75 0 000-1.5z"/></svg>';
|
||||
const ICON_PORT_USBC =
|
||||
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M5 10a3 3 0 013-3h8a3 3 0 013 3v4a3 3 0 01-3 3H8a3 3 0 01-3-3v-4zm3-1.5A1.5 1.5 0 006.5 10v4A1.5 1.5 0 008 15.5h8a1.5 1.5 0 001.5-1.5v-4A1.5 1.5 0 0016 8.5H8zm1 2h6v3H9v-3z"/></svg>';
|
||||
|
||||
const ICON_THERMOMETER =
|
||||
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M12 3a3 3 0 00-3 3v8.17A4 4 0 1015 14.17V6a3 3 0 00-3-3zm-1.5 3a1.5 1.5 0 113 0v8.76a2.5 2.5 0 11-3 0V6zm1.5 5a1 1 0 011 1v2.27a1.5 1.5 0 11-2 0V12a1 1 0 011-1z"/></svg>';
|
||||
|
||||
const ICON_MODE_MOVIE =
|
||||
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M4 5h16v14H4V5zm2 2v2h2V7H6zm0 4v2h2v-2H6zm0 4v2h2v-2H6zm10-8v2h2V7h-2zm0 4v2h2v-2h-2zm0 4v2h2v-2h-2zm-6-7h4v8h-4V8z"/></svg>';
|
||||
const ICON_MODE_GAME =
|
||||
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M7 8a5 5 0 00-5 5 4 4 0 007.4 2.1L11 14h2l1.6 1.1A4 4 0 0022 13a5 5 0 00-5-5H7zm1 3v1H7v-1H6v-1h1V9h1v1h1v1H8zm7 0a1 1 0 110-2 1 1 0 010 2zm2 2a1 1 0 110-2 1 1 0 010 2z"/></svg>';
|
||||
const ICON_MODE_SPORT =
|
||||
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M12 2a10 10 0 100 20 10 10 0 000-20zm0 2c1.7 0 3.3.5 4.6 1.4l-1.4 2.4L12 6.5l-3.2 1.3-1.4-2.4A8 8 0 0112 4zm-7.6 4l2.5 1.3-.5 3.5L4 16.4A8 8 0 014.4 8zm15.2 0a8 8 0 01.4 8.4l-2.4-1.6-.5-3.5L19.6 8zM12 8.7l3 1.2.6 3.2L13 15h-2l-2.6-1.9.6-3.2L12 8.7zm-5.3 8.8L9 16.5l2.4 1h1.2l2.4-1 2.3 1A8 8 0 016.7 17.5z"/></svg>';
|
||||
const ICON_MODE_PRO =
|
||||
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M3 4h18v12H13v2h4v2H7v-2h4v-2H3V4zm2 2v8h14V6H5zm2 2h6v2H7V8zm0 3h10v2H7v-2z"/></svg>';
|
||||
const ICON_MODE_DOCS =
|
||||
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M6 3h9l4 4v14H6V3zm2 2v14h10V9h-4V5H8zm2 6h6v2h-6v-2zm0 3h6v2h-6v-2z"/></svg>';
|
||||
const ICON_MODE_USER =
|
||||
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M12 3a4 4 0 100 8 4 4 0 000-8zm0 2a2 2 0 110 4 2 2 0 010-4zm0 8c-3.3 0-7 1.5-7 4.5V20h14v-2.5c0-3-3.7-4.5-7-4.5z"/></svg>';
|
||||
const ICON_MODE_DEFAULT =
|
||||
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M3 5h18v12H3V5zm2 2v8h14V7H5zm-2 12h18v2H3v-2z"/></svg>';
|
||||
const ICON_MODE_MIXED =
|
||||
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M3 5h18v14H3V5zm2 2v10h7V7H5zm9 0v10h5V7h-5z"/></svg>';
|
||||
|
||||
function inputSourceIcon(src) {
|
||||
const s = String(src || '').toUpperCase();
|
||||
if (s.startsWith('HDMI')) return ICON_PORT_HDMI;
|
||||
if (s.startsWith('DP')) return ICON_PORT_DP;
|
||||
if (s.startsWith('DVI')) return ICON_PORT_DVI;
|
||||
if (s.startsWith('VGA')) return ICON_PORT_VGA;
|
||||
if (s.startsWith('USB')) return ICON_PORT_USBC;
|
||||
return ICON_PORT_GENERIC;
|
||||
}
|
||||
|
||||
function pictureModeIcon(label) {
|
||||
const k = String(label || '').toLowerCase();
|
||||
if (k.includes('movie')) return ICON_MODE_MOVIE;
|
||||
if (k.includes('game')) return ICON_MODE_GAME;
|
||||
if (k.includes('sport')) return ICON_MODE_SPORT;
|
||||
if (k.includes('professional')) return ICON_MODE_PRO;
|
||||
if (k.includes('productivity')) return ICON_MODE_DOCS;
|
||||
if (k.includes('user')) return ICON_MODE_USER;
|
||||
if (k.includes('mixed')) return ICON_MODE_MIXED;
|
||||
return ICON_MODE_DEFAULT;
|
||||
}
|
||||
|
||||
// Humanise enum-style identifiers returned by monitorcontrol so users
|
||||
// don't see SHOUT_CASE strings in the UI.
|
||||
function humanizeInputSource(raw) {
|
||||
if (!raw) return '';
|
||||
// OFF / RESERVED → "Off" / "Reserved"
|
||||
// VGA1 → "VGA 1", HDMI1 → "HDMI 1", DP1 → "DisplayPort 1"
|
||||
const map = { DP: 'DisplayPort', DVI: 'DVI', HDMI: 'HDMI', VGA: 'VGA', USBC: 'USB-C' };
|
||||
const m = String(raw).toUpperCase().match(/^(DP|DVI|HDMI|VGA|USBC|USB_C)(\d*)$/);
|
||||
if (m) {
|
||||
const key = m[1] === 'USB_C' ? 'USBC' : m[1];
|
||||
return `${map[key]}${m[2] ? ' ' + m[2] : ''}`;
|
||||
}
|
||||
return String(raw)
|
||||
.replace(/_/g, ' ')
|
||||
.toLowerCase()
|
||||
.replace(/\b\w/g, c => c.toUpperCase());
|
||||
}
|
||||
|
||||
function humanizeColorPreset(raw) {
|
||||
if (!raw) return '';
|
||||
// COLOR_TEMP_6500K → "6500 K", COLOR_TEMP_NATIVE → "Native",
|
||||
// COLOR_TEMP_USER1 → "User 1"
|
||||
const s = String(raw).replace(/^COLOR_TEMP_?/i, '');
|
||||
const kelvin = s.match(/^(\d{4,5})K?$/);
|
||||
if (kelvin) return `${kelvin[1]} K`;
|
||||
const user = s.match(/^USER\s*_?(\d+)$/i);
|
||||
if (user) return `User ${user[1]}`;
|
||||
return s
|
||||
.replace(/_/g, ' ')
|
||||
.toLowerCase()
|
||||
.replace(/\b\w/g, c => c.toUpperCase());
|
||||
}
|
||||
|
||||
export async function loadDisplayMonitors() {
|
||||
if (!hasCredentials()) return;
|
||||
|
||||
const container = document.getElementById('displayMonitors');
|
||||
if (!container) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/display/monitors', {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
container.innerHTML = `<div class="empty-state-illustration">
|
||||
<svg viewBox="0 0 64 64"><rect x="8" y="10" width="48" height="32" rx="3" fill="none" stroke="currentColor" stroke-width="2"/><line x1="32" y1="42" x2="32" y2="50" stroke="currentColor" stroke-width="2"/><line x1="22" y1="50" x2="42" y2="50" stroke="currentColor" stroke-width="2"/></svg>
|
||||
<p data-i18n="display.error">Failed to load monitors</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const monitors = await response.json();
|
||||
|
||||
if (monitors.length === 0) {
|
||||
container.innerHTML = `<div class="empty-state-illustration">
|
||||
<svg viewBox="0 0 64 64"><rect x="8" y="10" width="48" height="32" rx="3" fill="none" stroke="currentColor" stroke-width="2"/><line x1="32" y1="42" x2="32" y2="50" stroke="currentColor" stroke-width="2"/><line x1="22" y1="50" x2="42" y2="50" stroke="currentColor" stroke-width="2"/></svg>
|
||||
<p data-i18n="display.no_monitors">No monitors detected</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Destroy IconSelects from a previous render so listeners + popups
|
||||
// don't pile up.
|
||||
_displayIconSelects.forEach(inst => { try { inst.destroy(); } catch (_) {} });
|
||||
_displayIconSelects = [];
|
||||
|
||||
container.innerHTML = '';
|
||||
const pendingIconSelects = [];
|
||||
monitors.forEach(monitor => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'display-monitor-card';
|
||||
card.id = `monitor-card-${monitor.id}`;
|
||||
|
||||
const brightnessValue = monitor.brightness !== null ? monitor.brightness : 0;
|
||||
const brightnessDisabled = monitor.brightness === null ? 'disabled' : '';
|
||||
|
||||
let powerBtn = '';
|
||||
if (monitor.power_supported) {
|
||||
powerBtn = `
|
||||
<button class="display-power-btn ${monitor.power_on ? 'on' : 'off'}" id="power-btn-${monitor.id}"
|
||||
onclick="toggleDisplayPower(${monitor.id}, '${monitor.name.replace(/'/g, "\\'")}')"
|
||||
title="${monitor.power_on ? t('display.power_off') : t('display.power_on')}">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M13 3h-2v10h2V3zm4.83 2.17l-1.42 1.42A6.92 6.92 0 0119 12c0 3.87-3.13 7-7 7s-7-3.13-7-7c0-2.27 1.08-4.28 2.76-5.56L6.34 5.02A8.95 8.95 0 003 12c0 4.97 4.03 9 9 9s9-4.03 9-9a8.95 8.95 0 00-3.17-6.83z"/></svg>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
const details = [monitor.resolution, monitor.manufacturer].filter(Boolean).join(' \u00B7 ');
|
||||
const detailsHtml = details ? `<span class="display-monitor-details">${details}</span>` : '';
|
||||
const primaryBadge = monitor.is_primary
|
||||
? `<span class="display-primary-badge" title="${t('display.primary')}" aria-label="${t('display.primary')}">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
|
||||
<path fill="currentColor" d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
|
||||
</svg>
|
||||
</span>`
|
||||
: '';
|
||||
|
||||
// Contrast (DDC/CI) — render only if the monitor reports it.
|
||||
let contrastRow = '';
|
||||
if (monitor.contrast_supported) {
|
||||
const contrastValue = monitor.contrast !== null && monitor.contrast !== undefined
|
||||
? monitor.contrast : 50;
|
||||
contrastRow = `
|
||||
<div class="display-slider-row">
|
||||
<svg class="display-slider-icon" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
||||
<path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18V4c4.41 0 8 3.59 8 8s-3.59 8-8 8z"/>
|
||||
</svg>
|
||||
<span class="display-slider-label" data-i18n="display.contrast">${t('display.contrast')}</span>
|
||||
<input type="range" class="display-slider display-contrast-slider"
|
||||
min="0" max="100" value="${contrastValue}"
|
||||
oninput="onDisplayContrastInput(${monitor.id}, this.value)"
|
||||
onchange="onDisplayContrastChange(${monitor.id}, this.value)">
|
||||
<span class="display-slider-value" id="contrast-val-${monitor.id}">${contrastValue}%</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Build the picture-tuning selects (input source / color preset / picture mode).
|
||||
const tuningRows = [];
|
||||
|
||||
// Each tuning field renders a hidden <select> (state holder)
|
||||
// which IconSelect then enhances after the card lands in the DOM.
|
||||
const tuningTargets = [];
|
||||
|
||||
if (monitor.input_source_supported && monitor.available_input_sources.length > 0) {
|
||||
const current = monitor.input_source;
|
||||
const options = monitor.available_input_sources.map(src => {
|
||||
const selected = src === current ? 'selected' : '';
|
||||
return `<option value="${escapeHtml(src)}" ${selected}>${escapeHtml(humanizeInputSource(src))}</option>`;
|
||||
}).join('');
|
||||
tuningRows.push(`
|
||||
<div class="display-tuning-field">
|
||||
<span class="display-tuning-label" data-i18n="display.input_source">${t('display.input_source')}</span>
|
||||
<select data-display-select="input" data-monitor-id="${monitor.id}"
|
||||
aria-label="${t('display.input_source')}">
|
||||
${options}
|
||||
</select>
|
||||
</div>`);
|
||||
tuningTargets.push({
|
||||
kind: 'input',
|
||||
monitorId: monitor.id,
|
||||
items: monitor.available_input_sources.map(src => ({
|
||||
value: src,
|
||||
icon: inputSourceIcon(src),
|
||||
label: humanizeInputSource(src),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (monitor.color_preset_supported && monitor.available_color_presets.length > 0) {
|
||||
const current = monitor.color_preset;
|
||||
const options = monitor.available_color_presets.map(p => {
|
||||
const selected = p === current ? 'selected' : '';
|
||||
return `<option value="${escapeHtml(p)}" ${selected}>${escapeHtml(humanizeColorPreset(p))}</option>`;
|
||||
}).join('');
|
||||
tuningRows.push(`
|
||||
<div class="display-tuning-field">
|
||||
<span class="display-tuning-label" data-i18n="display.color_preset">${t('display.color_preset')}</span>
|
||||
<select data-display-select="color" data-monitor-id="${monitor.id}"
|
||||
aria-label="${t('display.color_preset')}">
|
||||
${options}
|
||||
</select>
|
||||
</div>`);
|
||||
tuningTargets.push({
|
||||
kind: 'color',
|
||||
monitorId: monitor.id,
|
||||
items: monitor.available_color_presets.map(p => ({
|
||||
value: p,
|
||||
icon: ICON_THERMOMETER,
|
||||
label: humanizeColorPreset(p),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (monitor.picture_mode_supported && monitor.available_picture_modes.length > 0) {
|
||||
const current = monitor.picture_mode_code;
|
||||
const options = monitor.available_picture_modes.map(m => {
|
||||
const selected = m.code === current ? 'selected' : '';
|
||||
return `<option value="${m.code}" ${selected}>${escapeHtml(m.label)}</option>`;
|
||||
}).join('');
|
||||
tuningRows.push(`
|
||||
<div class="display-tuning-field">
|
||||
<span class="display-tuning-label" data-i18n="display.picture_mode">${t('display.picture_mode')}</span>
|
||||
<select data-display-select="mode" data-monitor-id="${monitor.id}"
|
||||
aria-label="${t('display.picture_mode')}">
|
||||
${options}
|
||||
</select>
|
||||
</div>`);
|
||||
tuningTargets.push({
|
||||
kind: 'mode',
|
||||
monitorId: monitor.id,
|
||||
items: monitor.available_picture_modes.map(m => ({
|
||||
value: String(m.code),
|
||||
icon: pictureModeIcon(m.label),
|
||||
label: m.label,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
pendingIconSelects.push(...tuningTargets);
|
||||
|
||||
const tuningBlock = tuningRows.length > 0
|
||||
? `<div class="display-tuning">
|
||||
<div class="display-tuning-title" data-i18n="display.tuning">${t('display.tuning')}</div>
|
||||
<div class="display-tuning-grid">${tuningRows.join('')}</div>
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="display-monitor-header">
|
||||
<svg class="display-monitor-icon" viewBox="0 0 24 24" width="20" height="20">
|
||||
<path fill="currentColor" d="M20 3H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h6v2H8v2h8v-2h-2v-2h6c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 12H4V5h16v10z"/>
|
||||
</svg>
|
||||
<div class="display-monitor-info">
|
||||
<span class="display-monitor-name"><span class="display-monitor-name-text">${monitor.name}</span>${primaryBadge}</span>
|
||||
${detailsHtml}
|
||||
</div>
|
||||
${powerBtn}
|
||||
</div>
|
||||
<div class="display-slider-row display-brightness-control">
|
||||
<svg class="display-slider-icon display-brightness-icon" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
||||
<path fill="currentColor" d="M20 8.69V4h-4.69L12 .69 8.69 4H4v4.69L.69 12 4 15.31V20h4.69L12 23.31 15.31 20H20v-4.69L23.31 12 20 8.69zM12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6 6 2.69 6 6-2.69 6-6 6zm0-10c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4z"/>
|
||||
</svg>
|
||||
<span class="display-slider-label" data-i18n="display.brightness">${t('display.brightness')}</span>
|
||||
<input type="range" class="display-slider display-brightness-slider" min="0" max="100" value="${brightnessValue}" ${brightnessDisabled}
|
||||
oninput="onDisplayBrightnessInput(${monitor.id}, this.value)"
|
||||
onchange="onDisplayBrightnessChange(${monitor.id}, this.value)">
|
||||
<span class="display-slider-value display-brightness-value" id="brightness-val-${monitor.id}">${brightnessValue}%</span>
|
||||
</div>
|
||||
${contrastRow}
|
||||
${tuningBlock}`;
|
||||
|
||||
container.appendChild(card);
|
||||
});
|
||||
|
||||
// Enhance every tuning <select> with an IconSelect now that the
|
||||
// cards are in the DOM (IconSelect needs offsetParent + sibling).
|
||||
pendingIconSelects.forEach(({ kind, monitorId, items }) => {
|
||||
const sel = container.querySelector(
|
||||
`select[data-display-select="${kind}"][data-monitor-id="${monitorId}"]`
|
||||
);
|
||||
if (!sel) return;
|
||||
const handler = kind === 'input' ? onDisplayInputSourceChange
|
||||
: kind === 'color' ? onDisplayColorPresetChange
|
||||
: onDisplayPictureModeChange;
|
||||
_displayIconSelects.push(new IconSelect({
|
||||
target: sel,
|
||||
items,
|
||||
columns: 1,
|
||||
horizontal: true,
|
||||
onChange: (value) => handler(monitorId, value),
|
||||
}));
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to load display monitors:', e);
|
||||
}
|
||||
}
|
||||
|
||||
export function onDisplayBrightnessInput(monitorId, value) {
|
||||
const label = document.getElementById(`brightness-val-${monitorId}`);
|
||||
if (label) label.textContent = `${value}%`;
|
||||
|
||||
if (displayBrightnessTimers[monitorId]) clearTimeout(displayBrightnessTimers[monitorId]);
|
||||
displayBrightnessTimers[monitorId] = setTimeout(() => {
|
||||
sendDisplayBrightness(monitorId, parseInt(value));
|
||||
displayBrightnessTimers[monitorId] = null;
|
||||
}, DISPLAY_THROTTLE_MS);
|
||||
}
|
||||
|
||||
export function onDisplayBrightnessChange(monitorId, value) {
|
||||
if (displayBrightnessTimers[monitorId]) {
|
||||
clearTimeout(displayBrightnessTimers[monitorId]);
|
||||
displayBrightnessTimers[monitorId] = null;
|
||||
}
|
||||
sendDisplayBrightness(monitorId, parseInt(value));
|
||||
}
|
||||
|
||||
async function sendDisplayBrightness(monitorId, brightness) {
|
||||
try {
|
||||
await fetch(`/api/display/brightness/${monitorId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
||||
body: JSON.stringify({ brightness })
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to set brightness:', e);
|
||||
}
|
||||
}
|
||||
|
||||
export function onDisplayContrastInput(monitorId, value) {
|
||||
const label = document.getElementById(`contrast-val-${monitorId}`);
|
||||
if (label) label.textContent = `${value}%`;
|
||||
|
||||
if (displayContrastTimers[monitorId]) clearTimeout(displayContrastTimers[monitorId]);
|
||||
displayContrastTimers[monitorId] = setTimeout(() => {
|
||||
sendDisplayContrast(monitorId, parseInt(value));
|
||||
displayContrastTimers[monitorId] = null;
|
||||
}, DISPLAY_THROTTLE_MS);
|
||||
}
|
||||
|
||||
export function onDisplayContrastChange(monitorId, value) {
|
||||
if (displayContrastTimers[monitorId]) {
|
||||
clearTimeout(displayContrastTimers[monitorId]);
|
||||
displayContrastTimers[monitorId] = null;
|
||||
}
|
||||
sendDisplayContrast(monitorId, parseInt(value));
|
||||
}
|
||||
|
||||
async function sendDisplayContrast(monitorId, contrast) {
|
||||
try {
|
||||
const r = await fetch(`/api/display/contrast/${monitorId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
||||
body: JSON.stringify({ contrast })
|
||||
});
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!data.success) showToast(t('display.msg.contrast_failed'), 'error');
|
||||
} catch (e) {
|
||||
console.error('Failed to set contrast:', e);
|
||||
showToast(t('display.msg.contrast_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function onDisplayInputSourceChange(monitorId, source) {
|
||||
try {
|
||||
const r = await fetch(`/api/display/input_source/${monitorId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
||||
body: JSON.stringify({ source })
|
||||
});
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (data.success) showToast(t('display.msg.input_changed'), 'success');
|
||||
else showToast(t('display.msg.input_failed'), 'error');
|
||||
} catch (e) {
|
||||
console.error('Failed to set input source:', e);
|
||||
showToast(t('display.msg.input_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function onDisplayColorPresetChange(monitorId, preset) {
|
||||
try {
|
||||
const r = await fetch(`/api/display/color_preset/${monitorId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
||||
body: JSON.stringify({ preset })
|
||||
});
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (data.success) showToast(t('display.msg.color_changed'), 'success');
|
||||
else showToast(t('display.msg.color_failed'), 'error');
|
||||
} catch (e) {
|
||||
console.error('Failed to set color preset:', e);
|
||||
showToast(t('display.msg.color_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function onDisplayPictureModeChange(monitorId, codeRaw) {
|
||||
const code = parseInt(codeRaw, 10);
|
||||
if (Number.isNaN(code)) return;
|
||||
try {
|
||||
const r = await fetch(`/api/display/picture_mode/${monitorId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
||||
body: JSON.stringify({ code })
|
||||
});
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (data.success) showToast(t('display.msg.mode_changed'), 'success');
|
||||
else showToast(t('display.msg.mode_failed'), 'error');
|
||||
} catch (e) {
|
||||
console.error('Failed to set picture mode:', e);
|
||||
showToast(t('display.msg.mode_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleDisplayPower(monitorId, monitorName) {
|
||||
const btn = document.getElementById(`power-btn-${monitorId}`);
|
||||
const isOn = btn && btn.classList.contains('on');
|
||||
const newState = !isOn;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/display/power/${monitorId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
||||
body: JSON.stringify({ on: newState })
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
if (btn) {
|
||||
btn.classList.toggle('on', newState);
|
||||
btn.classList.toggle('off', !newState);
|
||||
btn.title = newState ? t('display.power_off') : t('display.power_on');
|
||||
}
|
||||
showToast(newState ? 'Monitor turned on' : 'Monitor turned off', 'success');
|
||||
} else {
|
||||
showToast('Failed to change monitor power', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to set display power:', e);
|
||||
showToast('Failed to change monitor power', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Header Quick Links
|
||||
// ============================================================
|
||||
|
||||
export async function loadHeaderLinks() {
|
||||
if (!hasCredentials()) return;
|
||||
|
||||
const container = document.getElementById('headerLinks');
|
||||
if (!container) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/links/list', {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (!response.ok) return;
|
||||
|
||||
const links = await response.json();
|
||||
container.innerHTML = '';
|
||||
|
||||
for (const link of links) {
|
||||
const a = document.createElement('a');
|
||||
a.href = link.url;
|
||||
a.target = '_blank';
|
||||
a.rel = 'noopener noreferrer';
|
||||
a.className = 'header-link';
|
||||
a.title = link.label || link.url;
|
||||
|
||||
const iconSvg = await fetchMdiIcon(link.icon || 'mdi:link');
|
||||
a.innerHTML = iconSvg;
|
||||
|
||||
container.appendChild(a);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to load header links:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Links Management
|
||||
// ============================================================
|
||||
|
||||
let _loadLinksPromise = null;
|
||||
export let linkFormDirty = false;
|
||||
export function setLinkFormDirty(value) { linkFormDirty = value; }
|
||||
|
||||
export async function loadLinksTable() {
|
||||
if (_loadLinksPromise) return _loadLinksPromise;
|
||||
_loadLinksPromise = _loadLinksTableImpl();
|
||||
_loadLinksPromise.finally(() => { _loadLinksPromise = null; });
|
||||
return _loadLinksPromise;
|
||||
}
|
||||
|
||||
async function _loadLinksTableImpl() {
|
||||
const tbody = document.getElementById('linksTableBody');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/links/list', {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch links');
|
||||
}
|
||||
|
||||
const linksList = await response.json();
|
||||
|
||||
if (linksList.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="empty-state"><div class="empty-state-illustration"><svg viewBox="0 0 64 64"><path d="M26 20a10 10 0 010 14l-6 6a10 10 0 01-14-14l6-6a10 10 0 0114 0" fill="none" stroke="currentColor" stroke-width="2"/><path d="M38 44a10 10 0 010-14l6-6a10 10 0 0114 14l-6 6a10 10 0 01-14 0" fill="none" stroke="currentColor" stroke-width="2"/><path d="M24 40l16-16" stroke="currentColor" stroke-width="2"/></svg><p>' + t('links.empty') + '</p></div></td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = linksList.map(link => `
|
||||
<tr>
|
||||
<td><span class="name-with-icon"><span class="table-icon" data-mdi-icon="${escapeHtml(link.icon || 'mdi:link')}"></span><code>${escapeHtml(link.name)}</code></span></td>
|
||||
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"
|
||||
title="${escapeHtml(link.url)}">${escapeHtml(link.url)}</td>
|
||||
<td>${escapeHtml(link.label || '')}</td>
|
||||
<td>
|
||||
<div class="action-buttons">
|
||||
<button class="action-btn" data-action="edit" data-link-name="${escapeHtml(link.name)}" title="${t('links.button.edit')}">
|
||||
<svg viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
|
||||
</button>
|
||||
<button class="action-btn delete" data-action="delete" data-link-name="${escapeHtml(link.name)}" title="${t('links.button.delete')}">
|
||||
<svg viewBox="0 0 24 24"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
resolveMdiIcons(tbody);
|
||||
} catch (error) {
|
||||
console.error('Error loading links:', error);
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="empty-state" style="color: var(--error);">Failed to load links</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
export function showAddLinkDialog() {
|
||||
const dialog = document.getElementById('linkDialog');
|
||||
const form = document.getElementById('linkForm');
|
||||
const title = document.getElementById('linkDialogTitle');
|
||||
|
||||
form.reset();
|
||||
document.getElementById('linkOriginalName').value = '';
|
||||
document.getElementById('linkIsEdit').value = 'false';
|
||||
document.getElementById('linkName').disabled = false;
|
||||
document.getElementById('linkIconPreview').innerHTML = '';
|
||||
title.textContent = t('links.dialog.add');
|
||||
|
||||
linkFormDirty = false;
|
||||
|
||||
document.body.classList.add('dialog-open');
|
||||
dialog.showModal();
|
||||
}
|
||||
|
||||
export async function showEditLinkDialog(linkName) {
|
||||
const dialog = document.getElementById('linkDialog');
|
||||
const title = document.getElementById('linkDialogTitle');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/links/list', {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch link details');
|
||||
}
|
||||
|
||||
const linksList = await response.json();
|
||||
const link = linksList.find(l => l.name === linkName);
|
||||
|
||||
if (!link) {
|
||||
showToast(t('links.msg.not_found'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('linkOriginalName').value = linkName;
|
||||
document.getElementById('linkIsEdit').value = 'true';
|
||||
document.getElementById('linkName').value = linkName;
|
||||
document.getElementById('linkName').disabled = true;
|
||||
document.getElementById('linkUrl').value = link.url;
|
||||
document.getElementById('linkIcon').value = link.icon || '';
|
||||
document.getElementById('linkLabel').value = link.label || '';
|
||||
document.getElementById('linkDescription').value = link.description || '';
|
||||
|
||||
// Update icon preview
|
||||
const preview = document.getElementById('linkIconPreview');
|
||||
if (link.icon) {
|
||||
fetchMdiIcon(link.icon).then(svg => { preview.innerHTML = svg; });
|
||||
} else {
|
||||
preview.innerHTML = '';
|
||||
}
|
||||
|
||||
title.textContent = t('links.dialog.edit');
|
||||
|
||||
linkFormDirty = false;
|
||||
|
||||
document.body.classList.add('dialog-open');
|
||||
dialog.showModal();
|
||||
} catch (error) {
|
||||
console.error('Error loading link for edit:', error);
|
||||
showToast(t('links.msg.load_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeLinkDialog() {
|
||||
if (linkFormDirty) {
|
||||
if (!await showConfirm(t('links.confirm.unsaved'))) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const dialog = document.getElementById('linkDialog');
|
||||
linkFormDirty = false;
|
||||
closeDialog(dialog);
|
||||
document.body.classList.remove('dialog-open');
|
||||
}
|
||||
|
||||
export async function saveLink(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const submitBtn = event.target.querySelector('button[type="submit"]');
|
||||
if (submitBtn) submitBtn.disabled = true;
|
||||
|
||||
const isEdit = document.getElementById('linkIsEdit').value === 'true';
|
||||
const linkName = isEdit ?
|
||||
document.getElementById('linkOriginalName').value :
|
||||
document.getElementById('linkName').value;
|
||||
|
||||
const data = {
|
||||
url: document.getElementById('linkUrl').value,
|
||||
icon: document.getElementById('linkIcon').value || 'mdi:link',
|
||||
label: document.getElementById('linkLabel').value || '',
|
||||
description: document.getElementById('linkDescription').value || ''
|
||||
};
|
||||
|
||||
const endpoint = isEdit ?
|
||||
`/api/links/update/${linkName}` :
|
||||
`/api/links/create/${linkName}`;
|
||||
|
||||
const method = isEdit ? 'PUT' : 'POST';
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
showToast(t(isEdit ? 'links.msg.updated' : 'links.msg.created'), 'success');
|
||||
linkFormDirty = false;
|
||||
closeLinkDialog();
|
||||
} else {
|
||||
showToast(result.detail || t(isEdit ? 'links.msg.update_failed' : 'links.msg.create_failed'), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving link:', error);
|
||||
showToast(t(isEdit ? 'links.msg.update_failed' : 'links.msg.create_failed'), 'error');
|
||||
} finally {
|
||||
if (submitBtn) submitBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteLinkConfirm(linkName) {
|
||||
if (!await showConfirm(t('links.confirm.delete').replace('{name}', linkName))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/links/delete/${linkName}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
showToast(t('links.msg.deleted'), 'success');
|
||||
} else {
|
||||
showToast(result.detail || t('links.msg.delete_failed'), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting link:', error);
|
||||
showToast(t('links.msg.delete_failed'), 'error');
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,855 @@
|
||||
// ============================================================
|
||||
// Scripts: CRUD, quick access, execution dialog
|
||||
// ============================================================
|
||||
|
||||
import {
|
||||
t, showToast, escapeHtml, closeDialog, showConfirm,
|
||||
resolveMdiIcons, fetchMdiIcon,
|
||||
scripts, setScripts,
|
||||
getAuthHeaders, hasCredentials,
|
||||
} from './core.js';
|
||||
import { IconSelect } from './icon-select.js';
|
||||
import { paramTypeIcons } from './icons.js';
|
||||
|
||||
export let scriptFormDirty = false;
|
||||
export function setScriptFormDirty(value) { scriptFormDirty = value; }
|
||||
|
||||
export async function loadScripts() {
|
||||
try {
|
||||
const response = await fetch('/api/scripts/list', {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setScripts(await response.json());
|
||||
displayQuickAccess();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading scripts:', error);
|
||||
}
|
||||
}
|
||||
|
||||
let _quickAccessGen = 0;
|
||||
export async function displayQuickAccess() {
|
||||
const gen = ++_quickAccessGen;
|
||||
const grid = document.getElementById('scripts-grid');
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
const hasScripts = scripts.length > 0;
|
||||
let hasLinks = false;
|
||||
|
||||
scripts.forEach(script => {
|
||||
const button = document.createElement('button');
|
||||
button.className = 'script-btn';
|
||||
button.onclick = () => executeScript(script.name, button);
|
||||
|
||||
if (script.icon) {
|
||||
const iconEl = document.createElement('div');
|
||||
iconEl.className = 'script-icon';
|
||||
iconEl.setAttribute('data-mdi-icon', script.icon);
|
||||
button.appendChild(iconEl);
|
||||
}
|
||||
|
||||
const label = document.createElement('div');
|
||||
label.className = 'script-label';
|
||||
label.textContent = script.label || script.name;
|
||||
button.appendChild(label);
|
||||
|
||||
if (script.description) {
|
||||
const description = document.createElement('div');
|
||||
description.className = 'script-description';
|
||||
description.textContent = script.description;
|
||||
button.appendChild(description);
|
||||
}
|
||||
|
||||
fragment.appendChild(button);
|
||||
});
|
||||
|
||||
try {
|
||||
if (hasCredentials()) {
|
||||
const response = await fetch('/api/links/list', {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
if (gen !== _quickAccessGen) return;
|
||||
if (response.ok) {
|
||||
const links = await response.json();
|
||||
hasLinks = links.length > 0;
|
||||
links.forEach(link => {
|
||||
const card = document.createElement('a');
|
||||
card.className = 'script-btn link-card';
|
||||
card.href = link.url;
|
||||
card.target = '_blank';
|
||||
card.rel = 'noopener noreferrer';
|
||||
|
||||
if (link.icon) {
|
||||
const iconEl = document.createElement('div');
|
||||
iconEl.className = 'script-icon';
|
||||
iconEl.setAttribute('data-mdi-icon', link.icon);
|
||||
card.appendChild(iconEl);
|
||||
}
|
||||
|
||||
const label = document.createElement('div');
|
||||
label.className = 'script-label';
|
||||
label.textContent = link.label || link.name;
|
||||
card.appendChild(label);
|
||||
|
||||
if (link.description) {
|
||||
const desc = document.createElement('div');
|
||||
desc.className = 'script-description';
|
||||
desc.textContent = link.description;
|
||||
card.appendChild(desc);
|
||||
}
|
||||
|
||||
fragment.appendChild(card);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (gen !== _quickAccessGen) return;
|
||||
console.warn('Failed to load links for quick access:', e);
|
||||
}
|
||||
|
||||
if (!hasScripts && !hasLinks) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'scripts-empty empty-state-illustration';
|
||||
empty.innerHTML = `<svg viewBox="0 0 64 64"><path d="M20 8l-8 48"/><path d="M44 8l8 48"/><path d="M10 24h44"/><path d="M8 40h44"/></svg><p>${t('quick_access.no_items')}</p>`;
|
||||
fragment.prepend(empty);
|
||||
}
|
||||
|
||||
grid.innerHTML = '';
|
||||
grid.appendChild(fragment);
|
||||
resolveMdiIcons(grid);
|
||||
}
|
||||
|
||||
function _getScriptParams(scriptName) {
|
||||
const script = scripts.find(s => s.name === scriptName);
|
||||
return (script && script.parameters) ? script.parameters : {};
|
||||
}
|
||||
|
||||
async function executeScript(scriptName, buttonElement) {
|
||||
const paramDefs = _getScriptParams(scriptName);
|
||||
if (Object.keys(paramDefs).length > 0) {
|
||||
_showParamsInputDialog(scriptName, paramDefs, async (params) => {
|
||||
buttonElement.classList.add('executing');
|
||||
try {
|
||||
await _doExecuteScript(scriptName, params);
|
||||
} finally {
|
||||
buttonElement.classList.remove('executing');
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
buttonElement.classList.add('executing');
|
||||
try {
|
||||
await _doExecuteScript(scriptName, {});
|
||||
} finally {
|
||||
buttonElement.classList.remove('executing');
|
||||
}
|
||||
}
|
||||
|
||||
async function _doExecuteScript(scriptName, params) {
|
||||
try {
|
||||
const response = await fetch(`/api/scripts/execute/${scriptName}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
||||
body: JSON.stringify({ params })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
showToast(t('scripts.msg.executed', { name: scriptName }), 'success');
|
||||
} else {
|
||||
showToast(result.detail || t('scripts.msg.execute_failed', { name: scriptName }), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error executing script ${scriptName}:`, error);
|
||||
showToast(t('scripts.msg.execute_error', { name: scriptName }), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Script Parameters Input Dialog (execution-time)
|
||||
// ============================================================
|
||||
|
||||
let _paramsCallback = null;
|
||||
let _paramsScriptName = null;
|
||||
let _paramsIconSelects = null;
|
||||
|
||||
function _showParamsInputDialog(scriptName, paramDefs, callback) {
|
||||
_paramsCallback = callback;
|
||||
_paramsScriptName = scriptName;
|
||||
|
||||
const dialog = document.getElementById('scriptParamsDialog');
|
||||
const title = document.getElementById('scriptParamsDialogTitle');
|
||||
const container = document.getElementById('scriptParamsInputs');
|
||||
|
||||
const script = scripts.find(s => s.name === scriptName);
|
||||
title.textContent = script ? (script.label || scriptName) : scriptName;
|
||||
container.innerHTML = '';
|
||||
|
||||
// Track IconSelect instances for cleanup
|
||||
if (!_paramsIconSelects) _paramsIconSelects = [];
|
||||
|
||||
for (const [pname, pdef] of Object.entries(paramDefs)) {
|
||||
const wrapper = document.createElement('label');
|
||||
|
||||
const labelText = document.createElement('span');
|
||||
labelText.textContent = pname + (pdef.required ? ' *' : '');
|
||||
wrapper.appendChild(labelText);
|
||||
|
||||
if (pdef.description) {
|
||||
const hint = document.createElement('small');
|
||||
hint.className = 'param-hint';
|
||||
hint.textContent = pdef.description;
|
||||
wrapper.appendChild(hint);
|
||||
}
|
||||
|
||||
let input;
|
||||
if (pdef.type === 'select' && pdef.options) {
|
||||
input = document.createElement('select');
|
||||
if (!pdef.required) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = '';
|
||||
opt.textContent = '—';
|
||||
input.appendChild(opt);
|
||||
}
|
||||
for (const optVal of pdef.options) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = optVal;
|
||||
opt.textContent = optVal;
|
||||
if (pdef.default !== undefined && pdef.default !== null && String(pdef.default) === optVal) {
|
||||
opt.selected = true;
|
||||
}
|
||||
input.appendChild(opt);
|
||||
}
|
||||
input.dataset.paramName = pname;
|
||||
input.dataset.paramType = pdef.type;
|
||||
if (pdef.required) input.required = true;
|
||||
wrapper.appendChild(input);
|
||||
|
||||
// Enhance with icon grid if few options
|
||||
if (pdef.options.length <= 10) {
|
||||
const selItems = pdef.options.map(o => ({ value: o, icon: '', label: o }));
|
||||
const cols = Math.min(pdef.options.length, 4);
|
||||
const isel = new IconSelect({ target: input, items: selItems, columns: cols });
|
||||
_paramsIconSelects.push(isel);
|
||||
}
|
||||
} else if (pdef.type === 'boolean') {
|
||||
const boolSvgTrue = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>';
|
||||
const boolSvgFalse = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>';
|
||||
|
||||
input = document.createElement('select');
|
||||
const optTrue = document.createElement('option');
|
||||
optTrue.value = 'true';
|
||||
optTrue.textContent = 'true';
|
||||
const optFalse = document.createElement('option');
|
||||
optFalse.value = 'false';
|
||||
optFalse.textContent = 'false';
|
||||
input.appendChild(optTrue);
|
||||
input.appendChild(optFalse);
|
||||
if (pdef.default !== undefined && pdef.default !== null) {
|
||||
input.value = String(pdef.default);
|
||||
}
|
||||
input.dataset.paramName = pname;
|
||||
input.dataset.paramType = pdef.type;
|
||||
if (pdef.required) input.required = true;
|
||||
wrapper.appendChild(input);
|
||||
|
||||
// Enhance with icon grid
|
||||
const isel = new IconSelect({
|
||||
target: input,
|
||||
items: [
|
||||
{ value: 'true', icon: boolSvgTrue, label: 'True' },
|
||||
{ value: 'false', icon: boolSvgFalse, label: 'False' },
|
||||
],
|
||||
columns: 2,
|
||||
});
|
||||
_paramsIconSelects.push(isel);
|
||||
} else if (pdef.type === 'integer' || pdef.type === 'float') {
|
||||
input = document.createElement('input');
|
||||
input.type = 'number';
|
||||
if (pdef.type === 'float') input.step = 'any';
|
||||
if (pdef.min !== undefined && pdef.min !== null) input.min = pdef.min;
|
||||
if (pdef.max !== undefined && pdef.max !== null) input.max = pdef.max;
|
||||
if (pdef.default !== undefined && pdef.default !== null) input.value = pdef.default;
|
||||
input.dataset.paramName = pname;
|
||||
input.dataset.paramType = pdef.type;
|
||||
if (pdef.required) input.required = true;
|
||||
wrapper.appendChild(input);
|
||||
} else {
|
||||
input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
if (pdef.default !== undefined && pdef.default !== null) input.value = pdef.default;
|
||||
input.dataset.paramName = pname;
|
||||
input.dataset.paramType = pdef.type;
|
||||
if (pdef.required) input.required = true;
|
||||
wrapper.appendChild(input);
|
||||
}
|
||||
|
||||
container.appendChild(wrapper);
|
||||
}
|
||||
|
||||
document.body.classList.add('dialog-open');
|
||||
dialog.showModal();
|
||||
}
|
||||
|
||||
export function closeScriptParamsDialog() {
|
||||
const dialog = document.getElementById('scriptParamsDialog');
|
||||
_paramsCallback = null;
|
||||
_paramsScriptName = null;
|
||||
// Destroy icon selects from execution dialog
|
||||
if (_paramsIconSelects) {
|
||||
_paramsIconSelects.forEach(isel => isel.destroy());
|
||||
_paramsIconSelects = null;
|
||||
}
|
||||
closeDialog(dialog);
|
||||
document.body.classList.remove('dialog-open');
|
||||
}
|
||||
|
||||
export async function submitScriptWithParams(event) {
|
||||
event.preventDefault();
|
||||
const container = document.getElementById('scriptParamsInputs');
|
||||
const inputs = container.querySelectorAll('[data-param-name]');
|
||||
const params = {};
|
||||
|
||||
for (const input of inputs) {
|
||||
const name = input.dataset.paramName;
|
||||
const type = input.dataset.paramType;
|
||||
let val = input.value;
|
||||
|
||||
if (val === '' && !input.required) continue;
|
||||
if (val === '') continue;
|
||||
|
||||
if (type === 'integer') val = parseInt(val, 10);
|
||||
else if (type === 'float') val = parseFloat(val);
|
||||
else if (type === 'boolean') val = val === 'true';
|
||||
|
||||
params[name] = val;
|
||||
}
|
||||
|
||||
const callback = _paramsCallback;
|
||||
closeScriptParamsDialog();
|
||||
|
||||
if (callback) {
|
||||
await callback(params);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Script Management CRUD
|
||||
// ============================================================
|
||||
|
||||
let _loadScriptsPromise = null;
|
||||
export async function loadScriptsTable() {
|
||||
if (_loadScriptsPromise) return _loadScriptsPromise;
|
||||
_loadScriptsPromise = _loadScriptsTableImpl();
|
||||
_loadScriptsPromise.finally(() => { _loadScriptsPromise = null; });
|
||||
return _loadScriptsPromise;
|
||||
}
|
||||
|
||||
async function _loadScriptsTableImpl() {
|
||||
const tbody = document.getElementById('scriptsTableBody');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/scripts/list', {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch scripts');
|
||||
}
|
||||
|
||||
const scriptsList = await response.json();
|
||||
|
||||
if (scriptsList.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="empty-state"><div class="empty-state-illustration"><svg viewBox="0 0 64 64"><rect x="8" y="4" width="48" height="56" rx="4"/><path d="M20 20h24M20 32h16M20 44h20"/></svg><p>' + t('scripts.empty') + '</p></div></td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = scriptsList.map(script => `
|
||||
<tr>
|
||||
<td><span class="name-with-icon">${script.icon ? `<span class="table-icon" data-mdi-icon="${escapeHtml(script.icon)}"></span>` : ''}<code>${escapeHtml(script.name)}</code></span></td>
|
||||
<td>${escapeHtml(script.label || script.name)}</td>
|
||||
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"
|
||||
title="${escapeHtml(script.command || 'N/A')}">${escapeHtml(script.command || 'N/A')}</td>
|
||||
<td>${script.timeout}s</td>
|
||||
<td>
|
||||
<div class="action-buttons">
|
||||
<button class="action-btn execute" data-action="execute" data-script-name="${escapeHtml(script.name)}" title="Execute script">
|
||||
<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
</button>
|
||||
<button class="action-btn" data-action="edit" data-script-name="${escapeHtml(script.name)}" title="Edit script">
|
||||
<svg viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
|
||||
</button>
|
||||
<button class="action-btn delete" data-action="delete" data-script-name="${escapeHtml(script.name)}" title="Delete script">
|
||||
<svg viewBox="0 0 24 24"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
resolveMdiIcons(tbody);
|
||||
} catch (error) {
|
||||
console.error('Error loading scripts:', error);
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="empty-state" style="color: var(--error);">Failed to load scripts</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
export function showAddScriptDialog() {
|
||||
const dialog = document.getElementById('scriptDialog');
|
||||
const form = document.getElementById('scriptForm');
|
||||
const title = document.getElementById('dialogTitle');
|
||||
|
||||
form.reset();
|
||||
document.getElementById('scriptOriginalName').value = '';
|
||||
document.getElementById('scriptIsEdit').value = 'false';
|
||||
document.getElementById('scriptName').disabled = false;
|
||||
document.getElementById('scriptIconPreview').innerHTML = '';
|
||||
document.getElementById('scriptParamsContainer').innerHTML = '';
|
||||
title.textContent = t('scripts.dialog.add');
|
||||
|
||||
scriptFormDirty = false;
|
||||
|
||||
document.body.classList.add('dialog-open');
|
||||
dialog.showModal();
|
||||
}
|
||||
|
||||
export async function showEditScriptDialog(scriptName) {
|
||||
const dialog = document.getElementById('scriptDialog');
|
||||
const title = document.getElementById('dialogTitle');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/scripts/list', {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch script details');
|
||||
}
|
||||
|
||||
const scriptsList = await response.json();
|
||||
const script = scriptsList.find(s => s.name === scriptName);
|
||||
|
||||
if (!script) {
|
||||
showToast('Script not found', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('scriptOriginalName').value = scriptName;
|
||||
document.getElementById('scriptIsEdit').value = 'true';
|
||||
document.getElementById('scriptName').value = scriptName;
|
||||
document.getElementById('scriptName').disabled = true;
|
||||
document.getElementById('scriptLabel').value = script.label || '';
|
||||
document.getElementById('scriptCommand').value = script.command || '';
|
||||
document.getElementById('scriptDescription').value = script.description || '';
|
||||
document.getElementById('scriptIcon').value = script.icon || '';
|
||||
document.getElementById('scriptTimeout').value = script.timeout || 30;
|
||||
|
||||
const preview = document.getElementById('scriptIconPreview');
|
||||
if (script.icon) {
|
||||
fetchMdiIcon(script.icon).then(svg => { preview.innerHTML = svg; });
|
||||
} else {
|
||||
preview.innerHTML = '';
|
||||
}
|
||||
|
||||
// Populate parameters
|
||||
const paramsContainer = document.getElementById('scriptParamsContainer');
|
||||
paramsContainer.innerHTML = '';
|
||||
if (script.parameters) {
|
||||
for (const [pname, pdef] of Object.entries(script.parameters)) {
|
||||
_addParameterRowWithData(pname, pdef);
|
||||
}
|
||||
}
|
||||
|
||||
title.textContent = t('scripts.dialog.edit');
|
||||
scriptFormDirty = false;
|
||||
|
||||
document.body.classList.add('dialog-open');
|
||||
dialog.showModal();
|
||||
} catch (error) {
|
||||
console.error('Error loading script for edit:', error);
|
||||
showToast('Failed to load script details', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeScriptDialog() {
|
||||
if (scriptFormDirty) {
|
||||
if (!await showConfirm(t('scripts.confirm.unsaved'))) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const dialog = document.getElementById('scriptDialog');
|
||||
scriptFormDirty = false;
|
||||
closeDialog(dialog);
|
||||
document.body.classList.remove('dialog-open');
|
||||
}
|
||||
|
||||
export async function saveScript(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const submitBtn = event.target.querySelector('button[type="submit"]');
|
||||
if (submitBtn) submitBtn.disabled = true;
|
||||
|
||||
const isEdit = document.getElementById('scriptIsEdit').value === 'true';
|
||||
const scriptName = isEdit ?
|
||||
document.getElementById('scriptOriginalName').value :
|
||||
document.getElementById('scriptName').value;
|
||||
|
||||
const data = {
|
||||
command: document.getElementById('scriptCommand').value,
|
||||
label: document.getElementById('scriptLabel').value || null,
|
||||
description: document.getElementById('scriptDescription').value || '',
|
||||
icon: document.getElementById('scriptIcon').value || null,
|
||||
timeout: parseInt(document.getElementById('scriptTimeout').value) || 30,
|
||||
shell: true,
|
||||
parameters: _collectParameterDefinitions(),
|
||||
};
|
||||
|
||||
const endpoint = isEdit ?
|
||||
`/api/scripts/update/${scriptName}` :
|
||||
`/api/scripts/create/${scriptName}`;
|
||||
|
||||
const method = isEdit ? 'PUT' : 'POST';
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
showToast(`Script ${isEdit ? 'updated' : 'created'} successfully`, 'success');
|
||||
scriptFormDirty = false;
|
||||
closeScriptDialog();
|
||||
} else {
|
||||
showToast(result.detail || `Failed to ${isEdit ? 'update' : 'create'} script`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving script:', error);
|
||||
showToast(`Error ${isEdit ? 'updating' : 'creating'} script`, 'error');
|
||||
} finally {
|
||||
if (submitBtn) submitBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteScriptConfirm(scriptName) {
|
||||
if (!await showConfirm(t('scripts.confirm.delete').replace('{name}', scriptName))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/scripts/delete/${scriptName}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
showToast('Script deleted successfully', 'success');
|
||||
} else {
|
||||
showToast(result.detail || 'Failed to delete script', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting script:', error);
|
||||
showToast('Error deleting script', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Execution Result Dialog (shared by scripts and callbacks)
|
||||
// ============================================================
|
||||
|
||||
export function closeExecutionDialog() {
|
||||
const dialog = document.getElementById('executionDialog');
|
||||
closeDialog(dialog);
|
||||
document.body.classList.remove('dialog-open');
|
||||
}
|
||||
|
||||
function showExecutionResult(name, result, type = 'script') {
|
||||
const dialog = document.getElementById('executionDialog');
|
||||
const title = document.getElementById('executionDialogTitle');
|
||||
const statusDiv = document.getElementById('executionStatus');
|
||||
const outputSection = document.getElementById('outputSection');
|
||||
const errorSection = document.getElementById('errorSection');
|
||||
const outputPre = document.getElementById('executionOutput');
|
||||
const errorPre = document.getElementById('executionError');
|
||||
|
||||
title.textContent = `Execution Result: ${name}`;
|
||||
|
||||
const success = result.success && result.exit_code === 0;
|
||||
const statusClass = success ? 'success' : 'error';
|
||||
const statusText = success ? 'Success' : 'Failed';
|
||||
|
||||
statusDiv.innerHTML = `
|
||||
<div class="status-item ${statusClass}">
|
||||
<label>Status</label>
|
||||
<value>${statusText}</value>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<label>Exit Code</label>
|
||||
<value>${result.exit_code !== undefined ? result.exit_code : 'N/A'}</value>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<label>Duration</label>
|
||||
<value>${result.execution_time !== undefined && result.execution_time !== null ? result.execution_time.toFixed(3) + 's' : 'N/A'}</value>
|
||||
</div>
|
||||
`;
|
||||
|
||||
outputSection.style.display = 'block';
|
||||
if (result.stdout && result.stdout.trim()) {
|
||||
outputPre.textContent = result.stdout;
|
||||
} else {
|
||||
outputPre.textContent = '(no output)';
|
||||
outputPre.style.fontStyle = 'italic';
|
||||
outputPre.style.color = 'var(--text-secondary)';
|
||||
}
|
||||
|
||||
if (result.stderr && result.stderr.trim()) {
|
||||
errorSection.style.display = 'block';
|
||||
errorPre.textContent = result.stderr;
|
||||
errorPre.style.fontStyle = 'normal';
|
||||
errorPre.style.color = 'var(--error)';
|
||||
} else if (!success && result.error) {
|
||||
errorSection.style.display = 'block';
|
||||
errorPre.textContent = result.error;
|
||||
errorPre.style.fontStyle = 'normal';
|
||||
errorPre.style.color = 'var(--error)';
|
||||
} else {
|
||||
errorSection.style.display = 'none';
|
||||
}
|
||||
|
||||
dialog.showModal();
|
||||
}
|
||||
|
||||
export async function executeScriptDebug(scriptName) {
|
||||
const paramDefs = _getScriptParams(scriptName);
|
||||
if (Object.keys(paramDefs).length > 0) {
|
||||
_showParamsInputDialog(scriptName, paramDefs, (params) => _executeScriptDebugWithParams(scriptName, params));
|
||||
return;
|
||||
}
|
||||
await _executeScriptDebugWithParams(scriptName, {});
|
||||
}
|
||||
|
||||
async function _executeScriptDebugWithParams(scriptName, params) {
|
||||
const dialog = document.getElementById('executionDialog');
|
||||
const title = document.getElementById('executionDialogTitle');
|
||||
const statusDiv = document.getElementById('executionStatus');
|
||||
|
||||
title.textContent = `Executing: ${scriptName}`;
|
||||
statusDiv.innerHTML = `
|
||||
<div class="status-item">
|
||||
<label>Status</label>
|
||||
<value><span class="loading-spinner"></span> Running...</value>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('outputSection').style.display = 'none';
|
||||
document.getElementById('errorSection').style.display = 'none';
|
||||
document.body.classList.add('dialog-open');
|
||||
dialog.showModal();
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/scripts/execute/${scriptName}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
||||
body: JSON.stringify({ params })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
showExecutionResult(scriptName, result, 'script');
|
||||
} else {
|
||||
showExecutionResult(scriptName, {
|
||||
success: false,
|
||||
exit_code: -1,
|
||||
error: result.detail || 'Execution failed',
|
||||
stderr: result.detail || 'Unknown error'
|
||||
}, 'script');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error executing script ${scriptName}:`, error);
|
||||
showExecutionResult(scriptName, {
|
||||
success: false,
|
||||
exit_code: -1,
|
||||
error: error.message,
|
||||
stderr: `Network error: ${error.message}`
|
||||
}, 'script');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Parameter Definition Editor (CRUD dialog)
|
||||
// ============================================================
|
||||
|
||||
const PARAM_TYPES = ['string', 'integer', 'float', 'boolean', 'select'];
|
||||
|
||||
export function addParameterRow() {
|
||||
_addParameterRowWithData('', {});
|
||||
scriptFormDirty = true;
|
||||
}
|
||||
|
||||
const _paramTypeItems = PARAM_TYPES.map(pt => ({
|
||||
value: pt,
|
||||
icon: paramTypeIcons[pt] || '',
|
||||
label: pt.charAt(0).toUpperCase() + pt.slice(1),
|
||||
}));
|
||||
|
||||
function _addParameterRowWithData(name, def) {
|
||||
const container = document.getElementById('scriptParamsContainer');
|
||||
const row = document.createElement('div');
|
||||
row.className = 'param-row';
|
||||
|
||||
row.innerHTML = `
|
||||
<div class="param-row-header">
|
||||
<input type="text" class="param-name" value="${escapeHtml(name)}"
|
||||
placeholder="${t('scripts.params.name_placeholder')}" pattern="[a-zA-Z][a-zA-Z0-9_]*">
|
||||
<select class="param-type">
|
||||
${PARAM_TYPES.map(pt => `<option value="${pt}" ${def.type === pt ? 'selected' : ''}>${pt}</option>`).join('')}
|
||||
</select>
|
||||
<label class="param-required-label" title="${t('scripts.params.required')}">
|
||||
<input type="checkbox" class="param-required" ${def.required ? 'checked' : ''}>
|
||||
<span>*</span>
|
||||
</label>
|
||||
<button type="button" class="param-remove-btn" title="${t('scripts.params.remove')}">×</button>
|
||||
</div>
|
||||
<div class="param-row-details">
|
||||
<input type="text" class="param-description" value="${escapeHtml(def.description || '')}"
|
||||
placeholder="${t('scripts.params.description_placeholder')}">
|
||||
<div class="param-row-extra">
|
||||
<input type="text" class="param-default" value="${def.default !== undefined && def.default !== null ? escapeHtml(String(def.default)) : ''}"
|
||||
placeholder="${t('scripts.params.default_placeholder')}">
|
||||
<input type="text" class="param-min" value="${def.min !== undefined && def.min !== null ? def.min : ''}"
|
||||
placeholder="Min" style="display:${def.type === 'integer' || def.type === 'float' ? '' : 'none'}">
|
||||
<input type="text" class="param-max" value="${def.max !== undefined && def.max !== null ? def.max : ''}"
|
||||
placeholder="Max" style="display:${def.type === 'integer' || def.type === 'float' ? '' : 'none'}">
|
||||
<input type="text" class="param-options" value="${def.options ? def.options.join(', ') : ''}"
|
||||
placeholder="${t('scripts.params.options_placeholder')}" style="display:${def.type === 'select' ? '' : 'none'}">
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Enhance the type <select> with icon grid
|
||||
const typeSelect = row.querySelector('.param-type');
|
||||
const iconSelect = new IconSelect({
|
||||
target: typeSelect,
|
||||
items: _paramTypeItems,
|
||||
columns: 5,
|
||||
onChange: () => {
|
||||
const isNumeric = typeSelect.value === 'integer' || typeSelect.value === 'float';
|
||||
const isSelect = typeSelect.value === 'select';
|
||||
row.querySelector('.param-min').style.display = isNumeric ? '' : 'none';
|
||||
row.querySelector('.param-max').style.display = isNumeric ? '' : 'none';
|
||||
row.querySelector('.param-options').style.display = isSelect ? '' : 'none';
|
||||
scriptFormDirty = true;
|
||||
},
|
||||
});
|
||||
|
||||
row.querySelector('.param-remove-btn').addEventListener('click', () => {
|
||||
iconSelect.destroy();
|
||||
row.remove();
|
||||
scriptFormDirty = true;
|
||||
});
|
||||
|
||||
// Mark dirty on any input change
|
||||
row.querySelectorAll('input').forEach(el => {
|
||||
el.addEventListener('input', () => { scriptFormDirty = true; });
|
||||
});
|
||||
|
||||
container.appendChild(row);
|
||||
}
|
||||
|
||||
function _collectParameterDefinitions() {
|
||||
const container = document.getElementById('scriptParamsContainer');
|
||||
const rows = container.querySelectorAll('.param-row');
|
||||
const params = {};
|
||||
|
||||
for (const row of rows) {
|
||||
const name = row.querySelector('.param-name').value.trim();
|
||||
if (!name) continue;
|
||||
|
||||
const type = row.querySelector('.param-type').value;
|
||||
const def = { type };
|
||||
|
||||
const description = row.querySelector('.param-description').value.trim();
|
||||
if (description) def.description = description;
|
||||
|
||||
if (row.querySelector('.param-required').checked) def.required = true;
|
||||
|
||||
const defaultVal = row.querySelector('.param-default').value.trim();
|
||||
if (defaultVal !== '') {
|
||||
if (type === 'integer') def.default = parseInt(defaultVal, 10);
|
||||
else if (type === 'float') def.default = parseFloat(defaultVal);
|
||||
else if (type === 'boolean') def.default = defaultVal.toLowerCase() === 'true';
|
||||
else def.default = defaultVal;
|
||||
}
|
||||
|
||||
if (type === 'integer' || type === 'float') {
|
||||
const minVal = row.querySelector('.param-min').value.trim();
|
||||
const maxVal = row.querySelector('.param-max').value.trim();
|
||||
if (minVal !== '') def.min = parseFloat(minVal);
|
||||
if (maxVal !== '') def.max = parseFloat(maxVal);
|
||||
}
|
||||
|
||||
if (type === 'select') {
|
||||
const optStr = row.querySelector('.param-options').value.trim();
|
||||
if (optStr) def.options = optStr.split(',').map(s => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
params[name] = def;
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
export async function executeCallbackDebug(callbackName) {
|
||||
const dialog = document.getElementById('executionDialog');
|
||||
const title = document.getElementById('executionDialogTitle');
|
||||
const statusDiv = document.getElementById('executionStatus');
|
||||
|
||||
title.textContent = `Executing: ${callbackName}`;
|
||||
statusDiv.innerHTML = `
|
||||
<div class="status-item">
|
||||
<label>Status</label>
|
||||
<value><span class="loading-spinner"></span> Running...</value>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('outputSection').style.display = 'none';
|
||||
document.getElementById('errorSection').style.display = 'none';
|
||||
document.body.classList.add('dialog-open');
|
||||
dialog.showModal();
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/callbacks/execute/${callbackName}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
showExecutionResult(callbackName, result, 'callback');
|
||||
} else {
|
||||
showExecutionResult(callbackName, {
|
||||
success: false,
|
||||
exit_code: -1,
|
||||
error: result.detail || 'Execution failed',
|
||||
stderr: result.detail || 'Unknown error'
|
||||
}, 'callback');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error executing callback ${callbackName}:`, error);
|
||||
showExecutionResult(callbackName, {
|
||||
success: false,
|
||||
exit_code: -1,
|
||||
error: error.message,
|
||||
stderr: `Network error: ${error.message}`
|
||||
}, 'callback');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// ============================================================
|
||||
// WebSocket: Connection, reconnection, authentication
|
||||
// ============================================================
|
||||
|
||||
import {
|
||||
dom, t, showToast, setWs,
|
||||
WS_BACKOFF_BASE_MS, WS_BACKOFF_MAX_MS,
|
||||
WS_MAX_RECONNECT_ATTEMPTS, WS_PING_INTERVAL_MS,
|
||||
authRequired, showUpdateBanner,
|
||||
} from './core.js';
|
||||
import { updateUI, setFrequencyData, stopPositionInterpolation, loadAudioDevices } from './player.js';
|
||||
import { loadScripts, loadScriptsTable, displayQuickAccess } from './scripts.js';
|
||||
import { loadCallbacksTable } from './callbacks.js';
|
||||
import { loadHeaderLinks, loadLinksTable } from './links.js';
|
||||
|
||||
let reconnectTimeout = null;
|
||||
let pingInterval = null;
|
||||
let wsReconnectAttempts = 0;
|
||||
|
||||
export function showAuthForm(errorMessage = '') {
|
||||
const overlay = document.getElementById('auth-overlay');
|
||||
overlay.classList.remove('hidden');
|
||||
|
||||
const errorEl = document.getElementById('auth-error');
|
||||
if (errorMessage) {
|
||||
errorEl.textContent = errorMessage;
|
||||
errorEl.classList.add('visible');
|
||||
} else {
|
||||
errorEl.classList.remove('visible');
|
||||
}
|
||||
}
|
||||
|
||||
function hideAuthForm() {
|
||||
document.getElementById('auth-overlay').classList.add('hidden');
|
||||
}
|
||||
|
||||
export function authenticate() {
|
||||
const token = document.getElementById('token-input').value.trim();
|
||||
if (!token) {
|
||||
showAuthForm(t('auth.required'));
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem('media_server_token', token);
|
||||
connectWebSocket(token);
|
||||
}
|
||||
|
||||
export function clearToken() {
|
||||
localStorage.removeItem('media_server_token');
|
||||
// Access ws via import
|
||||
import('./core.js').then(core => {
|
||||
if (core.ws) {
|
||||
core.ws.close();
|
||||
}
|
||||
});
|
||||
showAuthForm(t('auth.cleared'));
|
||||
}
|
||||
|
||||
export function connectWebSocket(token) {
|
||||
if (pingInterval) {
|
||||
clearInterval(pingInterval);
|
||||
pingInterval = null;
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsBase = `${protocol}//${window.location.host}/api/media/ws`;
|
||||
const wsUrl = token ? `${wsBase}?token=${encodeURIComponent(token)}` : wsBase;
|
||||
|
||||
const newWs = new WebSocket(wsUrl);
|
||||
setWs(newWs);
|
||||
|
||||
newWs.onopen = () => {
|
||||
console.log('WebSocket connected');
|
||||
wsReconnectAttempts = 0;
|
||||
updateConnectionStatus(true);
|
||||
hideConnectionBanner();
|
||||
hideAuthForm();
|
||||
loadScripts();
|
||||
loadScriptsTable();
|
||||
loadCallbacksTable();
|
||||
loadLinksTable();
|
||||
loadHeaderLinks();
|
||||
loadAudioDevices();
|
||||
};
|
||||
|
||||
newWs.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
|
||||
if (msg.type === 'status' || msg.type === 'status_update') {
|
||||
updateUI(msg.data);
|
||||
} else if (msg.type === 'scripts_changed') {
|
||||
console.log('Scripts changed, reloading...');
|
||||
loadScripts();
|
||||
loadScriptsTable();
|
||||
} else if (msg.type === 'links_changed') {
|
||||
console.log('Links changed, reloading...');
|
||||
loadHeaderLinks();
|
||||
loadLinksTable();
|
||||
displayQuickAccess();
|
||||
} else if (msg.type === 'update_available') {
|
||||
showUpdateBanner(msg.data);
|
||||
} else if (msg.type === 'audio_data') {
|
||||
setFrequencyData(msg.data);
|
||||
} else if (msg.type === 'error') {
|
||||
console.error('WebSocket error:', msg.message);
|
||||
}
|
||||
};
|
||||
|
||||
newWs.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
updateConnectionStatus(false);
|
||||
};
|
||||
|
||||
newWs.onclose = (event) => {
|
||||
console.log('WebSocket closed:', event.code);
|
||||
updateConnectionStatus(false);
|
||||
stopPositionInterpolation();
|
||||
|
||||
if (event.code === 4001) {
|
||||
localStorage.removeItem('media_server_token');
|
||||
showAuthForm(t('auth.invalid'));
|
||||
} else if (event.code !== 1000) {
|
||||
wsReconnectAttempts++;
|
||||
|
||||
if (wsReconnectAttempts <= WS_MAX_RECONNECT_ATTEMPTS) {
|
||||
const delay = Math.min(
|
||||
WS_BACKOFF_BASE_MS * Math.pow(1.5, wsReconnectAttempts - 1),
|
||||
WS_BACKOFF_MAX_MS
|
||||
);
|
||||
console.log(`Reconnecting in ${Math.round(delay / 1000)}s (attempt ${wsReconnectAttempts}/${WS_MAX_RECONNECT_ATTEMPTS})...`);
|
||||
|
||||
if (wsReconnectAttempts >= 3) {
|
||||
showConnectionBanner(t('connection.reconnecting').replace('{attempt}', wsReconnectAttempts), false);
|
||||
}
|
||||
|
||||
reconnectTimeout = setTimeout(() => {
|
||||
const savedToken = localStorage.getItem('media_server_token');
|
||||
if (savedToken || !authRequired) {
|
||||
connectWebSocket(savedToken || '');
|
||||
}
|
||||
}, delay);
|
||||
} else {
|
||||
showConnectionBanner(t('connection.lost'), true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
pingInterval = setInterval(() => {
|
||||
if (newWs && newWs.readyState === WebSocket.OPEN) {
|
||||
newWs.send(JSON.stringify({ type: 'ping' }));
|
||||
}
|
||||
}, WS_PING_INTERVAL_MS);
|
||||
}
|
||||
|
||||
export function updateConnectionStatus(connected) {
|
||||
if (connected) {
|
||||
dom.statusDot.classList.add('connected');
|
||||
} else {
|
||||
dom.statusDot.classList.remove('connected');
|
||||
}
|
||||
}
|
||||
|
||||
function showConnectionBanner(message, showButton) {
|
||||
const banner = document.getElementById('connectionBanner');
|
||||
const text = document.getElementById('connectionBannerText');
|
||||
const btn = document.getElementById('connectionBannerBtn');
|
||||
text.textContent = message;
|
||||
btn.style.display = showButton ? '' : 'none';
|
||||
banner.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideConnectionBanner() {
|
||||
const banner = document.getElementById('connectionBanner');
|
||||
banner.classList.add('hidden');
|
||||
}
|
||||
|
||||
export function manualReconnect() {
|
||||
const savedToken = localStorage.getItem('media_server_token');
|
||||
if (savedToken || !authRequired) {
|
||||
wsReconnectAttempts = 0;
|
||||
hideConnectionBanner();
|
||||
connectWebSocket(savedToken || '');
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
"auth.cleared": "Token cleared. Please enter a new token.",
|
||||
"auth.required": "Please enter a token",
|
||||
"player.theme": "Toggle theme",
|
||||
"accent.custom": "Custom",
|
||||
"player.locale": "Change language",
|
||||
"player.previous": "Previous",
|
||||
"player.play": "Play/Pause",
|
||||
@@ -18,10 +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",
|
||||
@@ -71,6 +85,15 @@
|
||||
"scripts.execution.error_output": "Error Output",
|
||||
"scripts.execution.close": "Close",
|
||||
"scripts.confirm.unsaved": "You have unsaved changes. Are you sure you want to discard them?",
|
||||
"scripts.field.parameters": "Parameters",
|
||||
"scripts.params.add": "+ Add",
|
||||
"scripts.params.remove": "Remove parameter",
|
||||
"scripts.params.required": "Required",
|
||||
"scripts.params.name_placeholder": "param_name",
|
||||
"scripts.params.description_placeholder": "Parameter description",
|
||||
"scripts.params.default_placeholder": "Default",
|
||||
"scripts.params.options_placeholder": "option1, option2, ...",
|
||||
"scripts.params.execute": "Execute",
|
||||
"callbacks.management": "Callback Management",
|
||||
"callbacks.description": "Callbacks are scripts triggered automatically by media control events (play, pause, stop, etc.)",
|
||||
"callbacks.add": "Add",
|
||||
@@ -114,18 +137,43 @@
|
||||
"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.quick_actions": "Actions",
|
||||
"tab.scripts": "Scripts",
|
||||
"tab.callbacks": "Callbacks",
|
||||
"tab.links": "Links",
|
||||
"tab.player": "Now Spinning",
|
||||
"tab.browser": "Library",
|
||||
"tab.quick_access": "Quick Access",
|
||||
"tab.settings": "Settings",
|
||||
"tab.display": "Display",
|
||||
"settings.section.scripts": "Scripts",
|
||||
"settings.section.callbacks": "Callbacks",
|
||||
"settings.section.links": "Links",
|
||||
"settings.section.audio": "Audio",
|
||||
"settings.audio.description": "Select which audio output device to capture for the visualizer.",
|
||||
"settings.audio.device": "Loopback Device",
|
||||
"settings.audio.auto": "Auto-detect",
|
||||
"settings.audio.status_active": "Capturing audio",
|
||||
"settings.audio.status_available": "Available, not capturing",
|
||||
"settings.audio.status_unavailable": "Unavailable",
|
||||
"settings.audio.device_changed": "Audio device changed",
|
||||
"settings.audio.device_change_failed": "Failed to change audio device",
|
||||
"quick_access.no_items": "No quick actions or links configured",
|
||||
"display.loading": "Loading monitors...",
|
||||
"display.error": "Failed to load monitors",
|
||||
"display.no_monitors": "No monitors detected",
|
||||
"display.power_on": "Turn on",
|
||||
"display.power_off": "Turn off",
|
||||
"display.primary": "Primary",
|
||||
"display.brightness": "Brightness",
|
||||
"display.contrast": "Contrast",
|
||||
"display.tuning": "Picture tuning",
|
||||
"display.input_source": "Input",
|
||||
"display.color_preset": "Color temp",
|
||||
"display.picture_mode": "Picture mode",
|
||||
"display.msg.contrast_failed": "Failed to set contrast",
|
||||
"display.msg.input_changed": "Input source switched",
|
||||
"display.msg.input_failed": "Failed to switch input source",
|
||||
"display.msg.color_changed": "Color preset applied",
|
||||
"display.msg.color_failed": "Failed to apply color preset",
|
||||
"display.msg.mode_changed": "Picture mode applied",
|
||||
"display.msg.mode_failed": "Failed to apply picture mode",
|
||||
"browser.title": "Media Browser",
|
||||
"browser.home": "Home",
|
||||
"browser.manage_folders": "Manage Folders",
|
||||
@@ -149,7 +197,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 *",
|
||||
@@ -161,6 +229,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.",
|
||||
@@ -179,10 +252,12 @@
|
||||
"links.field.url": "URL *",
|
||||
"links.field.icon": "Icon (MDI)",
|
||||
"links.field.label": "Label",
|
||||
"links.field.description": "Description",
|
||||
"links.placeholder.name": "Only letters, numbers, and underscores allowed",
|
||||
"links.placeholder.url": "https://example.com",
|
||||
"links.placeholder.icon": "mdi:link",
|
||||
"links.placeholder.label": "Tooltip text",
|
||||
"links.placeholder.description": "What does this link point to?",
|
||||
"links.button.cancel": "Cancel",
|
||||
"links.button.save": "Save",
|
||||
"links.button.edit": "Edit",
|
||||
@@ -197,6 +272,13 @@
|
||||
"links.msg.load_failed": "Failed to load link details",
|
||||
"links.confirm.delete": "Are you sure you want to delete the link \"{name}\"?",
|
||||
"links.confirm.unsaved": "You have unsaved changes. Are you sure you want to discard them?",
|
||||
"footer.created_by": "Created by",
|
||||
"footer.source_code": "Source Code"
|
||||
"about.button_title": "About",
|
||||
"about.title": "About",
|
||||
"about.created_by": "Created by",
|
||||
"about.email": "Email",
|
||||
"about.repository": "Repository",
|
||||
"about.source_code": "Source Code",
|
||||
"dialog.close": "Close",
|
||||
"update.available": "Update available: v{version}",
|
||||
"update.view_release": "View Release"
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"auth.cleared": "Токен очищен. Пожалуйста, введите новый токен.",
|
||||
"auth.required": "Пожалуйста, введите токен",
|
||||
"player.theme": "Переключить тему",
|
||||
"accent.custom": "Свой цвет",
|
||||
"player.locale": "Изменить язык",
|
||||
"player.previous": "Предыдущий",
|
||||
"player.play": "Воспроизвести/Пауза",
|
||||
@@ -18,10 +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": "Остановлено",
|
||||
@@ -71,6 +85,15 @@
|
||||
"scripts.execution.error_output": "Вывод ошибок",
|
||||
"scripts.execution.close": "Закрыть",
|
||||
"scripts.confirm.unsaved": "У вас есть несохраненные изменения. Вы уверены, что хотите отменить их?",
|
||||
"scripts.field.parameters": "Параметры",
|
||||
"scripts.params.add": "+ Добавить",
|
||||
"scripts.params.remove": "Удалить параметр",
|
||||
"scripts.params.required": "Обязательный",
|
||||
"scripts.params.name_placeholder": "имя_параметра",
|
||||
"scripts.params.description_placeholder": "Описание параметра",
|
||||
"scripts.params.default_placeholder": "По умолчанию",
|
||||
"scripts.params.options_placeholder": "вариант1, вариант2, ...",
|
||||
"scripts.params.execute": "Выполнить",
|
||||
"callbacks.management": "Управление Обратными Вызовами",
|
||||
"callbacks.description": "Обратные вызовы - это скрипты, автоматически запускаемые при событиях управления медиа (воспроизведение, пауза, остановка и т.д.)",
|
||||
"callbacks.add": "Добавить",
|
||||
@@ -114,18 +137,43 @@
|
||||
"callbacks.msg.list_failed": "Не удалось загрузить обратные вызовы",
|
||||
"callbacks.confirm.delete": "Вы уверены, что хотите удалить обратный вызов \"{name}\"?",
|
||||
"callbacks.confirm.unsaved": "У вас есть несохраненные изменения. Вы уверены, что хотите отменить их?",
|
||||
"tab.player": "Плеер",
|
||||
"tab.browser": "Браузер",
|
||||
"tab.quick_actions": "Действия",
|
||||
"tab.scripts": "Скрипты",
|
||||
"tab.callbacks": "Колбэки",
|
||||
"tab.links": "Ссылки",
|
||||
"tab.player": "Сейчас играет",
|
||||
"tab.browser": "Библиотека",
|
||||
"tab.quick_access": "Быстрый Доступ",
|
||||
"tab.settings": "Настройки",
|
||||
"tab.display": "Дисплей",
|
||||
"settings.section.scripts": "Скрипты",
|
||||
"settings.section.callbacks": "Колбэки",
|
||||
"settings.section.links": "Ссылки",
|
||||
"settings.section.audio": "Аудио",
|
||||
"settings.audio.description": "Выберите аудиоустройство для захвата звука визуализатора.",
|
||||
"settings.audio.device": "Устройство захвата",
|
||||
"settings.audio.auto": "Автоопределение",
|
||||
"settings.audio.status_active": "Захват аудио",
|
||||
"settings.audio.status_available": "Доступно, не захватывает",
|
||||
"settings.audio.status_unavailable": "Недоступно",
|
||||
"settings.audio.device_changed": "Аудиоустройство изменено",
|
||||
"settings.audio.device_change_failed": "Не удалось изменить аудиоустройство",
|
||||
"quick_access.no_items": "Быстрые действия и ссылки не настроены",
|
||||
"display.loading": "Загрузка мониторов...",
|
||||
"display.error": "Не удалось загрузить мониторы",
|
||||
"display.no_monitors": "Мониторы не обнаружены",
|
||||
"display.power_on": "Включить",
|
||||
"display.power_off": "Выключить",
|
||||
"display.primary": "Основной",
|
||||
"display.brightness": "Яркость",
|
||||
"display.contrast": "Контраст",
|
||||
"display.tuning": "Настройка изображения",
|
||||
"display.input_source": "Вход",
|
||||
"display.color_preset": "Цветовая температура",
|
||||
"display.picture_mode": "Режим изображения",
|
||||
"display.msg.contrast_failed": "Не удалось установить контраст",
|
||||
"display.msg.input_changed": "Источник входа переключён",
|
||||
"display.msg.input_failed": "Не удалось переключить источник",
|
||||
"display.msg.color_changed": "Цветовая температура применена",
|
||||
"display.msg.color_failed": "Не удалось применить цветовую температуру",
|
||||
"display.msg.mode_changed": "Режим изображения применён",
|
||||
"display.msg.mode_failed": "Не удалось применить режим изображения",
|
||||
"browser.title": "Медиа Браузер",
|
||||
"browser.home": "Главная",
|
||||
"browser.manage_folders": "Управление папками",
|
||||
@@ -149,7 +197,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 папки *",
|
||||
@@ -161,6 +229,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": "Соединение потеряно. Сервер может быть недоступен.",
|
||||
@@ -179,10 +252,12 @@
|
||||
"links.field.url": "URL *",
|
||||
"links.field.icon": "Иконка (MDI)",
|
||||
"links.field.label": "Метка",
|
||||
"links.field.description": "Описание",
|
||||
"links.placeholder.name": "Только буквы, цифры и подчеркивания",
|
||||
"links.placeholder.url": "https://example.com",
|
||||
"links.placeholder.icon": "mdi:link",
|
||||
"links.placeholder.label": "Текст подсказки",
|
||||
"links.placeholder.description": "Куда ведет эта ссылка?",
|
||||
"links.button.cancel": "Отмена",
|
||||
"links.button.save": "Сохранить",
|
||||
"links.button.edit": "Редактировать",
|
||||
@@ -197,6 +272,13 @@
|
||||
"links.msg.load_failed": "Не удалось загрузить данные ссылки",
|
||||
"links.confirm.delete": "Вы уверены, что хотите удалить ссылку \"{name}\"?",
|
||||
"links.confirm.unsaved": "У вас есть несохраненные изменения. Вы уверены, что хотите отменить их?",
|
||||
"footer.created_by": "Создано",
|
||||
"footer.source_code": "Исходный код"
|
||||
"about.button_title": "О программе",
|
||||
"about.title": "О программе",
|
||||
"about.created_by": "Создано",
|
||||
"about.email": "Эл. почта",
|
||||
"about.repository": "Репозиторий",
|
||||
"about.source_code": "Исходный код",
|
||||
"dialog.close": "Закрыть",
|
||||
"update.available": "Доступно обновление: v{version}",
|
||||
"update.view_release": "Перейти к релизу"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "Media Server",
|
||||
"short_name": "Media",
|
||||
"description": "Remote media player control and file browser",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#121212",
|
||||
"theme_color": "#121212",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/icons/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
// Minimal service worker for PWA installability.
|
||||
// This app requires a live WebSocket connection, so offline caching is not useful.
|
||||
// All fetch requests are passed through to the network.
|
||||
|
||||
self.addEventListener('install', () => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
event.respondWith(fetch(event.request));
|
||||
});
|
||||
@@ -0,0 +1,911 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vinyl Variants · Studio Reference</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/icons/icon.svg">
|
||||
|
||||
<style>
|
||||
/* ───────── Local fonts (re-using main app's woff2 files) ───── */
|
||||
@font-face {
|
||||
font-family: 'Fraunces';
|
||||
font-style: italic;
|
||||
font-weight: 300 900;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/Fraunces-italic-latin.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Fraunces';
|
||||
font-style: normal;
|
||||
font-weight: 300 900;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/Fraunces-latin.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Geist';
|
||||
font-style: normal;
|
||||
font-weight: 300 700;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/Geist-latin.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Geist Mono';
|
||||
font-style: normal;
|
||||
font-weight: 300 600;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/GeistMono-latin.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* ───────── Tokens (Studio Reference, dark) ───── */
|
||||
:root {
|
||||
--bg-deep: #0E0D0B;
|
||||
--bg-paper: #18150F;
|
||||
--bg-card: #211E18;
|
||||
--bg-card-2: #26211A;
|
||||
--bg-rule: #2E2820;
|
||||
--ink: #F2EBDC;
|
||||
--ink-soft: #D6CDB9;
|
||||
--ink-mute: #9C937F;
|
||||
--ink-faint: #5C5447;
|
||||
--ink-ghost: #3A3528;
|
||||
--copper: #E08038;
|
||||
--copper-hi: #F4A064;
|
||||
--copper-lo: #B0561F;
|
||||
--copper-glow: rgba(224, 128, 56, 0.35);
|
||||
--rule: rgba(242, 235, 220, 0.08);
|
||||
--rule-strong: rgba(242, 235, 220, 0.18);
|
||||
--serif: 'Fraunces', Georgia, serif;
|
||||
--sans: 'Geist', system-ui, sans-serif;
|
||||
--mono: 'Geist Mono', ui-monospace, monospace;
|
||||
--ease: cubic-bezier(.2, .7, .2, 1);
|
||||
--ease-out: cubic-bezier(.16, 1, .3, 1);
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
html { background: var(--bg-deep); }
|
||||
body {
|
||||
font-family: var(--sans);
|
||||
background: var(--bg-deep);
|
||||
color: var(--ink);
|
||||
min-height: 100vh;
|
||||
padding: 56px 36px 80px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
/* Film grain */
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
opacity: 0.05;
|
||||
mix-blend-mode: overlay;
|
||||
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.95 0 0 0 0 0.92 0 0 0 0 0.86 0 0 0 0.7 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* ───────── Page header (editorial) ───── */
|
||||
header.page-head {
|
||||
max-width: 1320px;
|
||||
margin: 0 auto 48px;
|
||||
text-align: center;
|
||||
}
|
||||
.kicker {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.32em;
|
||||
text-transform: uppercase;
|
||||
color: var(--copper);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
.kicker::before, .kicker::after {
|
||||
content: "";
|
||||
height: 1px;
|
||||
width: 40px;
|
||||
background: var(--copper);
|
||||
opacity: 0.6;
|
||||
}
|
||||
h1 {
|
||||
font-family: var(--serif);
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-size: clamp(36px, 5vw, 56px);
|
||||
line-height: 1;
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 14px;
|
||||
font-variation-settings: 'opsz' 144;
|
||||
}
|
||||
.subtitle {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-mute);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.return-link {
|
||||
display: inline-block;
|
||||
margin-top: 24px;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-faint);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid var(--ink-faint);
|
||||
padding-bottom: 2px;
|
||||
transition: all 200ms var(--ease);
|
||||
}
|
||||
.return-link:hover { color: var(--copper); border-color: var(--copper); }
|
||||
|
||||
/* ───────── Variant grid ───── */
|
||||
.grid {
|
||||
max-width: 1320px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
|
||||
gap: 56px 40px;
|
||||
}
|
||||
|
||||
article.variant {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.stage {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
width: 100%;
|
||||
background:
|
||||
radial-gradient(ellipse at center, var(--bg-card-2) 0%, var(--bg-deep) 80%);
|
||||
border: 1px solid var(--rule);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: visible;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.label-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.label-num {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.2em;
|
||||
color: var(--copper);
|
||||
}
|
||||
.label-name {
|
||||
font-family: var(--serif);
|
||||
font-style: italic;
|
||||
font-size: 22px;
|
||||
font-weight: 400;
|
||||
font-variation-settings: 'opsz' 60;
|
||||
flex: 1;
|
||||
}
|
||||
.label-tag {
|
||||
font-family: var(--mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-faint);
|
||||
padding: 3px 8px;
|
||||
border: 1px solid var(--rule-strong);
|
||||
}
|
||||
.tag-css { color: var(--jade, #7AB294); border-color: rgba(122, 178, 148, 0.3); }
|
||||
.tag-needs-js { color: var(--copper); border-color: var(--copper-lo); }
|
||||
|
||||
p.descr {
|
||||
font-family: var(--sans);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
p.descr strong {
|
||||
color: var(--ink);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ───────── Shared vinyl base ───── */
|
||||
.vinyl {
|
||||
position: relative;
|
||||
width: 86%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
background:
|
||||
radial-gradient(circle at 50% 50%,
|
||||
#0a0907 0%, #0a0907 18%,
|
||||
#1a1611 18.3%, #0a0907 18.6%,
|
||||
#14110c 22%, #0a0907 22.3%,
|
||||
#14110c 26%, #0a0907 26.3%,
|
||||
#14110c 30%, #0a0907 30.3%,
|
||||
#14110c 34%, #0a0907 34.3%,
|
||||
#14110c 38%, #0a0907 38.3%,
|
||||
#14110c 42%, #0a0907 42.3%,
|
||||
#14110c 46%, #0a0907 46.3%,
|
||||
#1c1812 47%, #0a0907 100%);
|
||||
box-shadow:
|
||||
inset 0 0 60px rgba(0, 0, 0, 0.7),
|
||||
0 30px 80px rgba(0, 0, 0, 0.6),
|
||||
0 6px 20px rgba(0, 0, 0, 0.5);
|
||||
animation: spin 14s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.vinyl::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 12%;
|
||||
border-radius: 50%;
|
||||
background:
|
||||
conic-gradient(from 0deg,
|
||||
rgba(255,255,255,0.04) 0deg,
|
||||
transparent 30deg,
|
||||
rgba(255,255,255,0.06) 90deg,
|
||||
transparent 150deg,
|
||||
rgba(255,255,255,0.03) 210deg,
|
||||
transparent 270deg,
|
||||
rgba(255,255,255,0.05) 330deg,
|
||||
transparent 360deg);
|
||||
mix-blend-mode: screen;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.vinyl-label {
|
||||
position: absolute;
|
||||
inset: 28%;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
inset 0 0 24px rgba(0, 0, 0, 0.4),
|
||||
0 0 0 4px var(--bg-deep),
|
||||
0 0 0 5px var(--copper-lo);
|
||||
background: var(--bg-card);
|
||||
z-index: 1;
|
||||
}
|
||||
.vinyl-label::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 8%; height: 8%;
|
||||
top: 46%; left: 46%;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-deep);
|
||||
box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.1);
|
||||
z-index: 3;
|
||||
}
|
||||
.vinyl-label img,
|
||||
.vinyl-label svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Album art (shared SVG used by every variant) */
|
||||
.album-art {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Tonearm (decorative, on every stage so they read as "now playing") */
|
||||
.tonearm {
|
||||
position: absolute;
|
||||
top: -4%;
|
||||
right: -2%;
|
||||
width: 50%;
|
||||
height: 50%;
|
||||
pointer-events: none;
|
||||
transform-origin: 88% 12%;
|
||||
transform: rotate(0deg);
|
||||
z-index: 5;
|
||||
filter: drop-shadow(0 4px 12px rgba(0,0,0,0.5));
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
ORIGINAL — current shipping look (control)
|
||||
════════════════════════════════════════════════════════════════ */
|
||||
.v0 .stage { /* nothing extra */ }
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
VARIANT 1 — Sleeve frame
|
||||
Vinyl peeks out of a square cardstock sleeve.
|
||||
════════════════════════════════════════════════════════════════ */
|
||||
.v1 .stage {
|
||||
background:
|
||||
radial-gradient(ellipse at center, #1a1611 0%, var(--bg-deep) 80%);
|
||||
}
|
||||
.v1 .sleeve-stage {
|
||||
position: relative;
|
||||
width: 90%;
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.v1 .sleeve {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 6%;
|
||||
width: 70%;
|
||||
aspect-ratio: 1;
|
||||
background: var(--bg-card-2);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(0,0,0,0.4),
|
||||
inset 4px 4px 24px rgba(0,0,0,0.35),
|
||||
-2px 8px 24px rgba(0,0,0,0.5),
|
||||
-4px 16px 40px rgba(0,0,0,0.35);
|
||||
z-index: 3;
|
||||
/* Casually-placed tilt — like a sleeve set down on a console */
|
||||
transform: rotate(-3.2deg);
|
||||
transform-origin: 60% 60%;
|
||||
/* worn-edge cardstock effect */
|
||||
filter: contrast(1.05) brightness(0.97);
|
||||
}
|
||||
.v1 .sleeve::before {
|
||||
/* Cardstock paper grain */
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.2' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.10 0 0 0 0 0.08 0 0 0 0 0.06 0 0 0 0.7 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
mix-blend-mode: multiply;
|
||||
pointer-events: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.v1 .sleeve::after {
|
||||
/* Ring-wear: faint circle from the LP rubbing the cardstock */
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 6%;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(0,0,0,0.25);
|
||||
box-shadow:
|
||||
inset 0 0 12px rgba(0,0,0,0.18),
|
||||
inset 0 0 0 1px rgba(255,255,255,0.04);
|
||||
pointer-events: none;
|
||||
}
|
||||
.v1 .sleeve-art {
|
||||
position: absolute;
|
||||
inset: 6%;
|
||||
z-index: 1;
|
||||
filter: contrast(0.88) saturate(0.6) brightness(0.88);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.v1 .sleeve-art svg { width: 100%; height: 100%; }
|
||||
/* Worn corner notch */
|
||||
.v1 .sleeve-corner {
|
||||
position: absolute;
|
||||
width: 14%;
|
||||
height: 14%;
|
||||
bottom: -1px;
|
||||
right: -1px;
|
||||
background: var(--bg-deep);
|
||||
clip-path: polygon(100% 0, 100% 100%, 0 100%);
|
||||
opacity: 0.7;
|
||||
z-index: 4;
|
||||
}
|
||||
.v1 .vinyl-wrap {
|
||||
position: absolute;
|
||||
right: -2%;
|
||||
top: 16%;
|
||||
width: 70%;
|
||||
aspect-ratio: 1;
|
||||
z-index: 2;
|
||||
}
|
||||
.v1 .vinyl-wrap .vinyl {
|
||||
width: 100%;
|
||||
}
|
||||
.v1 .vinyl-label {
|
||||
/* Smaller label since the disc here is showing; album art lives on sleeve */
|
||||
inset: 32%;
|
||||
background: #2E2820;
|
||||
box-shadow:
|
||||
inset 0 0 18px rgba(0,0,0,0.4),
|
||||
0 0 0 3px var(--bg-deep),
|
||||
0 0 0 4px var(--copper-lo);
|
||||
}
|
||||
.v1 .vinyl-label::before {
|
||||
/* Plain-color label with faux pressing imprint */
|
||||
content: "REF · 24";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.3em;
|
||||
color: var(--copper);
|
||||
z-index: 2;
|
||||
}
|
||||
.v1 .tonearm {
|
||||
right: -8%;
|
||||
top: 8%;
|
||||
width: 44%;
|
||||
height: 44%;
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
VARIANT 2 — Sheen + paper grain + dead-wax + off-center
|
||||
The high-impact variant.
|
||||
════════════════════════════════════════════════════════════════ */
|
||||
.v2 .vinyl-label {
|
||||
/* Slightly off-center spindle for "pressed off-axis" feel */
|
||||
inset: 27% 27% 29% 29%;
|
||||
}
|
||||
.v2 .vinyl-label::after {
|
||||
/* Spindle hole offset 1.5% from true center */
|
||||
top: 47%;
|
||||
left: 47.5%;
|
||||
}
|
||||
/* Paper grain on the label, multiplied so it sits inside the print */
|
||||
.v2 .vinyl-label .label-grain {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.6' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.05 0 0 0 0 0.04 0 0 0 0 0.03 0 0 0 0.55 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
mix-blend-mode: multiply;
|
||||
pointer-events: none;
|
||||
z-index: 4;
|
||||
}
|
||||
/* Dead-wax: micro-text engraved between the label and the run-out groove */
|
||||
.v2 .dead-wax {
|
||||
position: absolute;
|
||||
inset: 21%;
|
||||
border-radius: 50%;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
/* Animation OFF the disc — engraving is part of the press, so it does spin with the vinyl */
|
||||
animation: spin 14s linear infinite;
|
||||
}
|
||||
.v2 .dead-wax svg { width: 100%; height: 100%; }
|
||||
/* Reflection sweep — fixed in viewer space, not rotating with the disc */
|
||||
.v2 .sheen {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
background:
|
||||
conic-gradient(from 110deg,
|
||||
transparent 0deg,
|
||||
rgba(255, 245, 220, 0) 30deg,
|
||||
rgba(255, 245, 220, 0.07) 60deg,
|
||||
rgba(255, 245, 220, 0.14) 80deg,
|
||||
rgba(255, 245, 220, 0.07) 100deg,
|
||||
transparent 140deg,
|
||||
transparent 280deg,
|
||||
rgba(255, 245, 220, 0.04) 305deg,
|
||||
rgba(255, 245, 220, 0.08) 320deg,
|
||||
rgba(255, 245, 220, 0.04) 335deg,
|
||||
transparent 360deg);
|
||||
mix-blend-mode: screen;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
VARIANT 3 — Tone-graded album art (duotone)
|
||||
════════════════════════════════════════════════════════════════ */
|
||||
.v3 .vinyl-label .album-art {
|
||||
filter:
|
||||
saturate(0.35)
|
||||
sepia(0.45)
|
||||
hue-rotate(345deg)
|
||||
brightness(0.85)
|
||||
contrast(1.18);
|
||||
}
|
||||
.v3 .vinyl-label::before {
|
||||
/* Subtle copper duotone overlay tints the highlights */
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(135deg,
|
||||
rgba(224, 128, 56, 0.18) 0%,
|
||||
rgba(31, 78, 61, 0.10) 50%,
|
||||
rgba(0,0,0,0.18) 100%);
|
||||
mix-blend-mode: overlay;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
.v3 .vinyl-label::after {
|
||||
z-index: 4;
|
||||
}
|
||||
.v3 .vinyl-label .vignette {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(circle at 50% 45%,
|
||||
transparent 35%,
|
||||
rgba(0,0,0,0.45) 100%);
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
VARIANT 4 — Sleeve-to-disc reveal animation
|
||||
(Hover the card to see the disc slide out)
|
||||
════════════════════════════════════════════════════════════════ */
|
||||
.v4 .sleeve-stage {
|
||||
position: relative;
|
||||
width: 90%;
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.v4 .sleeve {
|
||||
position: absolute;
|
||||
left: 14%;
|
||||
top: 12%;
|
||||
width: 72%;
|
||||
aspect-ratio: 1;
|
||||
background: var(--bg-card-2);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(0,0,0,0.4),
|
||||
-2px 6px 18px rgba(0,0,0,0.5);
|
||||
z-index: 4;
|
||||
overflow: hidden;
|
||||
}
|
||||
.v4 .sleeve::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.2' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.10 0 0 0 0 0.08 0 0 0 0 0.06 0 0 0 0.5 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
mix-blend-mode: multiply;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
.v4 .sleeve-art {
|
||||
width: 100%; height: 100%;
|
||||
filter: contrast(0.92) saturate(0.7) brightness(0.92);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.v4 .vinyl-slot {
|
||||
position: absolute;
|
||||
left: 14%;
|
||||
top: 12%;
|
||||
width: 72%;
|
||||
aspect-ratio: 1;
|
||||
z-index: 3;
|
||||
transition: transform 1.2s var(--ease-out);
|
||||
}
|
||||
.v4 .vinyl-slot .vinyl {
|
||||
width: 100%;
|
||||
animation-play-state: paused;
|
||||
transition: animation-play-state 0.4s;
|
||||
}
|
||||
.v4 .stage:hover .vinyl-slot {
|
||||
transform: translateX(46%);
|
||||
}
|
||||
.v4 .stage:hover .vinyl-slot .vinyl {
|
||||
animation-play-state: running;
|
||||
}
|
||||
.v4 .hover-hint {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-family: var(--mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.24em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-faint);
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
.v4 .stage:hover .hover-hint { opacity: 0.4; }
|
||||
|
||||
/* Note row at top of every variant */
|
||||
.note {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 14px;
|
||||
font-family: var(--mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-faint);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* ───────── Mobile ───── */
|
||||
@media (max-width: 720px) {
|
||||
body { padding: 36px 16px 60px; }
|
||||
.grid { gap: 36px 20px; grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="page-head">
|
||||
<div class="kicker">Studio Reference · Album Art Variants</div>
|
||||
<h1>Vinyl Cover Treatments</h1>
|
||||
<p class="subtitle">Five renderings of the same disc · Hover variant 04 for the sleeve reveal</p>
|
||||
<a class="return-link" href="/">← Return to player</a>
|
||||
</header>
|
||||
|
||||
<div class="grid">
|
||||
|
||||
<!-- ═════════ ORIGINAL ═════════ -->
|
||||
<article class="variant v0">
|
||||
<div class="stage">
|
||||
<span class="note">As shipping</span>
|
||||
<div class="vinyl">
|
||||
<div class="vinyl-label">
|
||||
<svg viewBox="0 0 400 400" class="album-art" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bgA" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#1F4E3D"/>
|
||||
<stop offset="55%" stop-color="#143E2F"/>
|
||||
<stop offset="100%" stop-color="#0a2519"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="vigA" cx="50%" cy="50%" r="70%">
|
||||
<stop offset="55%" stop-color="rgba(0,0,0,0)"/>
|
||||
<stop offset="100%" stop-color="rgba(0,0,0,0.55)"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect width="400" height="400" fill="url(#bgA)"/>
|
||||
<g stroke="#e08038" stroke-width="1" fill="none" opacity="0.55">
|
||||
<circle cx="200" cy="200" r="60"/>
|
||||
<circle cx="200" cy="200" r="100"/>
|
||||
<circle cx="200" cy="200" r="140"/>
|
||||
<circle cx="200" cy="200" r="180"/>
|
||||
</g>
|
||||
<text x="200" y="195" text-anchor="middle" font-family="serif" font-style="italic" fill="#f2ebdc" font-size="34">Reference</text>
|
||||
<text x="200" y="225" text-anchor="middle" font-family="monospace" fill="#e08038" font-size="11" letter-spacing="4">VOL · I</text>
|
||||
<text x="200" y="368" text-anchor="middle" font-family="monospace" fill="#9c937f" font-size="9" letter-spacing="6" opacity="0.6">STUDIO PRESSING</text>
|
||||
<rect width="400" height="400" fill="url(#vigA)"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<svg class="tonearm" viewBox="0 0 200 200" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="armGrad0" x1="0" x2="1">
|
||||
<stop offset="0" stop-color="#3a3528"/>
|
||||
<stop offset="0.5" stop-color="#9C937F"/>
|
||||
<stop offset="1" stop-color="#5C5447"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="176" cy="24" r="14" fill="#1a1611" stroke="#3A3528" stroke-width="1"/>
|
||||
<circle cx="176" cy="24" r="6" fill="#3A3528"/>
|
||||
<circle cx="176" cy="24" r="2" fill="#E08038"/>
|
||||
<line x1="176" y1="24" x2="64" y2="136" stroke="url(#armGrad0)" stroke-width="3.5" stroke-linecap="round"/>
|
||||
<rect x="180" y="14" width="14" height="20" fill="#26211A" stroke="#3A3528"/>
|
||||
<rect x="56" y="128" width="22" height="18" rx="2" fill="#26211A" stroke="#3A3528" transform="rotate(-45 67 137)"/>
|
||||
<circle cx="62" cy="138" r="3" fill="#E08038" opacity="0.8"/>
|
||||
<circle cx="62" cy="138" r="6" fill="none" stroke="#E08038" stroke-width="0.5" opacity="0.4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="label-row">
|
||||
<span class="label-num">00</span>
|
||||
<span class="label-name">Original</span>
|
||||
<span class="label-tag tag-css">control</span>
|
||||
</div>
|
||||
<p class="descr">Current shipping vinyl: pressed grooves, copper-bordered label rim, full album art on the label. Reference baseline for everything below.</p>
|
||||
</article>
|
||||
|
||||
<!-- ═════════ VARIANT 1 — SLEEVE FRAME ═════════ -->
|
||||
<article class="variant v1">
|
||||
<div class="stage">
|
||||
<span class="note">CSS only</span>
|
||||
<div class="sleeve-stage">
|
||||
<div class="sleeve">
|
||||
<div class="sleeve-art">
|
||||
<svg viewBox="0 0 400 400" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bgB" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#1F4E3D"/>
|
||||
<stop offset="55%" stop-color="#143E2F"/>
|
||||
<stop offset="100%" stop-color="#0a2519"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="400" height="400" fill="url(#bgB)"/>
|
||||
<g stroke="#e08038" stroke-width="1" fill="none" opacity="0.55">
|
||||
<circle cx="200" cy="200" r="60"/>
|
||||
<circle cx="200" cy="200" r="100"/>
|
||||
<circle cx="200" cy="200" r="140"/>
|
||||
<circle cx="200" cy="200" r="180"/>
|
||||
</g>
|
||||
<text x="200" y="195" text-anchor="middle" font-family="serif" font-style="italic" fill="#f2ebdc" font-size="34">Reference</text>
|
||||
<text x="200" y="225" text-anchor="middle" font-family="monospace" fill="#e08038" font-size="11" letter-spacing="4">VOL · I</text>
|
||||
<text x="200" y="368" text-anchor="middle" font-family="monospace" fill="#9c937f" font-size="9" letter-spacing="6" opacity="0.6">STUDIO PRESSING</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="sleeve-corner"></div>
|
||||
</div>
|
||||
<div class="vinyl-wrap">
|
||||
<div class="vinyl">
|
||||
<div class="vinyl-label"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<svg class="tonearm" viewBox="0 0 200 200" aria-hidden="true">
|
||||
<use href="#armGrad0"/>
|
||||
<circle cx="176" cy="24" r="14" fill="#1a1611" stroke="#3A3528" stroke-width="1"/>
|
||||
<circle cx="176" cy="24" r="6" fill="#3A3528"/>
|
||||
<circle cx="176" cy="24" r="2" fill="#E08038"/>
|
||||
<line x1="176" y1="24" x2="80" y2="120" stroke="#9C937F" stroke-width="3.5" stroke-linecap="round"/>
|
||||
<rect x="180" y="14" width="14" height="20" fill="#26211A" stroke="#3A3528"/>
|
||||
<rect x="72" y="112" width="22" height="18" rx="2" fill="#26211A" stroke="#3A3528" transform="rotate(-45 83 121)"/>
|
||||
<circle cx="78" cy="122" r="3" fill="#E08038" opacity="0.8"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="label-row">
|
||||
<span class="label-num">01</span>
|
||||
<span class="label-name">Sleeve Frame</span>
|
||||
<span class="label-tag tag-css">CSS only</span>
|
||||
</div>
|
||||
<p class="descr">Vinyl peeks out of a square cardstock <strong>sleeve</strong> — paper grain, ring-wear circle, worn-corner notch. The album art lives on the sleeve; the disc gets a plain typographic label. Reads instantly as "record on a turntable", not "spinning disc."</p>
|
||||
</article>
|
||||
|
||||
<!-- ═════════ VARIANT 2 — SHEEN + GRAIN + DEAD-WAX ═════════ -->
|
||||
<article class="variant v2">
|
||||
<div class="stage">
|
||||
<span class="note">CSS only · highest ROI</span>
|
||||
<div class="vinyl">
|
||||
<div class="dead-wax">
|
||||
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<path id="dwPath" d="M 50,50 m -36,0 a 36,36 0 1,1 72,0 a 36,36 0 1,1 -72,0"/>
|
||||
</defs>
|
||||
<text font-family="monospace" font-size="2.4" fill="#3a3528" letter-spacing="0.45" opacity="0.85">
|
||||
<textPath href="#dwPath">· STUDIO REFERENCE PRESSING · A-SIDE · MASTER LACQUER 24-S · DOLG.AD MASTERED · ½ SPEED</textPath>
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="vinyl-label">
|
||||
<svg viewBox="0 0 400 400" class="album-art" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bgC" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#1F4E3D"/>
|
||||
<stop offset="55%" stop-color="#143E2F"/>
|
||||
<stop offset="100%" stop-color="#0a2519"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="400" height="400" fill="url(#bgC)"/>
|
||||
<g stroke="#e08038" stroke-width="1" fill="none" opacity="0.55">
|
||||
<circle cx="200" cy="200" r="60"/>
|
||||
<circle cx="200" cy="200" r="100"/>
|
||||
<circle cx="200" cy="200" r="140"/>
|
||||
<circle cx="200" cy="200" r="180"/>
|
||||
</g>
|
||||
<text x="200" y="195" text-anchor="middle" font-family="serif" font-style="italic" fill="#f2ebdc" font-size="34">Reference</text>
|
||||
<text x="200" y="225" text-anchor="middle" font-family="monospace" fill="#e08038" font-size="11" letter-spacing="4">VOL · I</text>
|
||||
<text x="200" y="368" text-anchor="middle" font-family="monospace" fill="#9c937f" font-size="9" letter-spacing="6" opacity="0.6">STUDIO PRESSING</text>
|
||||
</svg>
|
||||
<div class="label-grain"></div>
|
||||
</div>
|
||||
<div class="sheen"></div>
|
||||
</div>
|
||||
<svg class="tonearm" viewBox="0 0 200 200" aria-hidden="true">
|
||||
<circle cx="176" cy="24" r="14" fill="#1a1611" stroke="#3A3528" stroke-width="1"/>
|
||||
<circle cx="176" cy="24" r="6" fill="#3A3528"/>
|
||||
<circle cx="176" cy="24" r="2" fill="#E08038"/>
|
||||
<line x1="176" y1="24" x2="64" y2="136" stroke="#9C937F" stroke-width="3.5" stroke-linecap="round"/>
|
||||
<rect x="180" y="14" width="14" height="20" fill="#26211A" stroke="#3A3528"/>
|
||||
<rect x="56" y="128" width="22" height="18" rx="2" fill="#26211A" stroke="#3A3528" transform="rotate(-45 67 137)"/>
|
||||
<circle cx="62" cy="138" r="3" fill="#E08038" opacity="0.8"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="label-row">
|
||||
<span class="label-num">02</span>
|
||||
<span class="label-name">Sheen, Grain & Dead-Wax</span>
|
||||
<span class="label-tag tag-css">CSS only</span>
|
||||
</div>
|
||||
<p class="descr">Three layers added to the existing vinyl: a <strong>fixed reflection sweep</strong> (doesn't rotate with the disc — the studio-light look), <strong>paper grain</strong> on the label so the print sits in cardstock, and a <strong>dead-wax engraving</strong> of the master‑lacquer code spinning with the disc. Off-center spindle by 1.5%. Highest visual ROI for the smallest amount of new code.</p>
|
||||
</article>
|
||||
|
||||
<!-- ═════════ VARIANT 3 — TONE-GRADED ═════════ -->
|
||||
<article class="variant v3">
|
||||
<div class="stage">
|
||||
<span class="note">CSS only</span>
|
||||
<div class="vinyl">
|
||||
<div class="vinyl-label">
|
||||
<svg viewBox="0 0 400 400" class="album-art" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bgD" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#1F4E3D"/>
|
||||
<stop offset="55%" stop-color="#143E2F"/>
|
||||
<stop offset="100%" stop-color="#0a2519"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="400" height="400" fill="url(#bgD)"/>
|
||||
<g stroke="#e08038" stroke-width="1" fill="none" opacity="0.55">
|
||||
<circle cx="200" cy="200" r="60"/>
|
||||
<circle cx="200" cy="200" r="100"/>
|
||||
<circle cx="200" cy="200" r="140"/>
|
||||
<circle cx="200" cy="200" r="180"/>
|
||||
</g>
|
||||
<text x="200" y="195" text-anchor="middle" font-family="serif" font-style="italic" fill="#f2ebdc" font-size="34">Reference</text>
|
||||
<text x="200" y="225" text-anchor="middle" font-family="monospace" fill="#e08038" font-size="11" letter-spacing="4">VOL · I</text>
|
||||
<text x="200" y="368" text-anchor="middle" font-family="monospace" fill="#9c937f" font-size="9" letter-spacing="6" opacity="0.6">STUDIO PRESSING</text>
|
||||
</svg>
|
||||
<div class="vignette"></div>
|
||||
</div>
|
||||
</div>
|
||||
<svg class="tonearm" viewBox="0 0 200 200" aria-hidden="true">
|
||||
<circle cx="176" cy="24" r="14" fill="#1a1611" stroke="#3A3528" stroke-width="1"/>
|
||||
<circle cx="176" cy="24" r="6" fill="#3A3528"/>
|
||||
<circle cx="176" cy="24" r="2" fill="#E08038"/>
|
||||
<line x1="176" y1="24" x2="64" y2="136" stroke="#9C937F" stroke-width="3.5" stroke-linecap="round"/>
|
||||
<rect x="180" y="14" width="14" height="20" fill="#26211A" stroke="#3A3528"/>
|
||||
<rect x="56" y="128" width="22" height="18" rx="2" fill="#26211A" stroke="#3A3528" transform="rotate(-45 67 137)"/>
|
||||
<circle cx="62" cy="138" r="3" fill="#E08038" opacity="0.8"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="label-row">
|
||||
<span class="label-num">03</span>
|
||||
<span class="label-name">Tone-Graded Cover</span>
|
||||
<span class="label-tag tag-css">CSS only</span>
|
||||
</div>
|
||||
<p class="descr">Same disc, but the album art on the label is <strong>color-graded</strong> — duotone copper/emerald, deeper saturation drop, vignette around the label rim. Effect: every album cover ends up looking like it came from the same pressing plant, matching the Studio Reference chrome.</p>
|
||||
</article>
|
||||
|
||||
<!-- ═════════ VARIANT 4 — SLEEVE-TO-DISC REVEAL ═════════ -->
|
||||
<article class="variant v4">
|
||||
<div class="stage">
|
||||
<span class="note">CSS hover · JS in production</span>
|
||||
<div class="sleeve-stage">
|
||||
<div class="sleeve">
|
||||
<div class="sleeve-art">
|
||||
<svg viewBox="0 0 400 400" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bgE" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#1F4E3D"/>
|
||||
<stop offset="55%" stop-color="#143E2F"/>
|
||||
<stop offset="100%" stop-color="#0a2519"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="400" height="400" fill="url(#bgE)"/>
|
||||
<g stroke="#e08038" stroke-width="1" fill="none" opacity="0.55">
|
||||
<circle cx="200" cy="200" r="60"/>
|
||||
<circle cx="200" cy="200" r="100"/>
|
||||
<circle cx="200" cy="200" r="140"/>
|
||||
<circle cx="200" cy="200" r="180"/>
|
||||
</g>
|
||||
<text x="200" y="195" text-anchor="middle" font-family="serif" font-style="italic" fill="#f2ebdc" font-size="34">Reference</text>
|
||||
<text x="200" y="225" text-anchor="middle" font-family="monospace" fill="#e08038" font-size="11" letter-spacing="4">VOL · I</text>
|
||||
<text x="200" y="368" text-anchor="middle" font-family="monospace" fill="#9c937f" font-size="9" letter-spacing="6" opacity="0.6">STUDIO PRESSING</text>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vinyl-slot">
|
||||
<div class="vinyl">
|
||||
<div class="vinyl-label"></div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="hover-hint">Hover to play</span>
|
||||
</div>
|
||||
<svg class="tonearm" viewBox="0 0 200 200" aria-hidden="true">
|
||||
<circle cx="176" cy="24" r="14" fill="#1a1611" stroke="#3A3528" stroke-width="1"/>
|
||||
<circle cx="176" cy="24" r="6" fill="#3A3528"/>
|
||||
<circle cx="176" cy="24" r="2" fill="#E08038"/>
|
||||
<line x1="176" y1="24" x2="64" y2="136" stroke="#9C937F" stroke-width="3.5" stroke-linecap="round"/>
|
||||
<rect x="180" y="14" width="14" height="20" fill="#26211A" stroke="#3A3528"/>
|
||||
<rect x="56" y="128" width="22" height="18" rx="2" fill="#26211A" stroke="#3A3528" transform="rotate(-45 67 137)"/>
|
||||
<circle cx="62" cy="138" r="3" fill="#E08038" opacity="0.8"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="label-row">
|
||||
<span class="label-num">04</span>
|
||||
<span class="label-name">Sleeve-to-Disc Reveal</span>
|
||||
<span class="label-tag tag-needs-js">needs JS</span>
|
||||
</div>
|
||||
<p class="descr"><strong>Hover this card</strong> — the disc slides out of the sleeve and starts spinning. In production, this would be wired to the play/pause state: paused = tucked-in sleeve view, playing = disc revealed and spinning. Most evocative, also the most code (animation choreography + state coupling).</p>
|
||||
</article>
|
||||
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,147 @@
|
||||
"""System tray icon for Media Server."""
|
||||
|
||||
import ctypes
|
||||
import io
|
||||
import logging
|
||||
import webbrowser
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# pystray is optional — tray silently disabled when missing
|
||||
try:
|
||||
import pystray
|
||||
|
||||
PYSTRAY_AVAILABLE = True
|
||||
except ImportError:
|
||||
pystray = None
|
||||
PYSTRAY_AVAILABLE = False
|
||||
|
||||
|
||||
# Windows-native confirmation (no tkinter needed)
|
||||
_MB_YESNO = 0x04
|
||||
_MB_ICONQUESTION = 0x20
|
||||
_MB_TOPMOST = 0x40000
|
||||
_MB_SETFOREGROUND = 0x10000
|
||||
_IDYES = 6
|
||||
|
||||
|
||||
def _confirm(title: str, message: str) -> bool:
|
||||
"""Show a Yes/No dialog using native Windows MessageBox."""
|
||||
result = ctypes.windll.user32.MessageBoxW(
|
||||
0, message, title, _MB_YESNO | _MB_ICONQUESTION | _MB_TOPMOST | _MB_SETFOREGROUND
|
||||
)
|
||||
return result == _IDYES
|
||||
|
||||
|
||||
def _create_icon_image(size: int = 64) -> Image.Image:
|
||||
"""Create a tray icon: green circle with white play triangle."""
|
||||
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Green circle background
|
||||
padding = 2
|
||||
draw.ellipse(
|
||||
[padding, padding, size - padding, size - padding],
|
||||
fill=(29, 185, 84, 255),
|
||||
)
|
||||
|
||||
# White play triangle
|
||||
cx, cy = size // 2, size // 2
|
||||
r = size * 0.28
|
||||
triangle = [
|
||||
(cx - r * 0.6, cy - r),
|
||||
(cx - r * 0.6, cy + r),
|
||||
(cx + r * 0.9, cy),
|
||||
]
|
||||
draw.polygon(triangle, fill=(255, 255, 255, 255))
|
||||
|
||||
return img
|
||||
|
||||
|
||||
def _load_icon_image() -> Image.Image:
|
||||
"""Load the ICO/SVG app icon, falling back to a generated image."""
|
||||
icons_dir = Path(__file__).parent / "static" / "icons"
|
||||
|
||||
# Try .ico first (best for Windows tray)
|
||||
ico_path = icons_dir / "icon.ico"
|
||||
if ico_path.exists():
|
||||
try:
|
||||
return Image.open(ico_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Try SVG via cairosvg
|
||||
try:
|
||||
import cairosvg
|
||||
|
||||
svg_path = icons_dir / "icon.svg"
|
||||
if svg_path.exists():
|
||||
png_data = cairosvg.svg2png(url=str(svg_path), output_width=64, output_height=64)
|
||||
return Image.open(io.BytesIO(png_data))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return _create_icon_image()
|
||||
|
||||
|
||||
class TrayManager:
|
||||
"""Manages the system tray icon and its context menu.
|
||||
|
||||
Call ``run()`` on the **main thread** — it blocks until ``stop()``
|
||||
is called (from any thread) or the user picks *Shutdown* from the menu.
|
||||
"""
|
||||
|
||||
def __init__(self, port: int, on_exit: Callable[[], None]) -> None:
|
||||
if not PYSTRAY_AVAILABLE:
|
||||
raise ImportError("pystray is required for system tray support")
|
||||
|
||||
self._port = port
|
||||
self._on_exit = on_exit
|
||||
|
||||
menu = pystray.Menu(
|
||||
pystray.MenuItem("Show UI", self._show_ui, default=True),
|
||||
pystray.Menu.SEPARATOR,
|
||||
pystray.MenuItem("Restart", self._restart),
|
||||
pystray.MenuItem("Shutdown", self._shutdown),
|
||||
)
|
||||
|
||||
self._icon = pystray.Icon(
|
||||
name="media-server",
|
||||
icon=_load_icon_image(),
|
||||
title="Media Server",
|
||||
menu=menu,
|
||||
)
|
||||
|
||||
def _show_ui(self, icon: "pystray.Icon", item: "pystray.MenuItem") -> None:
|
||||
webbrowser.open(f"http://localhost:{self._port}")
|
||||
|
||||
def _restart(self, icon: "pystray.Icon", item: "pystray.MenuItem") -> None:
|
||||
if not _confirm("Media Server", "Restart the server?"):
|
||||
return
|
||||
logger.info("Restart requested from tray")
|
||||
self._restart_requested = True
|
||||
self._on_exit()
|
||||
self._icon.stop()
|
||||
|
||||
@property
|
||||
def restart_requested(self) -> bool:
|
||||
return getattr(self, "_restart_requested", False)
|
||||
|
||||
def _shutdown(self, icon: "pystray.Icon", item: "pystray.MenuItem") -> None:
|
||||
if not _confirm("Media Server", "Shut down the server?"):
|
||||
return
|
||||
logger.info("Shutdown requested from tray")
|
||||
self._on_exit()
|
||||
self._icon.stop()
|
||||
|
||||
def run(self) -> None:
|
||||
"""Block the calling thread running the tray message loop."""
|
||||
self._icon.run()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the tray icon from any thread."""
|
||||
self._icon.stop()
|
||||
Generated
+690
@@ -0,0 +1,690 @@
|
||||
{
|
||||
"name": "media-server-frontend",
|
||||
"version": "0.2.4",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "media-server-frontend",
|
||||
"version": "0.2.4",
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.27.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
|
||||
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
|
||||
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
|
||||
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
|
||||
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
|
||||
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
|
||||
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
|
||||
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
|
||||
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
|
||||
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
|
||||
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
|
||||
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.27.4",
|
||||
"@esbuild/android-arm": "0.27.4",
|
||||
"@esbuild/android-arm64": "0.27.4",
|
||||
"@esbuild/android-x64": "0.27.4",
|
||||
"@esbuild/darwin-arm64": "0.27.4",
|
||||
"@esbuild/darwin-x64": "0.27.4",
|
||||
"@esbuild/freebsd-arm64": "0.27.4",
|
||||
"@esbuild/freebsd-x64": "0.27.4",
|
||||
"@esbuild/linux-arm": "0.27.4",
|
||||
"@esbuild/linux-arm64": "0.27.4",
|
||||
"@esbuild/linux-ia32": "0.27.4",
|
||||
"@esbuild/linux-loong64": "0.27.4",
|
||||
"@esbuild/linux-mips64el": "0.27.4",
|
||||
"@esbuild/linux-ppc64": "0.27.4",
|
||||
"@esbuild/linux-riscv64": "0.27.4",
|
||||
"@esbuild/linux-s390x": "0.27.4",
|
||||
"@esbuild/linux-x64": "0.27.4",
|
||||
"@esbuild/netbsd-arm64": "0.27.4",
|
||||
"@esbuild/netbsd-x64": "0.27.4",
|
||||
"@esbuild/openbsd-arm64": "0.27.4",
|
||||
"@esbuild/openbsd-x64": "0.27.4",
|
||||
"@esbuild/openharmony-arm64": "0.27.4",
|
||||
"@esbuild/sunos-x64": "0.27.4",
|
||||
"@esbuild/win32-arm64": "0.27.4",
|
||||
"@esbuild/win32-ia32": "0.27.4",
|
||||
"@esbuild/win32-x64": "0.27.4"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@esbuild/aix-ppc64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
|
||||
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@esbuild/android-arm": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
|
||||
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@esbuild/android-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@esbuild/android-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@esbuild/darwin-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@esbuild/darwin-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@esbuild/freebsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@esbuild/freebsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@esbuild/linux-arm": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
|
||||
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@esbuild/linux-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@esbuild/linux-ia32": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
|
||||
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@esbuild/linux-loong64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
|
||||
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@esbuild/linux-mips64el": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
|
||||
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@esbuild/linux-ppc64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
|
||||
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@esbuild/linux-riscv64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
|
||||
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@esbuild/linux-s390x": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
|
||||
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@esbuild/linux-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@esbuild/netbsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@esbuild/openbsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@esbuild/sunos-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@esbuild/win32-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@esbuild/win32-ia32": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
|
||||
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@esbuild/win32-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"esbuild": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
|
||||
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@esbuild/aix-ppc64": "0.27.4",
|
||||
"@esbuild/android-arm": "0.27.4",
|
||||
"@esbuild/android-arm64": "0.27.4",
|
||||
"@esbuild/android-x64": "0.27.4",
|
||||
"@esbuild/darwin-arm64": "0.27.4",
|
||||
"@esbuild/darwin-x64": "0.27.4",
|
||||
"@esbuild/freebsd-arm64": "0.27.4",
|
||||
"@esbuild/freebsd-x64": "0.27.4",
|
||||
"@esbuild/linux-arm": "0.27.4",
|
||||
"@esbuild/linux-arm64": "0.27.4",
|
||||
"@esbuild/linux-ia32": "0.27.4",
|
||||
"@esbuild/linux-loong64": "0.27.4",
|
||||
"@esbuild/linux-mips64el": "0.27.4",
|
||||
"@esbuild/linux-ppc64": "0.27.4",
|
||||
"@esbuild/linux-riscv64": "0.27.4",
|
||||
"@esbuild/linux-s390x": "0.27.4",
|
||||
"@esbuild/linux-x64": "0.27.4",
|
||||
"@esbuild/netbsd-arm64": "0.27.4",
|
||||
"@esbuild/netbsd-x64": "0.27.4",
|
||||
"@esbuild/openbsd-arm64": "0.27.4",
|
||||
"@esbuild/openbsd-x64": "0.27.4",
|
||||
"@esbuild/openharmony-arm64": "0.27.4",
|
||||
"@esbuild/sunos-x64": "0.27.4",
|
||||
"@esbuild/win32-arm64": "0.27.4",
|
||||
"@esbuild/win32-ia32": "0.27.4",
|
||||
"@esbuild/win32-x64": "0.27.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "media-server-frontend",
|
||||
"version": "0.2.4",
|
||||
"private": true,
|
||||
"description": "Frontend build tooling for media server WebUI",
|
||||
"scripts": {
|
||||
"build": "node esbuild.mjs",
|
||||
"watch": "node esbuild.mjs --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.27.4"
|
||||
}
|
||||
}
|
||||
+21
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "media-server"
|
||||
version = "1.0.0"
|
||||
version = "0.2.4"
|
||||
description = "REST API server for controlling system-wide media playback"
|
||||
readme = "README.md"
|
||||
license = { text = "MIT" }
|
||||
@@ -32,6 +32,8 @@ dependencies = [
|
||||
"pyyaml>=6.0",
|
||||
"mutagen>=1.47.0",
|
||||
"pillow>=10.0.0",
|
||||
"soundcard>=0.4.0",
|
||||
"numpy>=1.24.0,<2.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -41,12 +43,15 @@ windows = [
|
||||
"comtypes>=1.2.0",
|
||||
"pycaw>=20230407",
|
||||
"screen-brightness-control>=0.20.0",
|
||||
"wmi>=1.5.1",
|
||||
"monitorcontrol>=3.0.0",
|
||||
"pystray>=0.19.0",
|
||||
]
|
||||
dev = [
|
||||
"pytest>=7.0",
|
||||
"pytest-asyncio>=0.21",
|
||||
"httpx>=0.24",
|
||||
"ruff>=0.4.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -63,3 +68,18 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["media_server*"]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py310"
|
||||
line-length = 120
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "W"]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
# AppleScript string literals contain long lines that cannot be broken
|
||||
"media_server/services/macos_media.py" = ["E501"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
asyncio_mode = "auto"
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
@echo off
|
||||
REM Media Server Restart Script
|
||||
REM This script restarts the media server
|
||||
|
||||
echo Restarting Media Server...
|
||||
echo.
|
||||
|
||||
REM Stop the server first
|
||||
echo [1/2] Stopping server...
|
||||
call "%~dp0\stop-server.bat"
|
||||
|
||||
REM Wait a moment
|
||||
timeout /t 2 /nobreak >nul
|
||||
|
||||
REM Change to parent directory (media-server root)
|
||||
cd /d "%~dp0\.."
|
||||
|
||||
REM Start the server
|
||||
echo.
|
||||
echo [2/2] Starting server...
|
||||
python -m media_server.main
|
||||
|
||||
REM If the server exits, pause to show any error messages
|
||||
pause
|
||||
@@ -0,0 +1,103 @@
|
||||
# Restart the Media Server.
|
||||
#
|
||||
# Robust against the two ways the server gets started:
|
||||
# - Installer build: %LOCALAPPDATA%\Media Server\media-server.bat
|
||||
# (runs as python.exe -m media_server.main)
|
||||
# - Dev editable install: media-server console script on PATH
|
||||
# (runs as media-server.exe)
|
||||
#
|
||||
# The old version of this script only killed processes named 'media-server',
|
||||
# which silently missed the installer-bundled process (named 'python').
|
||||
# This version kills whatever currently owns the listen port, so it doesn't
|
||||
# matter how the previous instance was launched.
|
||||
|
||||
param(
|
||||
[ValidateSet('auto', 'dev', 'installer')]
|
||||
[string]$Mode = 'auto',
|
||||
[int]$Port = 8765
|
||||
)
|
||||
|
||||
$InstallerLauncher = Join-Path $env:LOCALAPPDATA 'Media Server\media-server.bat'
|
||||
$InstallerDir = Join-Path $env:LOCALAPPDATA 'Media Server'
|
||||
|
||||
# --- Resolve launch mode -----------------------------------------------------
|
||||
if ($Mode -eq 'auto') {
|
||||
if (Test-Path $InstallerLauncher) {
|
||||
$Mode = 'installer'
|
||||
} else {
|
||||
$Mode = 'dev'
|
||||
}
|
||||
}
|
||||
|
||||
# --- Stop whatever is listening on the port ---------------------------------
|
||||
$listenerPids = @()
|
||||
try {
|
||||
$conns = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue
|
||||
if ($conns) {
|
||||
$listenerPids = $conns | Select-Object -ExpandProperty OwningProcess -Unique
|
||||
}
|
||||
} catch {
|
||||
# Get-NetTCPConnection unavailable (rare); fall back to netstat parsing
|
||||
$listenerPids = & netstat -ano | Select-String ":$Port\s+.*LISTENING" | ForEach-Object {
|
||||
($_ -split '\s+')[-1]
|
||||
} | Sort-Object -Unique
|
||||
}
|
||||
|
||||
foreach ($targetPid in $listenerPids) {
|
||||
$proc = Get-Process -Id $targetPid -ErrorAction SilentlyContinue
|
||||
if ($proc) {
|
||||
Write-Host "Stopping listener PID $($proc.Id) ($($proc.ProcessName))..."
|
||||
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
# Also kill any orphan media-server.exe instances that didn't bind the port.
|
||||
$orphans = Get-Process -Name 'media-server' -ErrorAction SilentlyContinue
|
||||
foreach ($p in $orphans) {
|
||||
Write-Host "Stopping orphan media-server PID $($p.Id)..."
|
||||
Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
if ($listenerPids -or $orphans) {
|
||||
# Allow the OS to release the listen socket from TIME_WAIT.
|
||||
Start-Sleep -Seconds 3
|
||||
}
|
||||
|
||||
# --- Start the chosen flavour ------------------------------------------------
|
||||
if ($Mode -eq 'installer') {
|
||||
if (-not (Test-Path $InstallerLauncher)) {
|
||||
Write-Error "Installer launcher not found: $InstallerLauncher"
|
||||
exit 1
|
||||
}
|
||||
Write-Host "Starting installer build: $InstallerLauncher"
|
||||
Start-Process -FilePath $InstallerLauncher `
|
||||
-WorkingDirectory $InstallerDir `
|
||||
-WindowStyle Hidden
|
||||
} else {
|
||||
# Merge registry PATH so newly-installed dev tools are visible.
|
||||
$regUser = [Environment]::GetEnvironmentVariable('PATH', 'User')
|
||||
if ($regUser) {
|
||||
$currentDirs = $env:PATH -split ';' | ForEach-Object { $_.TrimEnd('\') }
|
||||
foreach ($dir in ($regUser -split ';')) {
|
||||
if ($dir -and ($currentDirs -notcontains $dir.TrimEnd('\'))) {
|
||||
$env:PATH = "$env:PATH;$dir"
|
||||
}
|
||||
}
|
||||
}
|
||||
Write-Host "Starting dev install (PATH media-server)..."
|
||||
Start-Process -FilePath 'media-server' `
|
||||
-WorkingDirectory 'c:\Users\Alexei\Documents\haos-integration-media-player\media-server' `
|
||||
-WindowStyle Hidden
|
||||
}
|
||||
|
||||
Start-Sleep -Seconds 3
|
||||
|
||||
# --- Verify it's listening ---------------------------------------------------
|
||||
$verify = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue
|
||||
if ($verify) {
|
||||
$vpid = $verify[0].OwningProcess
|
||||
$vproc = Get-Process -Id $vpid -ErrorAction SilentlyContinue
|
||||
Write-Host "Server listening on port $Port (PID $vpid, $($vproc.ProcessName))"
|
||||
} else {
|
||||
Write-Warning "Server is not listening on port $Port yet - check logs."
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
Set fso = CreateObject("Scripting.FileSystemObject")
|
||||
Set WshShell = CreateObject("WScript.Shell")
|
||||
' Get the directory of this script (scripts\), then go up to media-server root
|
||||
scriptDir = CreateObject("Scripting.FileSystemObject").GetParentFolderName(WScript.ScriptFullName)
|
||||
serverRoot = CreateObject("Scripting.FileSystemObject").GetParentFolderName(scriptDir)
|
||||
scriptDir = fso.GetParentFolderName(WScript.ScriptFullName)
|
||||
serverRoot = fso.GetParentFolderName(scriptDir)
|
||||
WshShell.CurrentDirectory = serverRoot
|
||||
' Run python completely hidden (0 = hidden, False = don't wait)
|
||||
WshShell.Run "python -m media_server.main", 0, False
|
||||
' Use embedded Python if present (installed distribution), otherwise system Python
|
||||
embeddedPython = serverRoot & "\python\python.exe"
|
||||
If fso.FileExists(embeddedPython) Then
|
||||
WshShell.Run """" & embeddedPython & """ -m media_server.main", 0, False
|
||||
Else
|
||||
WshShell.Run "python -m media_server.main", 0, False
|
||||
End If
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
Set WshShell = CreateObject("WScript.Shell")
|
||||
Set FSO = CreateObject("Scripting.FileSystemObject")
|
||||
' Get parent folder of scripts folder (media-server root)
|
||||
WshShell.CurrentDirectory = FSO.GetParentFolderName(FSO.GetParentFolderName(WScript.ScriptFullName))
|
||||
WshShell.Run "python -m media_server.main", 0, False
|
||||
Set FSO = Nothing
|
||||
Set WshShell = Nothing
|
||||
@@ -1,15 +0,0 @@
|
||||
@echo off
|
||||
REM Media Server Startup Script
|
||||
REM This script starts the media server
|
||||
|
||||
echo Starting Media Server...
|
||||
echo.
|
||||
|
||||
REM Change to the media-server directory (parent of scripts folder)
|
||||
cd /d "%~dp0\.."
|
||||
|
||||
REM Start the media server
|
||||
python -m media_server.main
|
||||
|
||||
REM If the server exits, pause to show any error messages
|
||||
pause
|
||||
@@ -1,19 +0,0 @@
|
||||
@echo off
|
||||
REM Media Server Stop Script
|
||||
REM This script stops the running media server
|
||||
|
||||
echo Stopping Media Server...
|
||||
echo.
|
||||
|
||||
REM Find and kill Python processes running media_server.main
|
||||
for /f "tokens=2" %%i in ('tasklist /FI "IMAGENAME eq python.exe" /FO LIST ^| findstr /B "PID:"') do (
|
||||
wmic process where "ProcessId=%%i" get CommandLine 2>nul | findstr /C:"media_server.main" >nul
|
||||
if not errorlevel 1 (
|
||||
taskkill /PID %%i /F
|
||||
echo Media server process (PID %%i) terminated.
|
||||
)
|
||||
)
|
||||
|
||||
echo.
|
||||
echo Done! Media server stopped.
|
||||
pause
|
||||
@@ -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