Compare commits
4 Commits
be48318212
...
02168519b7
| Author | SHA1 | Date | |
|---|---|---|---|
| 02168519b7 | |||
| c76ffb9997 | |||
| ddd8788701 | |||
| 5439af1955 |
@@ -0,0 +1,157 @@
|
||||
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: Create Gitea release
|
||||
id: create
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_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
|
||||
|
||||
BODY_JSON=$(python3 -c "
|
||||
import json, textwrap
|
||||
tag = '$TAG'
|
||||
body = '''## Downloads
|
||||
| Platform | File |
|
||||
|----------|------|
|
||||
| Windows (installer) | \`MediaServer-{tag}-setup.exe\` |
|
||||
| Windows (portable) | \`MediaServer-{tag}-win-x64.zip\` |
|
||||
| Linux | \`MediaServer-{tag}-linux-x64.tar.gz\` |
|
||||
'''
|
||||
print(json.dumps(textwrap.dedent(body).strip()))
|
||||
")
|
||||
|
||||
RELEASE=$(curl -s -X POST "$BASE_URL/releases" \
|
||||
-H "Authorization: token $GITEA_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 "Create failed, fetching existing release for tag $TAG..."
|
||||
RELEASE=$(curl -s "$BASE_URL/releases/tags/$TAG" \
|
||||
-H "Authorization: token $GITEA_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:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
RELEASE_ID="${{ needs.create-release.outputs.release_id }}"
|
||||
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
|
||||
|
||||
for FILE in build/MediaServer-*.zip build/MediaServer-*-setup.exe; do
|
||||
[ -f "$FILE" ] || continue
|
||||
echo "Uploading $(basename "$FILE")..."
|
||||
curl -s -X POST \
|
||||
"$BASE_URL/releases/$RELEASE_ID/assets?name=$(basename "$FILE")" \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@$FILE"
|
||||
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:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
RELEASE_ID="${{ needs.create-release.outputs.release_id }}"
|
||||
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
|
||||
|
||||
FILE=$(ls build/MediaServer-*-linux-x64.tar.gz | head -1)
|
||||
echo "Uploading $(basename "$FILE")..."
|
||||
curl -s -X POST \
|
||||
"$BASE_URL/releases/$RELEASE_ID/assets?name=$(basename "$FILE")" \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@$FILE"
|
||||
@@ -0,0 +1,35 @@
|
||||
name: Lint & Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Build frontend
|
||||
run: npm ci && npm run build
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install --upgrade pip
|
||||
pip install -e ".[dev]"
|
||||
|
||||
- name: Lint
|
||||
run: ruff check media_server/
|
||||
|
||||
- name: Test
|
||||
run: pytest --tb=short -q || test $? -eq 5
|
||||
@@ -49,3 +49,7 @@ Thumbs.db
|
||||
|
||||
# Thumbnail cache
|
||||
.cache/
|
||||
|
||||
# Node.js / esbuild
|
||||
node_modules/
|
||||
media_server/static/dist/
|
||||
|
||||
@@ -133,6 +133,48 @@ When releasing a new version, update both files with the same version string.
|
||||
|
||||
**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)
|
||||
|
||||
### 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.**
|
||||
|
||||
@@ -7,10 +7,12 @@ A REST API server for controlling system media playback on Windows, Linux, macOS
|
||||
- **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
|
||||
@@ -28,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
|
||||
@@ -40,7 +42,9 @@ The media server includes a built-in web interface for controlling and monitorin
|
||||
- **Connection status indicator** - Know when you're connected
|
||||
- **Token authentication** - Saved in browser localStorage
|
||||
- **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
|
||||
@@ -51,12 +55,14 @@ The media server includes a built-in web interface for controlling and monitorin
|
||||
### 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/
|
||||
```
|
||||
|
||||
@@ -78,12 +84,15 @@ The Web UI includes a real-time audio spectrum visualizer that captures system a
|
||||
|
||||
- **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
|
||||
```
|
||||
|
||||
@@ -135,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
|
||||
@@ -143,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`:
|
||||
|
||||
@@ -179,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
|
||||
@@ -189,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
|
||||
|
||||
@@ -230,7 +285,7 @@ All endpoints require bearer token authentication.
|
||||
|
||||
## Installation
|
||||
|
||||
### Windows
|
||||
### Installing on Windows
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
@@ -238,7 +293,7 @@ pip install -r requirements.txt
|
||||
|
||||
Required packages: `winsdk`, `pywin32`, `pycaw`, `comtypes`
|
||||
|
||||
### Linux
|
||||
### Installing on Linux
|
||||
|
||||
```bash
|
||||
# Install system dependencies
|
||||
@@ -247,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
|
||||
@@ -255,7 +310,7 @@ pip install -r requirements.txt
|
||||
|
||||
No additional dependencies - uses built-in `osascript`.
|
||||
|
||||
### Android (Termux)
|
||||
### Installing on Android (Termux)
|
||||
|
||||
```bash
|
||||
# In Termux
|
||||
@@ -268,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
|
||||
@@ -296,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
|
||||
@@ -316,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:
|
||||
@@ -363,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",
|
||||
@@ -380,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",
|
||||
@@ -401,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,
|
||||
@@ -465,7 +580,7 @@ Content-Type: application/json
|
||||
}
|
||||
```
|
||||
|
||||
### Configuring Scripts
|
||||
### Script Config Options
|
||||
|
||||
Add scripts in your `config.yaml`:
|
||||
|
||||
@@ -507,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:
|
||||
@@ -585,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
|
||||
|
||||
@@ -624,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
|
||||
```
|
||||
@@ -645,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:
|
||||
@@ -681,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
|
||||
@@ -698,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,126 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Build Linux distribution (self-contained venv + tarball)
|
||||
# Usage: ./build-dist-linux.sh [VERSION]
|
||||
|
||||
# --- Version detection ---
|
||||
VERSION="${1:-}"
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION=$(git describe --tags --exact-match 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION="${GITEA_REF_NAME:-${GITHUB_REF_NAME:-}}"
|
||||
fi
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION=$(grep -oP '__version__\s*=\s*"\K[^"]+' \
|
||||
media_server/__init__.py 2>/dev/null || echo "0.0.0")
|
||||
fi
|
||||
|
||||
VERSION_CLEAN="${VERSION#v}"
|
||||
echo "Building Media Server v${VERSION_CLEAN} for Linux"
|
||||
|
||||
# --- Configuration ---
|
||||
DIST_DIR="dist/media-server"
|
||||
BUILD_OUTPUT="build/MediaServer-v${VERSION_CLEAN}-linux-x64"
|
||||
|
||||
rm -rf dist build
|
||||
mkdir -p "${DIST_DIR}" build
|
||||
|
||||
# --- Verify frontend bundle ---
|
||||
if [ ! -f "media_server/static/dist/app.bundle.js" ]; then
|
||||
echo "ERROR: Frontend bundle not found. Run 'npm ci && npm run build' first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Create self-contained virtualenv ---
|
||||
echo "Creating virtualenv..."
|
||||
python3 -m venv "${DIST_DIR}/venv"
|
||||
source "${DIST_DIR}/venv/bin/activate"
|
||||
pip install --quiet --upgrade pip
|
||||
pip install --quiet ".[visualizer]"
|
||||
|
||||
# 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
|
||||
|
||||
# --- Copy application ---
|
||||
echo "Copying application files..."
|
||||
mkdir -p "${DIST_DIR}/app"
|
||||
cp -r media_server "${DIST_DIR}/app/"
|
||||
|
||||
# Remove source JS (bundle is in dist/)
|
||||
rm -rf "${DIST_DIR}/app/media_server/static/js"
|
||||
# Remove source maps from release
|
||||
rm -f "${DIST_DIR}/app/media_server/static/dist/"*.map
|
||||
|
||||
# Copy config example
|
||||
cp config.example.yaml "${DIST_DIR}/"
|
||||
|
||||
# --- Write version ---
|
||||
echo "$VERSION_CLEAN" > "${DIST_DIR}/VERSION"
|
||||
|
||||
# --- 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,151 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Cross-build Windows distribution on Linux
|
||||
# Usage: ./build-dist-windows.sh [VERSION]
|
||||
|
||||
# --- Version detection ---
|
||||
VERSION="${1:-}"
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION=$(git describe --tags --exact-match 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION="${GITEA_REF_NAME:-${GITHUB_REF_NAME:-}}"
|
||||
fi
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION=$(grep -oP '__version__\s*=\s*"\K[^"]+' \
|
||||
media_server/__init__.py 2>/dev/null || echo "0.0.0")
|
||||
fi
|
||||
|
||||
VERSION_CLEAN="${VERSION#v}"
|
||||
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"
|
||||
|
||||
rm -rf dist build
|
||||
mkdir -p "${DIST_DIR}" "${WHEEL_DIR}" "${SITE_PACKAGES}"
|
||||
|
||||
# --- Download embedded Python ---
|
||||
echo "Downloading embedded Python ${PYTHON_VERSION}..."
|
||||
curl -sL "https://www.python.org/ftp/python/${PYTHON_VERSION}/python-${PYTHON_VERSION}-embed-amd64.zip" \
|
||||
-o build/python-embed.zip
|
||||
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
|
||||
CORE_DEPS=(
|
||||
"fastapi>=0.109.0"
|
||||
"uvicorn[standard]>=0.27.0"
|
||||
"pydantic>=2.0"
|
||||
"pydantic-settings>=2.0"
|
||||
"pyyaml>=6.0"
|
||||
"mutagen>=1.47.0"
|
||||
"pillow>=10.0.0"
|
||||
)
|
||||
|
||||
# Windows-specific dependencies
|
||||
WIN_DEPS=(
|
||||
"winsdk>=1.0.0b10"
|
||||
"pywin32>=306"
|
||||
"comtypes>=1.2.0"
|
||||
"pycaw>=20230407"
|
||||
"screen-brightness-control>=0.20.0"
|
||||
"monitorcontrol>=3.0.0"
|
||||
)
|
||||
|
||||
# Visualizer dependencies
|
||||
VIS_DEPS=(
|
||||
"soundcard>=0.4.0"
|
||||
"numpy>=1.24.0"
|
||||
)
|
||||
|
||||
ALL_DEPS=("${CORE_DEPS[@]}" "${WIN_DEPS[@]}" "${VIS_DEPS[@]}")
|
||||
|
||||
for dep in "${ALL_DEPS[@]}"; do
|
||||
pip download --quiet --dest "$WHEEL_DIR" \
|
||||
--platform win_amd64 --python-version "${PYTHON_SHORT}" \
|
||||
--implementation cp --only-binary :all: \
|
||||
"$dep" 2>/dev/null || \
|
||||
pip download --quiet --dest "$WHEEL_DIR" "$dep"
|
||||
done
|
||||
|
||||
# Install wheels into site-packages
|
||||
echo "Installing wheels..."
|
||||
for whl in "$WHEEL_DIR"/*.whl; do
|
||||
unzip -qo "$whl" -d "$SITE_PACKAGES"
|
||||
done
|
||||
|
||||
# --- Size optimization ---
|
||||
echo "Optimizing size..."
|
||||
find "$SITE_PACKAGES" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$SITE_PACKAGES" -type d -name tests -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$SITE_PACKAGES" -type d -name "*.dist-info" -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$SITE_PACKAGES" -name "*.pyi" -delete 2>/dev/null || true
|
||||
rm -rf "$SITE_PACKAGES"/{pip,setuptools,pkg_resources}* 2>/dev/null || true
|
||||
|
||||
# Trim numpy if present
|
||||
rm -rf "$SITE_PACKAGES"/numpy/{tests,f2py,typing} 2>/dev/null || true
|
||||
|
||||
# --- Verify frontend bundle ---
|
||||
if [ ! -f "media_server/static/dist/app.bundle.js" ]; then
|
||||
echo "ERROR: Frontend bundle not found. Run 'npm ci && npm run build' first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Copy application ---
|
||||
echo "Copying application files..."
|
||||
mkdir -p "${DIST_DIR}/app"
|
||||
cp -r media_server "${DIST_DIR}/app/"
|
||||
|
||||
# Remove source JS (bundle is in dist/)
|
||||
rm -rf "${DIST_DIR}/app/media_server/static/js"
|
||||
# Remove source maps from release
|
||||
rm -f "${DIST_DIR}/app/media_server/static/dist/"*.map
|
||||
|
||||
# Copy config example
|
||||
cp config.example.yaml "${DIST_DIR}/"
|
||||
|
||||
# Copy scripts needed for auto-start
|
||||
mkdir -p "${DIST_DIR}/scripts"
|
||||
cp scripts/start-hidden.vbs "${DIST_DIR}/scripts/"
|
||||
|
||||
# --- Write version ---
|
||||
echo "$VERSION_CLEAN" > "${DIST_DIR}/VERSION"
|
||||
|
||||
# --- Create launcher ---
|
||||
cat > "${DIST_DIR}/media-server.bat" << 'LAUNCHER'
|
||||
@echo off
|
||||
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}"
|
||||
+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);
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
; 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 ---
|
||||
; To use a custom icon, convert icon.svg to icon.ico and uncomment:
|
||||
; !define MUI_ICON "media_server\static\icons\icon.ico"
|
||||
; !define MUI_UNICON "media_server\static\icons\icon.ico"
|
||||
!define MUI_ABORTWARNING
|
||||
|
||||
!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"
|
||||
|
||||
; --- Sections ---
|
||||
Section "!Core (required)" SecCore
|
||||
SectionIn RO
|
||||
|
||||
; Stop running instance if any
|
||||
nsExec::ExecToLog 'taskkill /F /IM python.exe /FI "WINDOWTITLE eq media_server*"'
|
||||
|
||||
SetOutPath "$INSTDIR"
|
||||
|
||||
; Copy entire distribution
|
||||
File /r "dist\media-server\*.*"
|
||||
|
||||
; Create uninstaller
|
||||
WriteUninstaller "$INSTDIR\uninstall.exe"
|
||||
|
||||
; Start Menu shortcuts
|
||||
CreateDirectory "$SMPROGRAMS\${APPNAME}"
|
||||
CreateShortcut "$SMPROGRAMS\${APPNAME}\${APPNAME}.lnk" \
|
||||
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
|
||||
"$INSTDIR\python\python.exe" 0
|
||||
CreateShortcut "$SMPROGRAMS\${APPNAME}\${APPNAME} (Console).lnk" \
|
||||
"$INSTDIR\${EXENAME}" "" \
|
||||
"$INSTDIR\python\python.exe" 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\python\python.exe" 0
|
||||
SectionEnd
|
||||
|
||||
Section "Start with Windows" SecAutostart
|
||||
; Create Startup folder shortcut (runs hidden via VBS)
|
||||
CreateShortcut "$SMSTARTUP\${APPNAME}.lnk" \
|
||||
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
|
||||
"$INSTDIR\python\python.exe" 0
|
||||
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 'taskkill /F /IM python.exe /FI "WINDOWTITLE eq media_server*"'
|
||||
|
||||
; 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
|
||||
@@ -76,7 +76,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
|
||||
|
||||
+13
-4
@@ -15,8 +15,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
|
||||
|
||||
@@ -206,12 +215,12 @@ def main():
|
||||
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("API Token has been saved to the config file.")
|
||||
return
|
||||
|
||||
if args.show_token:
|
||||
print(f"Config directory: {get_config_dir()}")
|
||||
print(f"\nAPI Tokens:")
|
||||
print("\nAPI Tokens:")
|
||||
for label, token in settings.api_tokens.items():
|
||||
print(f" {label:20} {token}")
|
||||
return
|
||||
|
||||
@@ -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__)
|
||||
@@ -281,7 +280,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,7 +2,6 @@
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
@@ -238,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
|
||||
@@ -283,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
|
||||
|
||||
@@ -7,7 +7,6 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from ..auth import verify_token
|
||||
from ..services.display_service import (
|
||||
get_brightness,
|
||||
list_monitors,
|
||||
set_brightness,
|
||||
set_power,
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import logging
|
||||
import platform
|
||||
import struct
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -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,7 +20,6 @@ class MetadataService:
|
||||
Dictionary with audio metadata.
|
||||
"""
|
||||
try:
|
||||
import mutagen
|
||||
from mutagen import File as MutagenFile
|
||||
|
||||
audio = MutagenFile(str(file_path), easy=True)
|
||||
@@ -68,7 +66,9 @@ class MetadataService:
|
||||
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"]
|
||||
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"]
|
||||
@@ -77,7 +77,9 @@ class MetadataService:
|
||||
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"]
|
||||
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:
|
||||
@@ -110,7 +112,6 @@ class MetadataService:
|
||||
Dictionary with video metadata.
|
||||
"""
|
||||
try:
|
||||
import mutagen
|
||||
from mutagen import File as MutagenFile
|
||||
|
||||
video = MutagenFile(str(file_path))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -5,7 +5,7 @@ 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
|
||||
@@ -47,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,
|
||||
)
|
||||
|
||||
@@ -61,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")
|
||||
|
||||
@@ -240,13 +242,18 @@ def _sync_get_media_status() -> dict[str, Any]:
|
||||
_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)}"
|
||||
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}, grace until: {_track_skip_pending['grace_until']}")
|
||||
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
|
||||
@@ -298,7 +305,10 @@ def _sync_get_media_status() -> dict[str, Any]:
|
||||
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})")
|
||||
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
|
||||
@@ -307,7 +317,10 @@ def _sync_get_media_status() -> dict[str, Any]:
|
||||
_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}")
|
||||
logger.debug(
|
||||
f"Grace period: SMTC stale ({smtc_pos}),"
|
||||
f" using interpolated {interpolated_pos}"
|
||||
)
|
||||
else:
|
||||
# Normal position tracking
|
||||
# Create track ID from title + artist + duration
|
||||
@@ -335,7 +348,9 @@ def _sync_get_media_status() -> dict[str, Any]:
|
||||
|
||||
# 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_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
|
||||
|
||||
|
||||
@@ -651,14 +651,6 @@
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="/static/js/core.js"></script>
|
||||
<script src="/static/js/player.js"></script>
|
||||
<script src="/static/js/websocket.js"></script>
|
||||
<script src="/static/js/scripts.js"></script>
|
||||
<script src="/static/js/callbacks.js"></script>
|
||||
<script src="/static/js/browser.js"></script>
|
||||
<script src="/static/js/links.js"></script>
|
||||
<script src="/static/js/background.js"></script>
|
||||
<script src="/static/js/main.js"></script>
|
||||
<script src="/static/dist/app.bundle.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,131 @@
|
||||
// ============================================================
|
||||
// Main: Initialization orchestrator (loaded last)
|
||||
// App: Entry point — imports all modules, registers window globals,
|
||||
// and orchestrates initialization (replaces main.js)
|
||||
// ============================================================
|
||||
|
||||
// Layer 0: Core state & utilities
|
||||
import {
|
||||
cacheDom, dom, registerUpdateCallbacks,
|
||||
initLocale, fetchVersion, formatTime, setupIconPreview,
|
||||
isUserAdjustingVolume, setIsUserAdjustingVolume,
|
||||
volumeUpdateTimer, setVolumeUpdateTimer,
|
||||
currentDuration, currentPosition, setVolume, seek,
|
||||
togglePlayPause, nextTrack, previousTrack, toggleMute,
|
||||
VOLUME_THROTTLE_MS, VOLUME_RELEASE_DELAY_MS,
|
||||
changeLocale, t,
|
||||
} from './core.js';
|
||||
|
||||
// Layer 1: Player (tabs, theme, accent, vinyl, visualizer, UI)
|
||||
import {
|
||||
activeTab, switchTab, updateTabIndicator, setMiniPlayerVisible,
|
||||
initTheme, toggleTheme, initAccentColor, applyAccentColor,
|
||||
renderAccentSwatches, selectAccentColor, toggleAccentPicker, lightenColor,
|
||||
toggleVinylMode, applyVinylMode,
|
||||
visualizerEnabled, visualizerAvailable,
|
||||
checkVisualizerAvailability, toggleVisualizer, applyVisualizerMode,
|
||||
loadAudioDevices, onAudioDeviceChanged,
|
||||
setupProgressDrag, updateUI, updatePlaybackState, stopPositionInterpolation,
|
||||
} from './player.js';
|
||||
|
||||
// Layer 2: WebSocket
|
||||
import {
|
||||
connectWebSocket, showAuthForm, authenticate, clearToken,
|
||||
manualReconnect, updateConnectionStatus,
|
||||
} from './websocket.js';
|
||||
|
||||
// Layer 3: Features
|
||||
import {
|
||||
loadScripts, loadScriptsTable, displayQuickAccess,
|
||||
showAddScriptDialog, showEditScriptDialog, closeScriptDialog, saveScript,
|
||||
deleteScriptConfirm, executeScriptDebug, executeCallbackDebug,
|
||||
closeExecutionDialog, scriptFormDirty, setScriptFormDirty,
|
||||
} from './scripts.js';
|
||||
|
||||
import {
|
||||
loadCallbacksTable,
|
||||
showAddCallbackDialog, showEditCallbackDialog, closeCallbackDialog,
|
||||
saveCallback, deleteCallbackConfirm,
|
||||
callbackFormDirty, setCallbackFormDirty,
|
||||
} from './callbacks.js';
|
||||
|
||||
import {
|
||||
loadMediaFolders, initBrowserToolbar, thumbnailCache,
|
||||
setViewMode, refreshBrowser, playAllFolder,
|
||||
previousPage, nextPage, goToPage,
|
||||
onBrowserSearch, clearBrowserSearch, onItemsPerPageChanged,
|
||||
downloadFile, closeFolderDialog, saveFolder,
|
||||
showManageFoldersDialog,
|
||||
} from './browser.js';
|
||||
|
||||
import {
|
||||
loadDisplayMonitors, onDisplayBrightnessInput, onDisplayBrightnessChange,
|
||||
toggleDisplayPower, loadHeaderLinks, loadLinksTable,
|
||||
showAddLinkDialog, showEditLinkDialog, closeLinkDialog, saveLink, deleteLinkConfirm,
|
||||
linkFormDirty, setLinkFormDirty,
|
||||
} from './links.js';
|
||||
|
||||
import {
|
||||
toggleDynamicBackground, applyDynamicBackground, updateBackgroundColors,
|
||||
} from './background.js';
|
||||
|
||||
// ============================================================
|
||||
// Register late-bound callbacks for core's updateAllText()
|
||||
// ============================================================
|
||||
|
||||
registerUpdateCallbacks({
|
||||
updatePlaybackState,
|
||||
updateConnectionStatus,
|
||||
loadScriptsTable,
|
||||
loadCallbacksTable,
|
||||
loadLinksTable,
|
||||
displayQuickAccess,
|
||||
renderAccentSwatches,
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Register all functions on window for HTML onclick handlers
|
||||
// ============================================================
|
||||
|
||||
Object.assign(window, {
|
||||
// Player controls
|
||||
togglePlayPause, nextTrack, previousTrack, toggleMute, seek,
|
||||
// Tabs
|
||||
switchTab,
|
||||
// Theme & accent
|
||||
toggleTheme, toggleAccentPicker, selectAccentColor, lightenColor,
|
||||
// Vinyl & visualizer
|
||||
toggleVinylMode, toggleVisualizer,
|
||||
// Background
|
||||
toggleDynamicBackground,
|
||||
// Auth
|
||||
authenticate, clearToken, manualReconnect,
|
||||
// Locale
|
||||
changeLocale,
|
||||
// Scripts
|
||||
showAddScriptDialog, showEditScriptDialog, closeScriptDialog, saveScript,
|
||||
deleteScriptConfirm, executeScriptDebug, executeCallbackDebug,
|
||||
closeExecutionDialog,
|
||||
// Callbacks
|
||||
showAddCallbackDialog, showEditCallbackDialog, closeCallbackDialog,
|
||||
saveCallback, deleteCallbackConfirm,
|
||||
// Browser
|
||||
setViewMode, refreshBrowser, playAllFolder,
|
||||
previousPage, nextPage, goToPage,
|
||||
onBrowserSearch, clearBrowserSearch, onItemsPerPageChanged,
|
||||
downloadFile, closeFolderDialog, saveFolder,
|
||||
showManageFoldersDialog,
|
||||
// Links
|
||||
showAddLinkDialog, showEditLinkDialog, closeLinkDialog,
|
||||
saveLink, deleteLinkConfirm,
|
||||
// Display
|
||||
loadDisplayMonitors, onDisplayBrightnessInput, onDisplayBrightnessChange,
|
||||
toggleDisplayPower,
|
||||
// Audio device
|
||||
onAudioDeviceChanged,
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Initialization (DOMContentLoaded)
|
||||
// ============================================================
|
||||
|
||||
window.addEventListener('DOMContentLoaded', async () => {
|
||||
@@ -50,7 +176,7 @@ window.addEventListener('DOMContentLoaded', async () => {
|
||||
function setupVolumeSlider(sliderId) {
|
||||
const slider = document.getElementById(sliderId);
|
||||
slider.addEventListener('input', (e) => {
|
||||
isUserAdjustingVolume = true;
|
||||
setIsUserAdjustingVolume(true);
|
||||
const volume = parseInt(e.target.value);
|
||||
// Sync both sliders and displays
|
||||
dom.volumeDisplay.textContent = `${volume}%`;
|
||||
@@ -59,20 +185,20 @@ window.addEventListener('DOMContentLoaded', async () => {
|
||||
dom.miniVolumeSlider.value = volume;
|
||||
|
||||
if (volumeUpdateTimer) clearTimeout(volumeUpdateTimer);
|
||||
volumeUpdateTimer = setTimeout(() => {
|
||||
setVolumeUpdateTimer(setTimeout(() => {
|
||||
setVolume(volume);
|
||||
volumeUpdateTimer = null;
|
||||
}, VOLUME_THROTTLE_MS);
|
||||
setVolumeUpdateTimer(null);
|
||||
}, VOLUME_THROTTLE_MS));
|
||||
});
|
||||
|
||||
slider.addEventListener('change', (e) => {
|
||||
if (volumeUpdateTimer) {
|
||||
clearTimeout(volumeUpdateTimer);
|
||||
volumeUpdateTimer = null;
|
||||
setVolumeUpdateTimer(null);
|
||||
}
|
||||
const volume = parseInt(e.target.value);
|
||||
setVolume(volume);
|
||||
setTimeout(() => { isUserAdjustingVolume = false; }, VOLUME_RELEASE_DELAY_MS);
|
||||
setTimeout(() => { setIsUserAdjustingVolume(false); }, VOLUME_RELEASE_DELAY_MS);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -124,25 +250,24 @@ window.addEventListener('DOMContentLoaded', async () => {
|
||||
// Script form dirty state tracking
|
||||
const scriptForm = document.getElementById('scriptForm');
|
||||
scriptForm.addEventListener('input', () => {
|
||||
scriptFormDirty = true;
|
||||
setScriptFormDirty(true);
|
||||
});
|
||||
scriptForm.addEventListener('change', () => {
|
||||
scriptFormDirty = true;
|
||||
setScriptFormDirty(true);
|
||||
});
|
||||
|
||||
// Callback form dirty state tracking
|
||||
const callbackForm = document.getElementById('callbackForm');
|
||||
callbackForm.addEventListener('input', () => {
|
||||
callbackFormDirty = true;
|
||||
setCallbackFormDirty(true);
|
||||
});
|
||||
callbackForm.addEventListener('change', () => {
|
||||
callbackFormDirty = true;
|
||||
setCallbackFormDirty(true);
|
||||
});
|
||||
|
||||
// Script dialog backdrop click to close
|
||||
const scriptDialog = document.getElementById('scriptDialog');
|
||||
scriptDialog.addEventListener('click', (e) => {
|
||||
// Check if click is on the backdrop (not the dialog content)
|
||||
if (e.target === scriptDialog) {
|
||||
closeScriptDialog();
|
||||
}
|
||||
@@ -151,7 +276,6 @@ window.addEventListener('DOMContentLoaded', async () => {
|
||||
// Callback dialog backdrop click to close
|
||||
const callbackDialog = document.getElementById('callbackDialog');
|
||||
callbackDialog.addEventListener('click', (e) => {
|
||||
// Check if click is on the backdrop (not the dialog content)
|
||||
if (e.target === callbackDialog) {
|
||||
closeCallbackDialog();
|
||||
}
|
||||
@@ -200,10 +324,10 @@ window.addEventListener('DOMContentLoaded', async () => {
|
||||
// Track link form dirty state
|
||||
const linkForm = document.getElementById('linkForm');
|
||||
linkForm.addEventListener('input', () => {
|
||||
linkFormDirty = true;
|
||||
setLinkFormDirty(true);
|
||||
});
|
||||
linkForm.addEventListener('change', () => {
|
||||
linkFormDirty = true;
|
||||
setLinkFormDirty(true);
|
||||
});
|
||||
|
||||
// Initialize browser toolbar and load folders
|
||||
@@ -2,6 +2,8 @@
|
||||
// Background: WebGL shader-based dynamic background
|
||||
// ============================================================
|
||||
|
||||
import { frequencyData } from './player.js';
|
||||
|
||||
let bgCanvas = null;
|
||||
let bgGL = null;
|
||||
let bgProgram = null;
|
||||
@@ -216,7 +218,7 @@ function resizeBackgroundCanvas() {
|
||||
|
||||
// ---- Cached color/theme updates (called on accent or theme change, not per-frame) ----
|
||||
|
||||
function updateBackgroundColors() {
|
||||
export function updateBackgroundColors() {
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
const accentHex = style.getPropertyValue('--accent').trim();
|
||||
if (accentHex && accentHex.length >= 7) {
|
||||
@@ -245,8 +247,8 @@ function renderBackgroundFrame() {
|
||||
|
||||
const time = performance.now() / 1000 - bgStartTime;
|
||||
|
||||
// Smooth audio data from the global frequencyData (shared with visualizer)
|
||||
if (typeof frequencyData !== 'undefined' && frequencyData && frequencyData.frequencies) {
|
||||
// Smooth audio data from the imported frequencyData (shared with visualizer)
|
||||
if (frequencyData && frequencyData.frequencies) {
|
||||
const bins = frequencyData.frequencies;
|
||||
const step = Math.max(1, Math.floor(bins.length / BG_BAND_COUNT));
|
||||
for (let i = 0; i < BG_BAND_COUNT; i++) {
|
||||
@@ -296,13 +298,13 @@ function stopBackground() {
|
||||
|
||||
// ---- Public API ----
|
||||
|
||||
function toggleDynamicBackground() {
|
||||
export function toggleDynamicBackground() {
|
||||
bgEnabled = !bgEnabled;
|
||||
localStorage.setItem('dynamicBackground', bgEnabled);
|
||||
applyDynamicBackground();
|
||||
}
|
||||
|
||||
function applyDynamicBackground() {
|
||||
export function applyDynamicBackground() {
|
||||
const btn = document.getElementById('bgToggle');
|
||||
if (bgEnabled) {
|
||||
startBackground();
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
// Media Browser: Navigation, rendering, search, pagination
|
||||
// ============================================================
|
||||
|
||||
import {
|
||||
t, showToast, escapeHtml, closeDialog,
|
||||
SEARCH_DEBOUNCE_MS, EMPTY_SVG_FILE, EMPTY_SVG_FOLDER, emptyStateHtml,
|
||||
} from './core.js';
|
||||
|
||||
// Browser state
|
||||
let currentFolderId = null;
|
||||
let currentPath = '';
|
||||
@@ -13,11 +18,11 @@ let viewMode = localStorage.getItem('mediaBrowser.viewMode') || 'grid';
|
||||
let cachedItems = null;
|
||||
let browserSearchTerm = '';
|
||||
let browserSearchTimer = null;
|
||||
const thumbnailCache = new Map();
|
||||
export const thumbnailCache = new Map();
|
||||
const THUMBNAIL_CACHE_MAX = 200;
|
||||
|
||||
// Load media folders on page load
|
||||
async function loadMediaFolders() {
|
||||
export async function loadMediaFolders() {
|
||||
try {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
if (!token) {
|
||||
@@ -169,11 +174,11 @@ async function browsePath(folderId, path, offset = 0, nocache = false) {
|
||||
}
|
||||
}
|
||||
|
||||
function renderBreadcrumbs(currentPath, parentPath) {
|
||||
function renderBreadcrumbs(currentPathStr, parentPath) {
|
||||
const breadcrumb = document.getElementById('breadcrumb');
|
||||
breadcrumb.innerHTML = '';
|
||||
|
||||
const parts = (currentPath || '').split('/').filter(p => p);
|
||||
const parts = (currentPathStr || '').split('/').filter(p => p);
|
||||
let path = '/';
|
||||
|
||||
// Home link (back to folder list)
|
||||
@@ -373,10 +378,10 @@ function renderBrowserGrid(items, container) {
|
||||
// Lazy load thumbnail
|
||||
loadThumbnail(thumbnail, item.name);
|
||||
} else {
|
||||
const icon = document.createElement('div');
|
||||
icon.className = 'browser-icon';
|
||||
icon.textContent = getFileIcon(item.type);
|
||||
thumbWrapper.appendChild(icon);
|
||||
const iconEl = document.createElement('div');
|
||||
iconEl.className = 'browser-icon';
|
||||
iconEl.textContent = getFileIcon(item.type);
|
||||
thumbWrapper.appendChild(iconEl);
|
||||
}
|
||||
|
||||
// Play overlay for media files
|
||||
@@ -527,11 +532,10 @@ async function loadThumbnail(imgElement, fileName) {
|
||||
};
|
||||
|
||||
// Revoke previous blob URL if not managed by cache
|
||||
// (Cache is keyed by path, so check values)
|
||||
if (imgElement.src && imgElement.src.startsWith('blob:')) {
|
||||
let isCached = false;
|
||||
for (const url of thumbnailCache.values()) {
|
||||
if (url === imgElement.src) { isCached = true; break; }
|
||||
for (const cachedUrl of thumbnailCache.values()) {
|
||||
if (cachedUrl === imgElement.src) { isCached = true; break; }
|
||||
}
|
||||
if (!isCached) URL.revokeObjectURL(imgElement.src);
|
||||
}
|
||||
@@ -544,10 +548,10 @@ async function loadThumbnail(imgElement, fileName) {
|
||||
if (isList) {
|
||||
parent.textContent = '\u{1F3B5}';
|
||||
} else {
|
||||
const icon = document.createElement('div');
|
||||
icon.className = 'browser-icon';
|
||||
icon.textContent = '\u{1F3B5}';
|
||||
parent.insertBefore(icon, parent.firstChild);
|
||||
const iconEl = document.createElement('div');
|
||||
iconEl.className = 'browser-icon';
|
||||
iconEl.textContent = '\u{1F3B5}';
|
||||
parent.insertBefore(iconEl, parent.firstChild);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -600,7 +604,7 @@ async function playMediaFile(fileName) {
|
||||
}
|
||||
}
|
||||
|
||||
async function playAllFolder() {
|
||||
export async function playAllFolder() {
|
||||
if (playInProgress) return;
|
||||
playInProgress = true;
|
||||
const btn = document.getElementById('playAllBtn');
|
||||
@@ -634,7 +638,7 @@ async function playAllFolder() {
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadFile(fileName, event) {
|
||||
export async function downloadFile(fileName, event) {
|
||||
if (event) event.stopPropagation();
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
if (!token) return;
|
||||
@@ -699,19 +703,19 @@ function renderPagination() {
|
||||
nextBtn.disabled = currentPage === totalPages;
|
||||
}
|
||||
|
||||
function previousPage() {
|
||||
export function previousPage() {
|
||||
if (currentOffset >= itemsPerPage) {
|
||||
browsePath(currentFolderId, currentPath, currentOffset - itemsPerPage);
|
||||
}
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
export function nextPage() {
|
||||
if (currentOffset + itemsPerPage < totalItems) {
|
||||
browsePath(currentFolderId, currentPath, currentOffset + itemsPerPage);
|
||||
}
|
||||
}
|
||||
|
||||
function refreshBrowser() {
|
||||
export function refreshBrowser() {
|
||||
if (currentFolderId) {
|
||||
browsePath(currentFolderId, currentPath, currentOffset, true);
|
||||
} else {
|
||||
@@ -720,7 +724,7 @@ function refreshBrowser() {
|
||||
}
|
||||
|
||||
// Browser search
|
||||
function onBrowserSearch() {
|
||||
export function onBrowserSearch() {
|
||||
const input = document.getElementById('browserSearchInput');
|
||||
const clearBtn = document.getElementById('browserSearchClear');
|
||||
const term = input.value.trim();
|
||||
@@ -735,7 +739,7 @@ function onBrowserSearch() {
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function clearBrowserSearch() {
|
||||
export function clearBrowserSearch() {
|
||||
const input = document.getElementById('browserSearchInput');
|
||||
input.value = '';
|
||||
document.getElementById('browserSearchClear').style.display = 'none';
|
||||
@@ -768,7 +772,7 @@ function showBrowserSearch(visible) {
|
||||
}
|
||||
}
|
||||
|
||||
function setViewMode(mode) {
|
||||
export function setViewMode(mode) {
|
||||
if (mode === viewMode) return;
|
||||
viewMode = mode;
|
||||
localStorage.setItem('mediaBrowser.viewMode', mode);
|
||||
@@ -786,7 +790,7 @@ function setViewMode(mode) {
|
||||
}
|
||||
}
|
||||
|
||||
function onItemsPerPageChanged() {
|
||||
export function onItemsPerPageChanged() {
|
||||
const select = document.getElementById('itemsPerPageSelect');
|
||||
itemsPerPage = parseInt(select.value);
|
||||
localStorage.setItem('mediaBrowser.itemsPerPage', itemsPerPage);
|
||||
@@ -798,7 +802,7 @@ function onItemsPerPageChanged() {
|
||||
}
|
||||
}
|
||||
|
||||
function goToPage() {
|
||||
export function goToPage() {
|
||||
const pageInput = document.getElementById('pageInput');
|
||||
const totalPages = Math.ceil(totalItems / itemsPerPage);
|
||||
let page = parseInt(pageInput.value);
|
||||
@@ -813,7 +817,7 @@ function goToPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function initBrowserToolbar() {
|
||||
export function initBrowserToolbar() {
|
||||
// Restore view mode
|
||||
const savedViewMode = localStorage.getItem('mediaBrowser.viewMode') || 'grid';
|
||||
viewMode = savedViewMode;
|
||||
@@ -865,18 +869,16 @@ function loadLastBrowserPath() {
|
||||
}
|
||||
|
||||
// Folder Management
|
||||
function showManageFoldersDialog() {
|
||||
export function showManageFoldersDialog() {
|
||||
// TODO: Implement folder management UI
|
||||
// For now, show a simple alert
|
||||
showToast(t('browser.manage_folders_hint'), 'info');
|
||||
}
|
||||
|
||||
function closeFolderDialog() {
|
||||
export function closeFolderDialog() {
|
||||
closeDialog(document.getElementById('folderDialog'));
|
||||
}
|
||||
|
||||
async function saveFolder(event) {
|
||||
export async function saveFolder(event) {
|
||||
event.preventDefault();
|
||||
// TODO: Implement folder save functionality
|
||||
closeFolderDialog();
|
||||
}
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
// Callbacks: CRUD management
|
||||
// ============================================================
|
||||
|
||||
let callbackFormDirty = false;
|
||||
import { t, showToast, escapeHtml, closeDialog, showConfirm } from './core.js';
|
||||
|
||||
export let callbackFormDirty = false;
|
||||
export function setCallbackFormDirty(value) { callbackFormDirty = value; }
|
||||
|
||||
let _loadCallbacksPromise = null;
|
||||
async function loadCallbacksTable() {
|
||||
export async function loadCallbacksTable() {
|
||||
if (_loadCallbacksPromise) return _loadCallbacksPromise;
|
||||
_loadCallbacksPromise = _loadCallbacksTableImpl();
|
||||
_loadCallbacksPromise.finally(() => { _loadCallbacksPromise = null; });
|
||||
@@ -59,7 +62,7 @@ async function _loadCallbacksTableImpl() {
|
||||
}
|
||||
}
|
||||
|
||||
function showAddCallbackDialog() {
|
||||
export function showAddCallbackDialog() {
|
||||
const dialog = document.getElementById('callbackDialog');
|
||||
const form = document.getElementById('callbackForm');
|
||||
const title = document.getElementById('callbackDialogTitle');
|
||||
@@ -75,7 +78,7 @@ function showAddCallbackDialog() {
|
||||
dialog.showModal();
|
||||
}
|
||||
|
||||
async function showEditCallbackDialog(callbackName) {
|
||||
export async function showEditCallbackDialog(callbackName) {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
const dialog = document.getElementById('callbackDialog');
|
||||
const title = document.getElementById('callbackDialogTitle');
|
||||
@@ -115,7 +118,7 @@ async function showEditCallbackDialog(callbackName) {
|
||||
}
|
||||
}
|
||||
|
||||
async function closeCallbackDialog() {
|
||||
export async function closeCallbackDialog() {
|
||||
if (callbackFormDirty) {
|
||||
if (!await showConfirm(t('callbacks.confirm.unsaved'))) {
|
||||
return;
|
||||
@@ -128,7 +131,7 @@ async function closeCallbackDialog() {
|
||||
document.body.classList.remove('dialog-open');
|
||||
}
|
||||
|
||||
async function saveCallback(event) {
|
||||
export async function saveCallback(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const submitBtn = event.target.querySelector('button[type="submit"]');
|
||||
@@ -179,7 +182,7 @@ async function saveCallback(event) {
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCallbackConfirm(callbackName) {
|
||||
export async function deleteCallbackConfirm(callbackName) {
|
||||
if (!await showConfirm(t('callbacks.confirm.delete').replace('{name}', callbackName))) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3,22 +3,22 @@
|
||||
// ============================================================
|
||||
|
||||
// SVG path constants (avoid rebuilding innerHTML on every state update)
|
||||
const SVG_PLAY = '<path d="M8 5v14l11-7z"/>';
|
||||
const SVG_PAUSE = '<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>';
|
||||
const SVG_STOP = '<path d="M6 6h12v12H6z"/>';
|
||||
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"/>';
|
||||
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"/>';
|
||||
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"/>';
|
||||
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
|
||||
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>';
|
||||
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>';
|
||||
function emptyStateHtml(svgStr, text) {
|
||||
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 }
|
||||
const MEDIA_SOURCES = {
|
||||
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>'
|
||||
@@ -89,7 +89,7 @@ const MEDIA_SOURCES = {
|
||||
},
|
||||
};
|
||||
|
||||
function resolveMediaSource(raw) {
|
||||
export function resolveMediaSource(raw) {
|
||||
if (!raw) return null;
|
||||
const lower = raw.toLowerCase();
|
||||
for (const [key, info] of Object.entries(MEDIA_SOURCES)) {
|
||||
@@ -99,8 +99,8 @@ function resolveMediaSource(raw) {
|
||||
}
|
||||
|
||||
// Cached DOM references (populated once after DOMContentLoaded)
|
||||
const dom = {};
|
||||
function cacheDom() {
|
||||
export const dom = {};
|
||||
export function cacheDom() {
|
||||
dom.trackTitle = document.getElementById('track-title');
|
||||
dom.artist = document.getElementById('artist');
|
||||
dom.album = document.getElementById('album');
|
||||
@@ -137,26 +137,35 @@ function cacheDom() {
|
||||
}
|
||||
|
||||
// Timing constants
|
||||
const VOLUME_THROTTLE_MS = 16;
|
||||
const POSITION_INTERPOLATION_MS = 100;
|
||||
const SEARCH_DEBOUNCE_MS = 200;
|
||||
const TOAST_DURATION_MS = 3000;
|
||||
const WS_BACKOFF_BASE_MS = 3000;
|
||||
const WS_BACKOFF_MAX_MS = 30000;
|
||||
const WS_MAX_RECONNECT_ATTEMPTS = 20;
|
||||
const WS_PING_INTERVAL_MS = 30000;
|
||||
const VOLUME_RELEASE_DELAY_MS = 500;
|
||||
export const VOLUME_THROTTLE_MS = 16;
|
||||
export const POSITION_INTERPOLATION_MS = 100;
|
||||
export const SEARCH_DEBOUNCE_MS = 200;
|
||||
export const TOAST_DURATION_MS = 3000;
|
||||
export const WS_BACKOFF_BASE_MS = 3000;
|
||||
export const WS_BACKOFF_MAX_MS = 30000;
|
||||
export const WS_MAX_RECONNECT_ATTEMPTS = 20;
|
||||
export const WS_PING_INTERVAL_MS = 30000;
|
||||
export const VOLUME_RELEASE_DELAY_MS = 500;
|
||||
|
||||
// Shared state (accessed across multiple modules)
|
||||
let ws = null;
|
||||
let currentState = 'idle';
|
||||
let currentDuration = 0;
|
||||
let currentPosition = 0;
|
||||
let isUserAdjustingVolume = false;
|
||||
let volumeUpdateTimer = null;
|
||||
let scripts = [];
|
||||
let lastStatus = null;
|
||||
let currentPlayState = 'idle';
|
||||
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)
|
||||
@@ -178,7 +187,7 @@ const fallbackTranslations = {
|
||||
'player.status.disconnected': 'Disconnected'
|
||||
};
|
||||
|
||||
function t(key, params = {}) {
|
||||
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]);
|
||||
@@ -208,7 +217,7 @@ function detectBrowserLocale() {
|
||||
return supportedLocales[langCode] ? langCode : 'en';
|
||||
}
|
||||
|
||||
async function initLocale() {
|
||||
export async function initLocale() {
|
||||
const savedLocale = localStorage.getItem('locale') || detectBrowserLocale();
|
||||
await setLocale(savedLocale);
|
||||
}
|
||||
@@ -228,7 +237,7 @@ async function setLocale(locale) {
|
||||
document.body.classList.add('translations-loaded');
|
||||
}
|
||||
|
||||
function changeLocale() {
|
||||
export function changeLocale() {
|
||||
const select = document.getElementById('locale-select');
|
||||
const newLocale = select.value;
|
||||
if (newLocale && newLocale !== currentLocale) {
|
||||
@@ -244,6 +253,26 @@ function updateLocaleSelect() {
|
||||
}
|
||||
}
|
||||
|
||||
// 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');
|
||||
@@ -259,9 +288,9 @@ function updateAllText() {
|
||||
});
|
||||
|
||||
// Re-apply dynamic content with new translations
|
||||
updatePlaybackState(currentState);
|
||||
if (_updatePlaybackState) _updatePlaybackState(currentState);
|
||||
const connected = ws && ws.readyState === WebSocket.OPEN;
|
||||
updateConnectionStatus(connected);
|
||||
if (_updateConnectionStatus) _updateConnectionStatus(connected);
|
||||
|
||||
if (lastStatus) {
|
||||
const fallbackTitle = lastStatus.state === 'idle' ? t('player.no_media') : t('player.title_unavailable');
|
||||
@@ -273,15 +302,15 @@ function updateAllText() {
|
||||
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
if (token) {
|
||||
loadScriptsTable();
|
||||
loadCallbacksTable();
|
||||
loadLinksTable();
|
||||
displayQuickAccess();
|
||||
if (_loadScriptsTable) _loadScriptsTable();
|
||||
if (_loadCallbacksTable) _loadCallbacksTable();
|
||||
if (_loadLinksTable) _loadLinksTable();
|
||||
if (_displayQuickAccess) _displayQuickAccess();
|
||||
}
|
||||
renderAccentSwatches();
|
||||
if (_renderAccentSwatches) _renderAccentSwatches();
|
||||
}
|
||||
|
||||
async function fetchVersion() {
|
||||
export async function fetchVersion() {
|
||||
try {
|
||||
const response = await fetch('/api/health');
|
||||
if (response.ok) {
|
||||
@@ -300,20 +329,20 @@ async function fetchVersion() {
|
||||
// Shared Utilities
|
||||
// ============================================================
|
||||
|
||||
function formatTime(seconds) {
|
||||
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')}`;
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
export function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function showToast(message, type = 'success') {
|
||||
export function showToast(message, type = 'success') {
|
||||
const container = document.getElementById('toast-container');
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast ${type}`;
|
||||
@@ -331,7 +360,7 @@ function showToast(message, type = 'success') {
|
||||
}, TOAST_DURATION_MS);
|
||||
}
|
||||
|
||||
function closeDialog(dialog) {
|
||||
export function closeDialog(dialog) {
|
||||
dialog.classList.add('dialog-closing');
|
||||
dialog.addEventListener('animationend', () => {
|
||||
dialog.classList.remove('dialog-closing');
|
||||
@@ -339,7 +368,7 @@ function closeDialog(dialog) {
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
function showConfirm(message) {
|
||||
export function showConfirm(message) {
|
||||
return new Promise((resolve) => {
|
||||
const dialog = document.getElementById('confirmDialog');
|
||||
const msg = document.getElementById('confirmDialogMessage');
|
||||
@@ -371,7 +400,7 @@ function showConfirm(message) {
|
||||
// API Commands
|
||||
// ============================================================
|
||||
|
||||
async function sendCommand(endpoint, body = null) {
|
||||
export async function sendCommand(endpoint, body = null) {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
|
||||
const options = {
|
||||
@@ -399,7 +428,7 @@ async function sendCommand(endpoint, body = null) {
|
||||
}
|
||||
}
|
||||
|
||||
function togglePlayPause() {
|
||||
export function togglePlayPause() {
|
||||
if (currentState === 'playing') {
|
||||
sendCommand('pause');
|
||||
} else {
|
||||
@@ -407,16 +436,16 @@ function togglePlayPause() {
|
||||
}
|
||||
}
|
||||
|
||||
function nextTrack() {
|
||||
export function nextTrack() {
|
||||
sendCommand('next');
|
||||
}
|
||||
|
||||
function previousTrack() {
|
||||
export function previousTrack() {
|
||||
sendCommand('previous');
|
||||
}
|
||||
|
||||
let lastSentVolume = -1;
|
||||
function setVolume(volume) {
|
||||
export function setVolume(volume) {
|
||||
if (volume === lastSentVolume) return;
|
||||
lastSentVolume = volume;
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
@@ -426,11 +455,11 @@ function setVolume(volume) {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
export function toggleMute() {
|
||||
sendCommand('mute');
|
||||
}
|
||||
|
||||
function seek(position) {
|
||||
export function seek(position) {
|
||||
sendCommand('seek', { position: position });
|
||||
}
|
||||
|
||||
@@ -448,7 +477,7 @@ function _persistMdiCache() {
|
||||
try { localStorage.setItem('mdiIconCache', JSON.stringify(mdiIconCache)); } catch {}
|
||||
}
|
||||
|
||||
async function fetchMdiIcon(iconName) {
|
||||
export async function fetchMdiIcon(iconName) {
|
||||
const name = iconName.replace(/^mdi:/, '');
|
||||
if (mdiIconCache[name]) return mdiIconCache[name];
|
||||
|
||||
@@ -467,7 +496,7 @@ async function fetchMdiIcon(iconName) {
|
||||
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>';
|
||||
}
|
||||
|
||||
async function resolveMdiIcons(container) {
|
||||
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;
|
||||
@@ -477,7 +506,7 @@ async function resolveMdiIcons(container) {
|
||||
}));
|
||||
}
|
||||
|
||||
function setupIconPreview(inputId, previewId) {
|
||||
export function setupIconPreview(inputId, previewId) {
|
||||
const input = document.getElementById(inputId);
|
||||
const preview = document.getElementById(previewId);
|
||||
if (!input || !preview) return;
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
// ============================================================
|
||||
// Display Brightness & Power Control
|
||||
// Display Brightness & Power Control + Links Management
|
||||
// ============================================================
|
||||
|
||||
import { t, showToast, escapeHtml, closeDialog, showConfirm, resolveMdiIcons, fetchMdiIcon } from './core.js';
|
||||
|
||||
let displayBrightnessTimers = {};
|
||||
const DISPLAY_THROTTLE_MS = 50;
|
||||
|
||||
async function loadDisplayMonitors() {
|
||||
export async function loadDisplayMonitors() {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
if (!token) return;
|
||||
|
||||
@@ -86,7 +88,7 @@ async function loadDisplayMonitors() {
|
||||
}
|
||||
}
|
||||
|
||||
function onDisplayBrightnessInput(monitorId, value) {
|
||||
export function onDisplayBrightnessInput(monitorId, value) {
|
||||
const label = document.getElementById(`brightness-val-${monitorId}`);
|
||||
if (label) label.textContent = `${value}%`;
|
||||
|
||||
@@ -97,7 +99,7 @@ function onDisplayBrightnessInput(monitorId, value) {
|
||||
}, DISPLAY_THROTTLE_MS);
|
||||
}
|
||||
|
||||
function onDisplayBrightnessChange(monitorId, value) {
|
||||
export function onDisplayBrightnessChange(monitorId, value) {
|
||||
if (displayBrightnessTimers[monitorId]) {
|
||||
clearTimeout(displayBrightnessTimers[monitorId]);
|
||||
displayBrightnessTimers[monitorId] = null;
|
||||
@@ -121,7 +123,7 @@ async function sendDisplayBrightness(monitorId, brightness) {
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleDisplayPower(monitorId, monitorName) {
|
||||
export async function toggleDisplayPower(monitorId, monitorName) {
|
||||
const btn = document.getElementById(`power-btn-${monitorId}`);
|
||||
const isOn = btn && btn.classList.contains('on');
|
||||
const newState = !isOn;
|
||||
@@ -157,7 +159,7 @@ async function toggleDisplayPower(monitorId, monitorName) {
|
||||
// Header Quick Links
|
||||
// ============================================================
|
||||
|
||||
async function loadHeaderLinks() {
|
||||
export async function loadHeaderLinks() {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
if (!token) return;
|
||||
|
||||
@@ -197,9 +199,10 @@ async function loadHeaderLinks() {
|
||||
// ============================================================
|
||||
|
||||
let _loadLinksPromise = null;
|
||||
let linkFormDirty = false;
|
||||
export let linkFormDirty = false;
|
||||
export function setLinkFormDirty(value) { linkFormDirty = value; }
|
||||
|
||||
async function loadLinksTable() {
|
||||
export async function loadLinksTable() {
|
||||
if (_loadLinksPromise) return _loadLinksPromise;
|
||||
_loadLinksPromise = _loadLinksTableImpl();
|
||||
_loadLinksPromise.finally(() => { _loadLinksPromise = null; });
|
||||
@@ -251,7 +254,7 @@ async function _loadLinksTableImpl() {
|
||||
}
|
||||
}
|
||||
|
||||
function showAddLinkDialog() {
|
||||
export function showAddLinkDialog() {
|
||||
const dialog = document.getElementById('linkDialog');
|
||||
const form = document.getElementById('linkForm');
|
||||
const title = document.getElementById('linkDialogTitle');
|
||||
@@ -269,7 +272,7 @@ function showAddLinkDialog() {
|
||||
dialog.showModal();
|
||||
}
|
||||
|
||||
async function showEditLinkDialog(linkName) {
|
||||
export async function showEditLinkDialog(linkName) {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
const dialog = document.getElementById('linkDialog');
|
||||
const title = document.getElementById('linkDialogTitle');
|
||||
@@ -320,7 +323,7 @@ async function showEditLinkDialog(linkName) {
|
||||
}
|
||||
}
|
||||
|
||||
async function closeLinkDialog() {
|
||||
export async function closeLinkDialog() {
|
||||
if (linkFormDirty) {
|
||||
if (!await showConfirm(t('links.confirm.unsaved'))) {
|
||||
return;
|
||||
@@ -333,7 +336,7 @@ async function closeLinkDialog() {
|
||||
document.body.classList.remove('dialog-open');
|
||||
}
|
||||
|
||||
async function saveLink(event) {
|
||||
export async function saveLink(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const submitBtn = event.target.querySelector('button[type="submit"]');
|
||||
@@ -385,7 +388,7 @@ async function saveLink(event) {
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteLinkConfirm(linkName) {
|
||||
export async function deleteLinkConfirm(linkName) {
|
||||
if (!await showConfirm(t('links.confirm.delete').replace('{name}', linkName))) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2,10 +2,21 @@
|
||||
// Player: Tabs, theme, accent, vinyl, visualizer, UI updates
|
||||
// ============================================================
|
||||
|
||||
// Tab management
|
||||
let activeTab = 'player';
|
||||
import {
|
||||
dom, t, formatTime, showToast, resolveMediaSource,
|
||||
SVG_PLAY, SVG_PAUSE, SVG_STOP, SVG_IDLE, SVG_MUTED, SVG_UNMUTED,
|
||||
ws, currentState, setCurrentState, currentDuration, setCurrentDuration,
|
||||
currentPosition, setCurrentPosition, isUserAdjustingVolume,
|
||||
lastStatus, setLastStatus, currentPlayState, setCurrentPlayState,
|
||||
POSITION_INTERPOLATION_MS, seek,
|
||||
} from './core.js';
|
||||
import { updateBackgroundColors } from './background.js';
|
||||
import { loadDisplayMonitors } from './links.js';
|
||||
|
||||
function setMiniPlayerVisible(visible) {
|
||||
// Tab management
|
||||
export let activeTab = 'player';
|
||||
|
||||
export function setMiniPlayerVisible(visible) {
|
||||
const miniPlayer = document.getElementById('mini-player');
|
||||
if (visible) {
|
||||
miniPlayer.classList.remove('hidden');
|
||||
@@ -16,7 +27,7 @@ function setMiniPlayerVisible(visible) {
|
||||
}
|
||||
}
|
||||
|
||||
function updateTabIndicator(btn, animate = true) {
|
||||
export function updateTabIndicator(btn, animate = true) {
|
||||
const indicator = document.getElementById('tabIndicator');
|
||||
if (!indicator || !btn) return;
|
||||
const tabBar = document.getElementById('tabBar');
|
||||
@@ -32,7 +43,7 @@ function updateTabIndicator(btn, animate = true) {
|
||||
}
|
||||
}
|
||||
|
||||
function switchTab(tabName) {
|
||||
export function switchTab(tabName) {
|
||||
activeTab = tabName;
|
||||
|
||||
document.querySelectorAll('[data-tab-content]').forEach(el => {
|
||||
@@ -75,12 +86,12 @@ function switchTab(tabName) {
|
||||
}
|
||||
|
||||
// Theme management
|
||||
function initTheme() {
|
||||
export function initTheme() {
|
||||
const savedTheme = localStorage.getItem('theme') || 'dark';
|
||||
setTheme(savedTheme);
|
||||
}
|
||||
|
||||
function setTheme(theme) {
|
||||
export function setTheme(theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('theme', theme);
|
||||
|
||||
@@ -100,17 +111,17 @@ function setTheme(theme) {
|
||||
metaThemeColor.setAttribute('content', theme === 'light' ? '#ffffff' : '#121212');
|
||||
}
|
||||
|
||||
if (typeof updateBackgroundColors === 'function') updateBackgroundColors();
|
||||
updateBackgroundColors();
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
export function toggleTheme() {
|
||||
const currentTheme = document.documentElement.getAttribute('data-theme') || 'dark';
|
||||
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
||||
setTheme(newTheme);
|
||||
}
|
||||
|
||||
// Accent color management
|
||||
const accentPresets = [
|
||||
export const accentPresets = [
|
||||
{ name: 'Green', color: '#1db954', hover: '#1ed760' },
|
||||
{ name: 'Blue', color: '#3b82f6', hover: '#60a5fa' },
|
||||
{ name: 'Purple', color: '#8b5cf6', hover: '#a78bfa' },
|
||||
@@ -122,7 +133,7 @@ const accentPresets = [
|
||||
{ name: 'Yellow', color: '#eab308', hover: '#facc15' },
|
||||
];
|
||||
|
||||
function lightenColor(hex, percent) {
|
||||
export function lightenColor(hex, percent) {
|
||||
const num = parseInt(hex.replace('#', ''), 16);
|
||||
const r = Math.min(255, (num >> 16) + Math.round(255 * percent / 100));
|
||||
const g = Math.min(255, ((num >> 8) & 0xff) + Math.round(255 * percent / 100));
|
||||
@@ -130,7 +141,7 @@ function lightenColor(hex, percent) {
|
||||
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
|
||||
}
|
||||
|
||||
function initAccentColor() {
|
||||
export function initAccentColor() {
|
||||
const saved = localStorage.getItem('accentColor');
|
||||
if (saved) {
|
||||
const preset = accentPresets.find(p => p.color === saved);
|
||||
@@ -143,16 +154,16 @@ function initAccentColor() {
|
||||
renderAccentSwatches();
|
||||
}
|
||||
|
||||
function applyAccentColor(color, hover) {
|
||||
export function applyAccentColor(color, hover) {
|
||||
document.documentElement.style.setProperty('--accent', color);
|
||||
document.documentElement.style.setProperty('--accent-hover', hover);
|
||||
localStorage.setItem('accentColor', color);
|
||||
const dot = document.getElementById('accentDot');
|
||||
if (dot) dot.style.background = color;
|
||||
if (typeof updateBackgroundColors === 'function') updateBackgroundColors();
|
||||
updateBackgroundColors();
|
||||
}
|
||||
|
||||
function renderAccentSwatches() {
|
||||
export function renderAccentSwatches() {
|
||||
const dropdown = document.getElementById('accentDropdown');
|
||||
if (!dropdown) return;
|
||||
const current = localStorage.getItem('accentColor') || '#1db954';
|
||||
@@ -177,13 +188,13 @@ function renderAccentSwatches() {
|
||||
dropdown.innerHTML = swatches + customRow;
|
||||
}
|
||||
|
||||
function selectAccentColor(color, hover) {
|
||||
export function selectAccentColor(color, hover) {
|
||||
applyAccentColor(color, hover);
|
||||
renderAccentSwatches();
|
||||
document.getElementById('accentDropdown').classList.remove('open');
|
||||
}
|
||||
|
||||
function toggleAccentPicker() {
|
||||
export function toggleAccentPicker() {
|
||||
document.getElementById('accentDropdown').classList.toggle('open');
|
||||
}
|
||||
|
||||
@@ -225,14 +236,14 @@ function restoreVinylAngle() {
|
||||
setInterval(saveVinylAngle, 2000);
|
||||
window.addEventListener('beforeunload', saveVinylAngle);
|
||||
|
||||
function toggleVinylMode() {
|
||||
export function toggleVinylMode() {
|
||||
if (vinylMode) saveVinylAngle();
|
||||
vinylMode = !vinylMode;
|
||||
localStorage.setItem('vinylMode', vinylMode);
|
||||
applyVinylMode();
|
||||
}
|
||||
|
||||
function applyVinylMode() {
|
||||
export function applyVinylMode() {
|
||||
const container = document.querySelector('.album-art-container');
|
||||
const btn = document.getElementById('vinylToggle');
|
||||
if (!container) return;
|
||||
@@ -260,15 +271,16 @@ function updateVinylSpin() {
|
||||
}
|
||||
|
||||
// Audio Visualizer
|
||||
let visualizerEnabled = localStorage.getItem('visualizerEnabled') === 'true';
|
||||
let visualizerAvailable = false;
|
||||
export let visualizerEnabled = localStorage.getItem('visualizerEnabled') === 'true';
|
||||
export let visualizerAvailable = false;
|
||||
let visualizerCtx = null;
|
||||
let visualizerAnimFrame = null;
|
||||
let frequencyData = null;
|
||||
export let frequencyData = null;
|
||||
export function setFrequencyData(value) { frequencyData = value; }
|
||||
let smoothedFrequencies = null;
|
||||
const VISUALIZER_SMOOTHING = 0.15;
|
||||
|
||||
async function checkVisualizerAvailability() {
|
||||
export async function checkVisualizerAvailability() {
|
||||
try {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
const resp = await fetch('/api/media/visualizer/status', {
|
||||
@@ -285,13 +297,13 @@ async function checkVisualizerAvailability() {
|
||||
if (btn) btn.style.display = visualizerAvailable ? '' : 'none';
|
||||
}
|
||||
|
||||
function toggleVisualizer() {
|
||||
export function toggleVisualizer() {
|
||||
visualizerEnabled = !visualizerEnabled;
|
||||
localStorage.setItem('visualizerEnabled', visualizerEnabled);
|
||||
applyVisualizerMode();
|
||||
}
|
||||
|
||||
function applyVisualizerMode() {
|
||||
export function applyVisualizerMode() {
|
||||
const container = document.querySelector('.album-art-container');
|
||||
const btn = document.getElementById('visualizerToggle');
|
||||
if (!container) return;
|
||||
@@ -333,7 +345,7 @@ function startVisualizerRender() {
|
||||
renderVisualizerFrame();
|
||||
}
|
||||
|
||||
function stopVisualizerRender() {
|
||||
export function stopVisualizerRender() {
|
||||
if (visualizerAnimFrame) {
|
||||
cancelAnimationFrame(visualizerAnimFrame);
|
||||
visualizerAnimFrame = null;
|
||||
@@ -410,7 +422,7 @@ function renderVisualizerFrame() {
|
||||
}
|
||||
|
||||
// Audio device selection
|
||||
async function loadAudioDevices() {
|
||||
export async function loadAudioDevices() {
|
||||
const section = document.getElementById('audioDeviceSection');
|
||||
const select = document.getElementById('audioDeviceSelect');
|
||||
if (!section || !select) return;
|
||||
@@ -478,7 +490,7 @@ function updateAudioDeviceStatus(status) {
|
||||
}
|
||||
}
|
||||
|
||||
async function onAudioDeviceChanged() {
|
||||
export async function onAudioDeviceChanged() {
|
||||
const select = document.getElementById('audioDeviceSelect');
|
||||
if (!select) return;
|
||||
|
||||
@@ -519,7 +531,7 @@ let lastPositionUpdate = 0;
|
||||
let lastPositionValue = 0;
|
||||
let interpolationInterval = null;
|
||||
|
||||
function setupProgressDrag(bar, fill) {
|
||||
export function setupProgressDrag(bar, fill) {
|
||||
let dragging = false;
|
||||
|
||||
function getPercent(clientX) {
|
||||
@@ -571,8 +583,8 @@ function setupProgressDrag(bar, fill) {
|
||||
});
|
||||
}
|
||||
|
||||
function updateUI(status) {
|
||||
lastStatus = status;
|
||||
export function updateUI(status) {
|
||||
setLastStatus(status);
|
||||
|
||||
const fallbackTitle = status.state === 'idle' ? t('player.no_media') : t('player.title_unavailable');
|
||||
dom.trackTitle.textContent = status.title || fallbackTitle;
|
||||
@@ -583,7 +595,7 @@ function updateUI(status) {
|
||||
dom.miniArtist.textContent = status.artist || '';
|
||||
|
||||
const previousState = currentState;
|
||||
currentState = status.state;
|
||||
setCurrentState(status.state);
|
||||
updatePlaybackState(status.state);
|
||||
|
||||
const altText = status.title && status.artist
|
||||
@@ -628,8 +640,8 @@ function updateUI(status) {
|
||||
}
|
||||
|
||||
if (status.duration && status.position !== null) {
|
||||
currentDuration = status.duration;
|
||||
currentPosition = status.position;
|
||||
setCurrentDuration(status.duration);
|
||||
setCurrentPosition(status.position);
|
||||
lastPositionUpdate = Date.now();
|
||||
lastPositionValue = status.position;
|
||||
updateProgress(status.position, status.duration);
|
||||
@@ -661,8 +673,8 @@ function updateUI(status) {
|
||||
}
|
||||
}
|
||||
|
||||
function updatePlaybackState(state) {
|
||||
currentPlayState = state;
|
||||
export function updatePlaybackState(state) {
|
||||
setCurrentPlayState(state);
|
||||
switch(state) {
|
||||
case 'playing':
|
||||
dom.playbackState.textContent = t('state.playing');
|
||||
@@ -715,7 +727,7 @@ function updateProgress(position, duration) {
|
||||
miniBar.setAttribute('aria-valuemax', durRound);
|
||||
}
|
||||
|
||||
function startPositionInterpolation() {
|
||||
export function startPositionInterpolation() {
|
||||
if (interpolationInterval) {
|
||||
clearInterval(interpolationInterval);
|
||||
}
|
||||
@@ -728,7 +740,7 @@ function startPositionInterpolation() {
|
||||
}, POSITION_INTERPOLATION_MS);
|
||||
}
|
||||
|
||||
function stopPositionInterpolation() {
|
||||
export function stopPositionInterpolation() {
|
||||
if (interpolationInterval) {
|
||||
clearInterval(interpolationInterval);
|
||||
interpolationInterval = null;
|
||||
|
||||
@@ -2,9 +2,16 @@
|
||||
// Scripts: CRUD, quick access, execution dialog
|
||||
// ============================================================
|
||||
|
||||
let scriptFormDirty = false;
|
||||
import {
|
||||
t, showToast, escapeHtml, closeDialog, showConfirm,
|
||||
resolveMdiIcons, fetchMdiIcon,
|
||||
scripts, setScripts,
|
||||
} from './core.js';
|
||||
|
||||
async function loadScripts() {
|
||||
export let scriptFormDirty = false;
|
||||
export function setScriptFormDirty(value) { scriptFormDirty = value; }
|
||||
|
||||
export async function loadScripts() {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
|
||||
try {
|
||||
@@ -15,7 +22,7 @@ async function loadScripts() {
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
scripts = await response.json();
|
||||
setScripts(await response.json());
|
||||
displayQuickAccess();
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -24,7 +31,7 @@ async function loadScripts() {
|
||||
}
|
||||
|
||||
let _quickAccessGen = 0;
|
||||
async function displayQuickAccess() {
|
||||
export async function displayQuickAccess() {
|
||||
const gen = ++_quickAccessGen;
|
||||
const grid = document.getElementById('scripts-grid');
|
||||
|
||||
@@ -150,7 +157,7 @@ async function executeScript(scriptName, buttonElement) {
|
||||
// ============================================================
|
||||
|
||||
let _loadScriptsPromise = null;
|
||||
async function loadScriptsTable() {
|
||||
export async function loadScriptsTable() {
|
||||
if (_loadScriptsPromise) return _loadScriptsPromise;
|
||||
_loadScriptsPromise = _loadScriptsTableImpl();
|
||||
_loadScriptsPromise.finally(() => { _loadScriptsPromise = null; });
|
||||
@@ -206,7 +213,7 @@ async function _loadScriptsTableImpl() {
|
||||
}
|
||||
}
|
||||
|
||||
function showAddScriptDialog() {
|
||||
export function showAddScriptDialog() {
|
||||
const dialog = document.getElementById('scriptDialog');
|
||||
const form = document.getElementById('scriptForm');
|
||||
const title = document.getElementById('dialogTitle');
|
||||
@@ -224,7 +231,7 @@ function showAddScriptDialog() {
|
||||
dialog.showModal();
|
||||
}
|
||||
|
||||
async function showEditScriptDialog(scriptName) {
|
||||
export async function showEditScriptDialog(scriptName) {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
const dialog = document.getElementById('scriptDialog');
|
||||
const title = document.getElementById('dialogTitle');
|
||||
@@ -274,7 +281,7 @@ async function showEditScriptDialog(scriptName) {
|
||||
}
|
||||
}
|
||||
|
||||
async function closeScriptDialog() {
|
||||
export async function closeScriptDialog() {
|
||||
if (scriptFormDirty) {
|
||||
if (!await showConfirm(t('scripts.confirm.unsaved'))) {
|
||||
return;
|
||||
@@ -287,7 +294,7 @@ async function closeScriptDialog() {
|
||||
document.body.classList.remove('dialog-open');
|
||||
}
|
||||
|
||||
async function saveScript(event) {
|
||||
export async function saveScript(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const submitBtn = event.target.querySelector('button[type="submit"]');
|
||||
@@ -341,7 +348,7 @@ async function saveScript(event) {
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteScriptConfirm(scriptName) {
|
||||
export async function deleteScriptConfirm(scriptName) {
|
||||
if (!await showConfirm(t('scripts.confirm.delete').replace('{name}', scriptName))) {
|
||||
return;
|
||||
}
|
||||
@@ -373,7 +380,7 @@ async function deleteScriptConfirm(scriptName) {
|
||||
// Execution Result Dialog (shared by scripts and callbacks)
|
||||
// ============================================================
|
||||
|
||||
function closeExecutionDialog() {
|
||||
export function closeExecutionDialog() {
|
||||
const dialog = document.getElementById('executionDialog');
|
||||
closeDialog(dialog);
|
||||
document.body.classList.remove('dialog-open');
|
||||
@@ -435,7 +442,7 @@ function showExecutionResult(name, result, type = 'script') {
|
||||
dialog.showModal();
|
||||
}
|
||||
|
||||
async function executeScriptDebug(scriptName) {
|
||||
export async function executeScriptDebug(scriptName) {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
const dialog = document.getElementById('executionDialog');
|
||||
const title = document.getElementById('executionDialogTitle');
|
||||
@@ -486,7 +493,7 @@ async function executeScriptDebug(scriptName) {
|
||||
}
|
||||
}
|
||||
|
||||
async function executeCallbackDebug(callbackName) {
|
||||
export async function executeCallbackDebug(callbackName) {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
const dialog = document.getElementById('executionDialog');
|
||||
const title = document.getElementById('executionDialogTitle');
|
||||
|
||||
@@ -2,11 +2,21 @@
|
||||
// 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,
|
||||
} from './core.js';
|
||||
import { updateUI, visualizerEnabled, visualizerAvailable, 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;
|
||||
|
||||
function showAuthForm(errorMessage = '') {
|
||||
export function showAuthForm(errorMessage = '') {
|
||||
const overlay = document.getElementById('auth-overlay');
|
||||
overlay.classList.remove('hidden');
|
||||
|
||||
@@ -23,7 +33,7 @@ function hideAuthForm() {
|
||||
document.getElementById('auth-overlay').classList.add('hidden');
|
||||
}
|
||||
|
||||
function authenticate() {
|
||||
export function authenticate() {
|
||||
const token = document.getElementById('token-input').value.trim();
|
||||
if (!token) {
|
||||
showAuthForm(t('auth.required'));
|
||||
@@ -34,15 +44,18 @@ function authenticate() {
|
||||
connectWebSocket(token);
|
||||
}
|
||||
|
||||
function clearToken() {
|
||||
export function clearToken() {
|
||||
localStorage.removeItem('media_server_token');
|
||||
if (ws) {
|
||||
ws.close();
|
||||
}
|
||||
// Access ws via import
|
||||
import('./core.js').then(core => {
|
||||
if (core.ws) {
|
||||
core.ws.close();
|
||||
}
|
||||
});
|
||||
showAuthForm(t('auth.cleared'));
|
||||
}
|
||||
|
||||
function connectWebSocket(token) {
|
||||
export function connectWebSocket(token) {
|
||||
if (pingInterval) {
|
||||
clearInterval(pingInterval);
|
||||
pingInterval = null;
|
||||
@@ -51,9 +64,10 @@ function connectWebSocket(token) {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/api/media/ws?token=${encodeURIComponent(token)}`;
|
||||
|
||||
ws = new WebSocket(wsUrl);
|
||||
const newWs = new WebSocket(wsUrl);
|
||||
setWs(newWs);
|
||||
|
||||
ws.onopen = () => {
|
||||
newWs.onopen = () => {
|
||||
console.log('WebSocket connected');
|
||||
wsReconnectAttempts = 0;
|
||||
updateConnectionStatus(true);
|
||||
@@ -66,11 +80,11 @@ function connectWebSocket(token) {
|
||||
loadHeaderLinks();
|
||||
loadAudioDevices();
|
||||
if (visualizerEnabled && visualizerAvailable) {
|
||||
ws.send(JSON.stringify({ type: 'enable_visualizer' }));
|
||||
newWs.send(JSON.stringify({ type: 'enable_visualizer' }));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
newWs.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
|
||||
if (msg.type === 'status' || msg.type === 'status_update') {
|
||||
@@ -85,18 +99,18 @@ function connectWebSocket(token) {
|
||||
loadLinksTable();
|
||||
displayQuickAccess();
|
||||
} else if (msg.type === 'audio_data') {
|
||||
frequencyData = msg.data;
|
||||
setFrequencyData(msg.data);
|
||||
} else if (msg.type === 'error') {
|
||||
console.error('WebSocket error:', msg.message);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
newWs.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
updateConnectionStatus(false);
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
newWs.onclose = (event) => {
|
||||
console.log('WebSocket closed:', event.code);
|
||||
updateConnectionStatus(false);
|
||||
stopPositionInterpolation();
|
||||
@@ -131,13 +145,13 @@ function connectWebSocket(token) {
|
||||
};
|
||||
|
||||
pingInterval = setInterval(() => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'ping' }));
|
||||
if (newWs && newWs.readyState === WebSocket.OPEN) {
|
||||
newWs.send(JSON.stringify({ type: 'ping' }));
|
||||
}
|
||||
}, WS_PING_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function updateConnectionStatus(connected) {
|
||||
export function updateConnectionStatus(connected) {
|
||||
if (connected) {
|
||||
dom.statusDot.classList.add('connected');
|
||||
} else {
|
||||
@@ -159,7 +173,7 @@ function hideConnectionBanner() {
|
||||
banner.classList.add('hidden');
|
||||
}
|
||||
|
||||
function manualReconnect() {
|
||||
export function manualReconnect() {
|
||||
const savedToken = localStorage.getItem('media_server_token');
|
||||
if (savedToken) {
|
||||
wsReconnectAttempts = 0;
|
||||
|
||||
Generated
+690
@@ -0,0 +1,690 @@
|
||||
{
|
||||
"name": "media-server-frontend",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "media-server-frontend",
|
||||
"version": "1.0.0",
|
||||
"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": "1.0.0",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,7 @@ dev = [
|
||||
"pytest>=7.0",
|
||||
"pytest-asyncio>=0.21",
|
||||
"httpx>=0.24",
|
||||
"ruff>=0.4.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -67,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,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
|
||||
Reference in New Issue
Block a user