Compare commits

..

42 Commits

Author SHA1 Message Date
be48318212 Add dynamic WebGL background with audio reactivity
- WebGL shader background with flowing waves, radial pulse, and frequency ring arcs
- Reacts to captured audio data (frequency bands + bass) when visualizer is active
- Uses page accent color; adapts to dark/light theme via bg-primary blending
- Toggle button in header toolbar, state persisted in localStorage
- Cached uniform locations and color values to avoid per-frame getComputedStyle calls
- i18n support for EN/RU locales

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 21:42:19 +03:00
8a8f00ff31 Persist vinyl rotation angle across page reloads
- Save current rotation angle to localStorage every 2s and on page unload
- Restore angle on page load via CSS custom property --vinyl-offset
- Extract angle from computed transform matrix

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-27 21:06:20 +03:00
397d38ac12 Add primary display indicator, custom accent color picker, restart script
- Detect primary monitor via Windows EnumDisplayMonitors API and show badge
- Expand accent color picker with 9 presets and custom color input
- Auto-generate hover color for custom accent colors
- Re-render accent swatches on locale change for proper i18n
- Replace restart-server.bat with PowerShell restart-server.ps1

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-27 16:18:18 +03:00
adf2d936da Consolidate tabs, Quick Access links, mini player nav, link descriptions
- Merge Scripts/Callbacks/Links tabs into single Settings tab with collapsible sections
- Rename Actions tab to Quick Access showing both scripts and configured links
- Add prev/next buttons to mini (secondary) player
- Add optional description field to links (backend + frontend)
- Add CSS chevron indicators on collapsible settings sections
- Persist section collapse/expand state in localStorage
- Fix race condition in Quick Access rendering with generation counter
- Order settings sections: Scripts, Links, Callbacks

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-27 15:08:09 +03:00
99dbbb1019 Add header quick links with CRUD management and icon enhancements
- Add LinkConfig model and links field to settings
- Add CRUD API endpoints for links (list/create/update/delete)
- Add Links management tab in WebUI with add/edit/delete dialogs
- Add live icon preview in Link and Script dialog forms
- Show MDI icons inline in Quick Actions cards, Scripts table, Links table
- Add broadcast_links_changed WebSocket event for live updates
- Add EN/RU translations for all links management strings

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-27 14:42:18 +03:00
6f6a4e4aec Improve slider track visibility in both themes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 13:58:35 +03:00
a568608ec3 Add display brightness and power control
- New display service with DDC/CI brightness and power control via screen_brightness_control and monitorcontrol
- New /api/display/* endpoints (monitors, brightness, power)
- Display tab in WebUI with per-monitor brightness sliders and power toggle
- EDID resolution parsing to distinguish same-name monitors
- Throttled brightness slider (50ms) matching volume control pattern

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 13:54:43 +03:00
03a1b30cd8 Comprehensive WebUI improvements: security, UX, accessibility, performance
Security:
- Replace inline onclick handlers with data-attribute event delegation (XSS fix)
- Remove auth tokens from URL query params; use Authorization header + blob URLs
- Defer artwork blob URL revocation to prevent ERR_FILE_NOT_FOUND

Reliability:
- Merge duplicate DOMContentLoaded listeners
- WebSocket exponential backoff reconnect (3s base, 30s max, 20 attempts)
- Connection banner with manual reconnect button after failures

UX:
- Toast notifications now stack (multiple visible simultaneously)
- Custom styled confirm dialog replacing native confirm()
- Drag-to-seek on progress bars (mouse + touch)
- Keyboard shortcuts: Space, arrows, M for media controls
- Browser search matches both filename and title
- Path separator auto-detection (Unix/Windows)

Accessibility:
- WAI-ARIA Tabs pattern (tablist, tab, tabpanel roles)
- Arrow/Home/End keyboard navigation in tab bar
- ARIA slider roles on progress bars with live value updates
- aria-label on volume sliders, aria-live on status dot

Performance:
- Thumbnail cache (Map, max 200 entries, LRU eviction)
- Skip revocation of cached blob URLs during grid re-render
- Blob URL cleanup on page unload

Visual polish:
- Vinyl mode uses CSS custom properties (works in light + dark themes)
- Light theme shadow overrides for containers, dialogs, toasts
- Optimized system font stack

Code quality:
- Scoped button reset, merged duplicate CSS selectors
- WCAG AA contrast fix for --text-muted
- Normalized CSS to consistent 4-space indentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 21:36:12 +03:00
ef1935c5cf Update README with current features
- Add media browser, scripts, callbacks to top-level features
- Document vinyl record mode, accent color picker, light theme
- Document mini player, tab navigation
- Document browser view modes, search, pagination, play all, download
- Mention Web UI script/callback management (CRUD)
- Update platform and authentication details

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-25 01:59:12 +03:00
7ee0a60e8d Update media browser screenshot
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-25 01:56:38 +03:00
7f28145644 Update documentation screenshots
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-25 01:54:51 +03:00
80d4dbccf3 Fix browser grid card sizing
- Reduce regular grid icon/thumbnail to 90px fixed
- Cap compact grid columns at 100px max width
- Compact thumbnails fill card width with aspect-ratio
- Reduce grid gap and column min-width

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-25 01:49:42 +03:00
caf24db494 Compact browser grid cards
- Reduce card padding and gap
- Use fluid width thumbnails/icons instead of fixed 120px
- Fix cards stretching to tallest row height (align-items: start)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-24 01:13:33 +03:00
babdb61791 Update media-server: Vinyl record mode and accent color picker
- Vinyl mode: album art as center label with grooves, spindle hole, vignette
- Smooth transition between normal and vinyl modes
- Accent color picker with 9 preset colors in header
- Fix progress bar hover layout shift (use scaleY instead of height)
- Fix glow position jump when toggling vinyl mode

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-23 23:46:24 +03:00
65b513ca17 Update media-server: UI polish and bug fixes
- Compact header and footer (reduced padding, margins, font sizes)
- Remove separator borders from header and footer
- Fix color contrast on hover states (white text on accent backgrounds)
- Fix album art not updating on track change (composite cache key)
- Slow vinyl spin animation (4s → 12s)
- Replace Add buttons with dashed-border add-cards
- Fix dialog header text color
- Make theme toggle button transparent

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-23 23:16:53 +03:00
84b985e6df Backend optimizations, frontend optimizations, and UI design improvements
Backend optimizations:
- GZip middleware for compressed responses
- Concurrent WebSocket broadcast
- Skip status polling when no clients connected
- Deduplicated token validation with caching
- Fire-and-forget HA state callbacks
- Single stat() per browser item
- Metadata caching (LRU)
- M3U playlist optimization
- Autostart setup (Task Scheduler + hidden VBS launcher)

Frontend code optimizations:
- Fix thumbnail blob URL memory leak
- Fix WebSocket ping interval leak on reconnect
- Skip artwork re-fetch when same track playing
- Deduplicate volume slider logic
- Extract magic numbers into named constants
- Standardize error handling with toast notifications
- Cache play/pause SVG constants
- Loading state management for async buttons
- Request deduplication for rapid clicks
- Cache 30+ DOM element references
- Deduplicate volume updates over WebSocket

Frontend design improvements:
- Progress bar seek thumb and hover expansion
- Custom themed scrollbars
- Toast notification accent border strips
- Keyboard focus-visible states
- Album art ambient glow effect
- Animated sliding tab indicator
- Mini-player top progress line
- Empty state SVG illustrations
- Responsive tablet breakpoint (601-900px)
- Horizontal player layout on wide screens (>900px)
- Glassmorphism mini-player with backdrop blur
- Vinyl spin animation (toggleable)
- Table horizontal scroll on narrow screens

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:38:35 +03:00
d1ec27cb7b Improve error handling for unavailable network shares
- Add OSError/PermissionError handling in browse endpoint
- Return 503 status code when folders are temporarily unavailable
- Display user-friendly error messages in the UI
- Enhance error logging with exception type information

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-09 12:03:25 +03:00
13df69adb4 Show media title (Artist – Title) instead of filename when available
- Extract title and artist tags via mutagen easy=True in get_media_info
- Display "Artist – Title" in both grid and list views, fall back to filename
- Show original filename in tooltip on hover

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 03:42:32 +03:00
4c13322936 Show bitrate in browser, remove type labels and Play All text
- Extract bitrate alongside duration in browse_directory via get_media_info
- Display bitrate in large card view metadata (duration · bitrate · size)
- Replace Audio/Video type badge with bitrate column in list view
- Remove Play All button text, keep icon only
- Add formatBitrate helper function

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 03:37:13 +03:00
5f474d6c9f Add browser search/filter for media items
- Search bar appears when browsing inside a folder
- Client-side filtering with 200ms debounce
- Clear button and search icon
- Hides at root level, resets on navigation
- Localized placeholder (en/ru)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 02:44:31 +03:00
98a33bca54 Tabbed UI, browse caching, and bottom mini player
- Convert stacked sections to tabbed interface (Player, Browser, Actions, Scripts, Callbacks) with localStorage persistence
- Add in-memory directory listing cache (5-min TTL) with nocache bypass for refresh
- Defer stat()/duration calls to paginated items only for faster browse
- Move mini player from top to bottom with footer padding fix
- Always show scrollbar to prevent layout shift between tabs
- Add tab localization keys (en/ru)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-09 02:34:29 +03:00
8db40d3ee9 UI polish: refresh button, negative thumbnail cache, and style fixes
- Add refresh button to browser toolbar to re-fetch current folder
- Cache "no thumbnail" results to avoid repeated slow SMB lookups
- Fix list view fallback icon sizing for files without album art
- Fix view toggle button hover (no background/scale on hover)
- Skip re-render when clicking already-active view mode button

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 02:10:22 +03:00
f275240e59 Add Play All, home navigation, and UI improvements
- Add Play All button with M3U playlist generation (local temp file with absolute paths)
- Replace folder combobox with root folder cards and home icon breadcrumb
- Fix compact grid card sizing (64x64 thumbnails, align-items: start)
- Add loading spinner when browsing folders
- Cache browse items to avoid re-fetching on view mode switch
- Remove unused browser-controls CSS
- Add localization keys for Play All and Home (en/ru)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 01:57:32 +03:00
e16674c658 Add media browser with grid/compact/list views and single-click playback
- Add browser UI with three view modes (grid, compact, list) and pagination
- Add file browsing, thumbnail loading, download, and play endpoints
- Add duration extraction via mutagen for media files
- Single-click plays media or navigates folders, with play overlay on hover
- Add type badges, file size display, and duration metadata
- Add localization keys for browser UI (en/ru)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-08 23:34:38 +03:00
32b058c5fb Add low-latency volume control via WebSocket
- Send volume updates through WebSocket instead of HTTP POST
- Reduce throttle from 50ms to 16ms (~60 updates/sec)
- Fall back to HTTP if WebSocket is disconnected

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 13:19:58 +03:00
c5f8c7a092 Fix HTTPException handling in folder endpoints and install script path
- Add missing `except HTTPException: raise` in create_folder and
  update_folder endpoints to prevent 400 errors being masked as 500s
- Fix install_task_windows.ps1 working directory to point to
  media-server root instead of parent project root

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 13:09:47 +03:00
8d15a2a54b Update Web UI: Header redesign, thumbnail fix, and title fallback
- Add version label next to Media Server header (fetched from /api/health)
- Move connection status dot before title, remove status text
- Move logout button into header after language selector
- Return 204 instead of 404 for missing thumbnails (eliminates console errors)
- Show "Title unavailable" when media is playing but title is empty
- Add player.title_unavailable translation key for en/ru locales

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 20:03:43 +03:00
1cb83eac1c Add screenshots to README
- Web UI media player screenshot
- Media Browser screenshot
- Script and Callback Management screenshot

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 18:03:59 +03:00
62c42f70d1 Move install_task_windows.ps1 to scripts folder
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 12:26:45 +03:00
45 changed files with 9566 additions and 3332 deletions

View File

@@ -5,30 +5,47 @@ A REST API server for controlling system media playback on Windows, Linux, macOS
## Features
- **Built-in Web UI** for real-time media control and monitoring
- **Installable PWA** - Add to home screen on mobile for a native app experience
- **Audio Visualizer** - Real-time spectrum analyzer with beat-reactive album art effects
- **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.)
- Control any media player via system-wide media transport controls
- Play/Pause/Stop/Next/Previous track
- Volume control and mute
- Seek within tracks
- Get current track info (title, artist, album, artwork)
- WebSocket support for real-time updates
- Token-based authentication
- Cross-platform support
- Token-based authentication with multi-token support
- Dark/light theme with customizable accent colors
- Multi-language support (English, Russian)
- Cross-platform support (Windows, Linux, macOS, Android)
## Web UI
The media server includes a built-in web interface for controlling and monitoring media playback.
![Web UI](docs/web-ui.PNG)
### Features
- **Real-time status updates** via WebSocket connection
- **Album artwork display** with automatic updates
- **Album artwork display** with glow effect and automatic updates
- **Vinyl record mode** - Album art displayed as a spinning vinyl disc with grooves and center spindle
- **Playback controls** - Play, pause, next, previous
- **Volume control** with mute toggle
- **Seekable progress bar** - Click to jump to any position
- **Mini player** - Sticky compact player that appears when scrolling away from the main player
- **Connection status indicator** - Know when you're connected
- **Token authentication** - Saved in browser localStorage
- **Responsive design** - Works on desktop and mobile
- **Dark theme** - Easy on the eyes
- **Audio spectrum visualizer** - Real-time frequency bars with beat-reactive album art scaling and glow (on-demand WASAPI loopback capture)
- **Display control** - Monitor brightness adjustment and power on/off
- **Installable PWA** - Add to home screen on mobile/desktop for standalone app experience with safe area support for notched phones
- **Responsive design** - Works on desktop, tablet, and mobile
- **Dark and light themes** - Toggle between dark and light modes with dynamic status bar theming
- **Accent color picker** - Choose from 9 preset accent colors or pick a custom color
- **Tab-based navigation** - Player, Display, Browser, Quick Actions, and Settings tabs
- **Multi-language support** - English and Russian locales with automatic detection
### Accessing the Web UI
@@ -47,6 +64,29 @@ The media server includes a built-in web interface for controlling and monitorin
4. Start playing media in any supported player and watch the UI update in real-time!
### Installing as a PWA
The Web UI can be installed as a Progressive Web App for a native app-like experience:
1. Open the Web UI in Chrome/Edge on your phone or desktop
2. Tap the **Install** icon in the address bar (or "Add to Home Screen" on mobile)
3. The app launches in standalone mode — no browser chrome, with proper safe area handling for notched phones
### Audio Visualizer
The Web UI includes a real-time audio spectrum visualizer that captures system audio output:
- **On-demand capture** - Audio capture starts only when a client enables the visualizer, and stops when the last client disconnects
- **Beat-reactive effects** - Album art pulses and glows in response to bass frequencies
- **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_device: "Speakers" # optional: specific device name
```
### Remote Access
To access the Web UI from other devices on your network:
@@ -93,15 +133,21 @@ See [CLAUDE.md](CLAUDE.md#internationalization-i18n) for detailed translation gu
The Media Browser feature allows you to browse and play media files from configured folders directly through the Web UI.
![Media Browser](docs/media-browser.PNG)
### Browser Features
- **Folder Configuration** - Mount multiple media folders (music/video directories)
- **Recursive Navigation** - Browse through folder hierarchies with breadcrumb navigation
- **Thumbnail Display** - Automatically generated thumbnails from album art
- **Metadata Extraction** - View title, artist, album, duration, bitrate, and more
- **Multiple View Modes** - Grid, compact grid, and list views with toggle buttons
- **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
- **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
- **Lazy Loading** - Thumbnails load only when visible for better performance
### Configuration
@@ -375,7 +421,9 @@ All control endpoints require authentication and return `{"success": true}` on s
### Script Execution
The server supports executing pre-defined scripts via 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.
![Script and Callback Management](docs/scripts-management.PNG)
#### List Scripts

BIN
docs/media-browser.PNG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

BIN
docs/scripts-management.PNG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
docs/web-ui.PNG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

View File

@@ -36,9 +36,7 @@ async def verify_token(
) -> str:
"""Verify the API token from the Authorization header.
Args:
request: The incoming request
credentials: The bearer token credentials
Reuses the label from middleware context when already validated.
Returns:
The token label
@@ -46,6 +44,11 @@ async def verify_token(
Raises:
HTTPException: If the token is missing or invalid
"""
# Reuse label already set by middleware to avoid redundant O(n) scan
existing = token_label_var.get("unknown")
if existing != "unknown":
return existing
if credentials is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -61,7 +64,6 @@ async def verify_token(
headers={"WWW-Authenticate": "Bearer"},
)
# Set label in context for logging
token_label_var.set(label)
return label
@@ -120,6 +122,11 @@ async def verify_token_or_query(
Raises:
HTTPException: If the token is missing or invalid
"""
# Reuse label already set by middleware
existing = token_label_var.get("unknown")
if existing != "unknown":
return existing
label = None
# Try header first
@@ -137,6 +144,5 @@ async def verify_token_or_query(
headers={"WWW-Authenticate": "Bearer"},
)
# Set label in context for logging
token_label_var.set(label)
return label

View File

@@ -39,6 +39,15 @@ class ScriptConfig(BaseModel):
shell: bool = Field(default=True, description="Run command in shell")
class LinkConfig(BaseModel):
"""Configuration for a header quick link."""
url: str = Field(..., description="URL to open")
icon: str = Field(default="mdi:link", description="MDI icon name (e.g., 'mdi:led-strip-variant')")
label: str = Field(default="", description="Tooltip text")
description: str = Field(default="", description="Optional description")
class Settings(BaseSettings):
"""Application settings loaded from environment or config file."""
@@ -97,6 +106,34 @@ class Settings(BaseSettings):
description='Thumbnail size: "small" (150x150), "medium" (300x300), or "both"',
)
# Header quick links
links: dict[str, LinkConfig] = Field(
default_factory=dict,
description="Quick links displayed as icons in the header",
)
# Audio visualizer
visualizer_enabled: bool = Field(
default=True,
description="Enable audio spectrum visualizer (requires soundcard + numpy)",
)
visualizer_fps: int = Field(
default=30,
description="Visualizer update rate in frames per second",
ge=10,
le=60,
)
visualizer_bins: int = Field(
default=32,
description="Number of frequency bins for the visualizer",
ge=8,
le=128,
)
visualizer_device: Optional[str] = Field(
default=None,
description="Loopback audio device name for visualizer (None = auto-detect)",
)
@classmethod
def load_from_yaml(cls, path: Optional[Path] = None) -> "Settings":
"""Load settings from a YAML configuration file."""

View File

@@ -8,7 +8,7 @@ from typing import Optional
import yaml
from .config import CallbackConfig, MediaFolderConfig, ScriptConfig, settings
from .config import CallbackConfig, LinkConfig, MediaFolderConfig, ScriptConfig, settings
logger = logging.getLogger(__name__)
@@ -387,6 +387,70 @@ class ConfigManager:
logger.info(f"Media folder '{folder_id}' deleted from config")
def add_link(self, name: str, config: LinkConfig) -> None:
"""Add a new link to config."""
with self._lock:
if not self._config_path.exists():
data = {}
else:
with open(self._config_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
if "links" in data and name in data["links"]:
raise ValueError(f"Link '{name}' already exists")
if "links" not in data:
data["links"] = {}
data["links"][name] = config.model_dump(exclude_none=True)
self._config_path.parent.mkdir(parents=True, exist_ok=True)
with open(self._config_path, "w", encoding="utf-8") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
settings.links[name] = config
logger.info(f"Link '{name}' added to config")
def update_link(self, name: str, config: LinkConfig) -> None:
"""Update an existing link."""
with self._lock:
if not self._config_path.exists():
raise ValueError(f"Config file not found: {self._config_path}")
with open(self._config_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
if "links" not in data or name not in data["links"]:
raise ValueError(f"Link '{name}' does not exist")
data["links"][name] = config.model_dump(exclude_none=True)
with open(self._config_path, "w", encoding="utf-8") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
settings.links[name] = config
logger.info(f"Link '{name}' updated in config")
def delete_link(self, name: str) -> None:
"""Delete a link from config."""
with self._lock:
if not self._config_path.exists():
raise ValueError(f"Config file not found: {self._config_path}")
with open(self._config_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
if "links" not in data or name not in data["links"]:
raise ValueError(f"Link '{name}' does not exist")
del data["links"][name]
with open(self._config_path, "w", encoding="utf-8") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
if name in settings.links:
del settings.links[name]
logger.info(f"Link '{name}' deleted from config")
# Global config manager instance
config_manager = ConfigManager()

View File

@@ -9,13 +9,14 @@ from pathlib import Path
import uvicorn
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import FileResponse
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, health_router, media_router, scripts_router
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
@@ -58,10 +59,38 @@ async def lifespan(app: FastAPI):
await ws_manager.start_status_monitor(controller.get_status)
logger.info("WebSocket status monitor started")
# Register audio visualizer (capture starts on-demand when clients subscribe)
analyzer = None
if settings.visualizer_enabled:
from .services.audio_analyzer import get_audio_analyzer
analyzer = get_audio_analyzer(
num_bins=settings.visualizer_bins,
target_fps=settings.visualizer_fps,
device_name=settings.visualizer_device,
)
if analyzer.available:
await ws_manager.start_audio_monitor(analyzer)
logger.info("Audio visualizer available (capture on-demand)")
else:
logger.info("Audio visualizer unavailable (install soundcard + numpy)")
yield
# Stop audio visualizer
await ws_manager.stop_audio_monitor()
if analyzer and analyzer.running:
analyzer.stop()
# Stop WebSocket status monitor
await ws_manager.stop_status_monitor()
# Clean up platform-specific resources
import platform as _platform
if _platform.system() == "Windows":
from .services.windows_media import shutdown_executor
shutdown_executor()
logger.info("Media Server shutting down")
@@ -74,11 +103,15 @@ def create_app() -> FastAPI:
lifespan=lifespan,
)
# Compress responses > 1KB
app.add_middleware(GZipMiddleware, minimum_size=1000)
# Add CORS middleware for cross-origin requests
# Token auth is via Authorization header, not cookies, so credentials are not needed
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
@@ -112,13 +145,24 @@ def create_app() -> FastAPI:
app.include_router(audio_router)
app.include_router(browser_router)
app.include_router(callbacks_router)
app.include_router(display_router)
app.include_router(health_router)
app.include_router(links_router)
app.include_router(media_router)
app.include_router(scripts_router)
# Mount static files and serve UI at root
static_dir = Path(__file__).parent / "static"
if static_dir.exists():
@app.get("/sw.js", include_in_schema=False)
async def serve_service_worker():
"""Serve service worker from root scope for PWA installability."""
return FileResponse(
static_dir / "sw.js",
media_type="application/javascript",
headers={"Cache-Control": "no-cache"},
)
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
@app.get("/", include_in_schema=False)

View File

@@ -3,8 +3,10 @@
from .audio import router as audio_router
from .browser import router as browser_router
from .callbacks import router as callbacks_router
from .display import router as display_router
from .health import router as health_router
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", "health_router", "media_router", "scripts_router"]
__all__ = ["audio_router", "browser_router", "callbacks_router", "display_router", "health_router", "links_router", "media_router", "scripts_router"]

View File

@@ -2,15 +2,16 @@
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 StreamingResponse
from fastapi.responses import FileResponse, StreamingResponse
from pydantic import BaseModel, Field
from ..auth import verify_token
from ..auth import verify_token, verify_token_or_query
from ..config import MediaFolderConfig, settings
from ..config_manager import config_manager
from ..services.browser_service import BrowserService
@@ -24,6 +25,28 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/browser", tags=["browser"])
async def _broadcast_after_open(controller, label: str, max_wait: float = 2.0) -> None:
"""Poll until media session registers, then broadcast status update.
Fires as a background task so the HTTP response returns immediately.
"""
try:
interval = 0.3
elapsed = 0.0
while elapsed < max_wait:
await asyncio.sleep(interval)
elapsed += interval
status = await controller.get_status()
if status.state in ("playing", "paused"):
break
status_dict = status.model_dump()
await ws_manager.broadcast({"type": "status", "data": status_dict})
logger.info(f"Broadcasted status update after opening: {label}")
except Exception as e:
logger.warning(f"Failed to broadcast status after opening {label}: {e}")
# Request/Response Models
class FolderCreateRequest(BaseModel):
"""Request model for creating a media folder."""
@@ -48,6 +71,13 @@ class PlayRequest(BaseModel):
path: str = Field(..., description="Full path to the media file")
class PlayFolderRequest(BaseModel):
"""Request model for playing all media files in a folder."""
folder_id: str = Field(..., description="Media folder ID")
path: str = Field(default="", description="Path relative to folder root")
# Folder Management Endpoints
@router.get("/folders")
async def list_folders(_: str = Depends(verify_token)):
@@ -113,6 +143,8 @@ async def create_folder(
"message": f"Media folder '{request.folder_id}' created successfully",
}
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
@@ -161,6 +193,8 @@ async def update_folder(
"message": f"Media folder '{folder_id}' updated successfully",
}
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
@@ -206,6 +240,7 @@ async def browse(
path: str = Query(default="", description="Path relative to folder root"),
offset: int = Query(default=0, ge=0, description="Pagination offset"),
limit: int = Query(default=100, ge=1, le=1000, description="Pagination limit"),
nocache: bool = Query(default=False, description="Bypass directory cache"),
_: str = Depends(verify_token),
):
"""Browse a directory and list files/folders.
@@ -232,6 +267,7 @@ async def browse(
path=decoded_path,
offset=offset,
limit=limit,
nocache=nocache,
)
return result
@@ -240,8 +276,15 @@ async def browse(
raise HTTPException(status_code=400, detail=str(e))
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except (OSError, PermissionError) as e:
# Network share unavailable or access denied
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."
)
except Exception as e:
logger.error(f"Error browsing directory: {e}")
logger.error(f"Error browsing directory (type: {type(e).__name__}): {e}")
raise HTTPException(status_code=500, detail="Failed to browse directory")
@@ -328,7 +371,7 @@ async def get_thumbnail(
thumbnail_data = await ThumbnailService.get_thumbnail(file_path, size)
if thumbnail_data is None:
raise HTTPException(status_code=404, detail="Thumbnail not available")
return Response(status_code=204)
# Calculate ETag (hash of path + mtime)
import hashlib
@@ -391,21 +434,8 @@ async def play_file(
if not success:
raise HTTPException(status_code=500, detail="Failed to open file")
# Wait for media player to start and register with Windows Media Session API
# This allows the UI to update immediately with the new playback state
await asyncio.sleep(1.5)
# Get updated status and broadcast to all connected clients
try:
status = await controller.get_status()
status_dict = status.model_dump()
await ws_manager.broadcast({
"type": "status",
"data": status_dict
})
logger.info(f"Broadcasted status update after opening file: {file_path.name}")
except Exception as e:
logger.warning(f"Failed to broadcast status after opening file: {e}")
# Poll until player registers with media session API (up to 2s)
asyncio.create_task(_broadcast_after_open(controller, file_path.name))
return {
"success": True,
@@ -417,3 +447,121 @@ async def play_file(
except Exception as e:
logger.error(f"Error playing file: {e}")
raise HTTPException(status_code=500, detail="Failed to play file")
# Play Folder Endpoint (M3U playlist)
@router.post("/play-folder")
async def play_folder(
request: PlayFolderRequest,
_: str = Depends(verify_token),
):
"""Play all media files in a folder by generating an M3U playlist.
Args:
request: Play folder request with folder_id and path.
Returns:
Success message with file count.
Raises:
HTTPException: If folder not found or playback fails.
"""
try:
decoded_path = unquote(request.path)
full_path = BrowserService.validate_path(request.folder_id, decoded_path)
if not full_path.is_dir():
raise HTTPException(status_code=400, detail="Path is not a directory")
# Collect all media files sorted by name
media_files = sorted(
[f for f in full_path.iterdir() if f.is_file() and BrowserService.is_media_file(f)],
key=lambda f: f.name.lower(),
)
if not media_files:
raise HTTPException(status_code=404, detail="No media files found in this folder")
# Generate M3U playlist with absolute paths and EXTINF entries
# Written to local temp dir to avoid extra SMB file handle on network shares
# Uses utf-8-sig (BOM) so players detect encoding properly
lines = ["#EXTM3U"]
for f in media_files:
lines.append(f"#EXTINF:-1,{f.stem}")
lines.append(str(f))
m3u_content = "\r\n".join(lines) + "\r\n"
playlist_path = Path(tempfile.gettempdir()) / ".media_server_playlist.m3u"
playlist_path.write_text(m3u_content, encoding="utf-8-sig")
# Open playlist with default player
controller = get_media_controller()
success = await controller.open_file(playlist_path)
if not success:
raise HTTPException(status_code=500, detail="Failed to open playlist")
# Poll until player registers with media session API (up to 2s)
asyncio.create_task(_broadcast_after_open(controller, f"playlist ({len(media_files)} files)"))
return {
"success": True,
"message": f"Playing {len(media_files)} files",
"count": len(media_files),
}
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Error playing folder: {e}")
raise HTTPException(status_code=500, detail="Failed to play folder")
# Download Endpoint
@router.get("/download")
async def download_file(
folder_id: str = Query(..., description="Media folder ID"),
path: str = Query(..., description="File path relative to folder root (URL-encoded)"),
_: str = Depends(verify_token_or_query),
):
"""Download a media file.
Args:
folder_id: ID of the media folder.
path: Path to the file (URL-encoded, relative to folder root).
Returns:
File download response.
Raises:
HTTPException: If file not found or not a media file.
"""
try:
decoded_path = unquote(path)
file_path = BrowserService.validate_path(folder_id, decoded_path)
if not file_path.is_file():
raise HTTPException(status_code=400, detail="Path is not a file")
if not BrowserService.is_media_file(file_path):
raise HTTPException(status_code=400, detail="File is not a media file")
return FileResponse(
path=file_path,
filename=file_path.name,
media_type="application/octet-stream",
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error downloading file: {e}")
raise HTTPException(status_code=500, detail="Failed to download file")

View File

@@ -5,6 +5,7 @@ import logging
import re
import subprocess
import time
from concurrent.futures import ThreadPoolExecutor
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
@@ -17,6 +18,9 @@ from ..config_manager import config_manager
router = APIRouter(prefix="/api/callbacks", tags=["callbacks"])
logger = logging.getLogger(__name__)
# Dedicated executor for callback/subprocess execution
_callback_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="callback")
class CallbackInfo(BaseModel):
"""Information about a configured callback."""
@@ -127,10 +131,10 @@ async def execute_callback(
logger.info(f"Executing callback for debugging: {callback_name}")
try:
# Execute in thread pool to not block
# Execute in dedicated thread pool to not block the default executor
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
None,
_callback_executor,
lambda: _run_callback(
command=callback_config.command,
timeout=callback_config.timeout,

View File

@@ -0,0 +1,59 @@
"""Display brightness and power control API endpoints."""
import logging
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from ..auth import verify_token
from ..services.display_service import (
get_brightness,
list_monitors,
set_brightness,
set_power,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/display", tags=["display"])
class BrightnessRequest(BaseModel):
brightness: int = Field(ge=0, le=100)
class PowerRequest(BaseModel):
on: bool
@router.get("/monitors")
async def get_monitors(
refresh: bool = False, _: str = Depends(verify_token)
) -> list[dict]:
"""List all connected monitors with brightness and power info."""
monitors = list_monitors(force_refresh=refresh)
logger.debug("Found %d monitors", len(monitors))
return [m.to_dict() for m in monitors]
@router.post("/brightness/{monitor_id}")
async def set_monitor_brightness(
monitor_id: int, request: BrightnessRequest, _: str = Depends(verify_token)
) -> dict:
"""Set brightness for a specific monitor."""
success = set_brightness(monitor_id, request.brightness)
if success:
logger.info("Set monitor %d brightness to %d", monitor_id, request.brightness)
return {"success": success}
@router.post("/power/{monitor_id}")
async def set_monitor_power(
monitor_id: int, request: PowerRequest, _: str = Depends(verify_token)
) -> dict:
"""Turn a monitor on or off."""
action = "on" if request.on else "off"
success = set_power(monitor_id, request.on)
if success:
logger.info("Set monitor %d power %s", monitor_id, action)
return {"success": success}

View File

@@ -0,0 +1,188 @@
"""Header quick links management API endpoints."""
import logging
import re
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from ..auth import verify_token
from ..config import LinkConfig, settings
from ..config_manager import config_manager
from ..services.websocket_manager import ws_manager
router = APIRouter(prefix="/api/links", tags=["links"])
logger = logging.getLogger(__name__)
class LinkInfo(BaseModel):
"""Information about a configured link."""
name: str
url: str
icon: str
label: str
description: str
class LinkCreateRequest(BaseModel):
"""Request model for creating or updating a link."""
url: str = Field(..., description="URL to open", min_length=1)
icon: str = Field(default="mdi:link", description="MDI icon name (e.g., 'mdi:led-strip-variant')")
label: str = Field(default="", description="Tooltip text")
description: str = Field(default="", description="Optional description")
def _validate_link_name(name: str) -> None:
"""Validate link name.
Args:
name: Link name to validate.
Raises:
HTTPException: If name is invalid.
"""
if not re.match(r'^[a-zA-Z0-9_]+$', name):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Link name must contain only letters, numbers, and underscores",
)
if len(name) > 64:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Link name must be 64 characters or less",
)
@router.get("/list")
async def list_links(_: str = Depends(verify_token)) -> list[LinkInfo]:
"""List all configured links.
Returns:
List of configured links.
"""
return [
LinkInfo(
name=name,
url=config.url,
icon=config.icon,
label=config.label,
description=config.description,
)
for name, config in settings.links.items()
]
@router.post("/create/{link_name}")
async def create_link(
link_name: str,
request: LinkCreateRequest,
_: str = Depends(verify_token),
) -> dict[str, Any]:
"""Create a new link.
Args:
link_name: Link name (alphanumeric and underscores only).
request: Link configuration.
Returns:
Success response with link name.
"""
_validate_link_name(link_name)
if link_name in settings.links:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Link '{link_name}' already exists. Use PUT /api/links/update/{link_name} to update it.",
)
link_config = LinkConfig(**request.model_dump())
try:
config_manager.add_link(link_name, link_config)
except Exception as e:
logger.error(f"Failed to add link '{link_name}': {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to add link: {str(e)}",
)
await ws_manager.broadcast_links_changed()
logger.info(f"Link '{link_name}' created successfully")
return {"success": True, "link": link_name}
@router.put("/update/{link_name}")
async def update_link(
link_name: str,
request: LinkCreateRequest,
_: str = Depends(verify_token),
) -> dict[str, Any]:
"""Update an existing link.
Args:
link_name: Link name.
request: Updated link configuration.
Returns:
Success response with link name.
"""
_validate_link_name(link_name)
if link_name not in settings.links:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Link '{link_name}' not found. Use POST /api/links/create/{link_name} to create it.",
)
link_config = LinkConfig(**request.model_dump())
try:
config_manager.update_link(link_name, link_config)
except Exception as e:
logger.error(f"Failed to update link '{link_name}': {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to update link: {str(e)}",
)
await ws_manager.broadcast_links_changed()
logger.info(f"Link '{link_name}' updated successfully")
return {"success": True, "link": link_name}
@router.delete("/delete/{link_name}")
async def delete_link(
link_name: str,
_: str = Depends(verify_token),
) -> dict[str, Any]:
"""Delete a link.
Args:
link_name: Link name.
Returns:
Success response with link name.
"""
_validate_link_name(link_name)
if link_name not in settings.links:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Link '{link_name}' not found",
)
try:
config_manager.delete_link(link_name)
except Exception as e:
logger.error(f"Failed to delete link '{link_name}': {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delete link: {str(e)}",
)
await ws_manager.broadcast_links_changed()
logger.info(f"Link '{link_name}' deleted successfully")
return {"success": True, "link": link_name}

View File

@@ -18,31 +18,37 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/media", tags=["media"])
async def _run_callback(callback_name: str) -> None:
"""Run a callback if configured. Failures are logged but don't raise."""
def _run_callback(callback_name: str) -> None:
"""Fire-and-forget a callback if configured. Failures are logged but don't block."""
if not settings.callbacks or callback_name not in settings.callbacks:
return
from .scripts import _run_script
async def _execute():
from .scripts import _run_script
callback = settings.callbacks[callback_name]
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
None,
lambda: _run_script(
command=callback.command,
timeout=callback.timeout,
shell=callback.shell,
working_dir=callback.working_dir,
),
)
if result["exit_code"] != 0:
logger.warning(
"Callback %s failed with exit code %s: %s",
callback_name,
result["exit_code"],
result["stderr"],
)
try:
callback = settings.callbacks[callback_name]
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
None,
lambda: _run_script(
command=callback.command,
timeout=callback.timeout,
shell=callback.shell,
working_dir=callback.working_dir,
),
)
if result["exit_code"] != 0:
logger.warning(
"Callback %s failed with exit code %s: %s",
callback_name,
result["exit_code"],
result["stderr"],
)
except Exception as e:
logger.error("Callback %s error: %s", callback_name, e)
asyncio.create_task(_execute())
@router.get("/status", response_model=MediaStatus)
@@ -70,7 +76,7 @@ async def play(_: str = Depends(verify_token)) -> dict:
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Failed to start playback - no active media session",
)
await _run_callback("on_play")
_run_callback("on_play")
return {"success": True}
@@ -88,7 +94,7 @@ async def pause(_: str = Depends(verify_token)) -> dict:
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Failed to pause - no active media session",
)
await _run_callback("on_pause")
_run_callback("on_pause")
return {"success": True}
@@ -106,7 +112,7 @@ async def stop(_: str = Depends(verify_token)) -> dict:
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Failed to stop - no active media session",
)
await _run_callback("on_stop")
_run_callback("on_stop")
return {"success": True}
@@ -124,7 +130,7 @@ async def next_track(_: str = Depends(verify_token)) -> dict:
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Failed to skip - no active media session",
)
await _run_callback("on_next")
_run_callback("on_next")
return {"success": True}
@@ -142,7 +148,7 @@ async def previous_track(_: str = Depends(verify_token)) -> dict:
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Failed to go back - no active media session",
)
await _run_callback("on_previous")
_run_callback("on_previous")
return {"success": True}
@@ -165,7 +171,7 @@ async def set_volume(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Failed to set volume",
)
await _run_callback("on_volume")
_run_callback("on_volume")
return {"success": True, "volume": request.volume}
@@ -178,7 +184,7 @@ async def toggle_mute(_: str = Depends(verify_token)) -> dict:
"""
controller = get_media_controller()
muted = await controller.toggle_mute()
await _run_callback("on_mute")
_run_callback("on_mute")
return {"success": True, "muted": muted}
@@ -199,7 +205,7 @@ async def seek(request: SeekRequest, _: str = Depends(verify_token)) -> dict:
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Failed to seek - no active media session or seek not supported",
)
await _run_callback("on_seek")
_run_callback("on_seek")
return {"success": True, "position": request.position}
@@ -210,7 +216,7 @@ async def turn_on(_: str = Depends(verify_token)) -> dict:
Returns:
Success status
"""
await _run_callback("on_turn_on")
_run_callback("on_turn_on")
return {"success": True}
@@ -221,7 +227,7 @@ async def turn_off(_: str = Depends(verify_token)) -> dict:
Returns:
Success status
"""
await _run_callback("on_turn_off")
_run_callback("on_turn_off")
return {"success": True}
@@ -232,7 +238,7 @@ async def toggle(_: str = Depends(verify_token)) -> dict:
Returns:
Success status
"""
await _run_callback("on_toggle")
_run_callback("on_toggle")
return {"success": True}
@@ -262,6 +268,53 @@ async def get_artwork(_: str = Depends(verify_token_or_query)) -> Response:
return Response(content=art_bytes, media_type=content_type)
@router.get("/visualizer/status")
async def visualizer_status(_: str = Depends(verify_token)) -> dict:
"""Check if audio visualizer is available and running."""
from ..services.audio_analyzer import get_audio_analyzer
analyzer = get_audio_analyzer()
return {
"available": analyzer.available,
"running": analyzer.running,
"current_device": analyzer.current_device,
}
@router.get("/visualizer/devices")
async def visualizer_devices(_: str = Depends(verify_token)) -> list[dict[str, str]]:
"""List available loopback audio devices for the visualizer."""
from ..services.audio_analyzer import AudioAnalyzer
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, AudioAnalyzer.list_loopback_devices)
@router.post("/visualizer/device")
async def set_visualizer_device(
request: dict,
_: str = Depends(verify_token),
) -> dict:
"""Set the loopback audio device for the visualizer.
Body: {"device_name": "Device Name" | null}
Passing null resets to auto-detect.
"""
from ..services.audio_analyzer import get_audio_analyzer
device_name = request.get("device_name")
analyzer = get_audio_analyzer()
# set_device() handles stop/start internally if capture was running
success = analyzer.set_device(device_name)
return {
"success": success,
"current_device": analyzer.current_device,
"running": analyzer.running,
}
@router.websocket("/ws")
async def websocket_endpoint(
websocket: WebSocket,
@@ -309,6 +362,16 @@ async def websocket_endpoint(
"type": "status",
"data": status_data.model_dump(),
})
elif data.get("type") == "volume":
# Low-latency volume control via WebSocket
volume = data.get("volume")
if volume is not None:
controller = get_media_controller()
await controller.set_volume(int(volume))
elif data.get("type") == "enable_visualizer":
await ws_manager.subscribe_visualizer(websocket)
elif data.get("type") == "disable_visualizer":
await ws_manager.unsubscribe_visualizer(websocket)
except WebSocketDisconnect:
await ws_manager.disconnect(websocket)

View File

@@ -5,6 +5,7 @@ import logging
import re
import subprocess
import time
from concurrent.futures import ThreadPoolExecutor
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
@@ -16,6 +17,9 @@ from ..config_manager import config_manager
from ..services.websocket_manager import ws_manager
router = APIRouter(prefix="/api/scripts", tags=["scripts"])
# Dedicated executor for script/subprocess execution (avoids blocking the default pool)
_script_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="script")
logger = logging.getLogger(__name__)
@@ -101,10 +105,10 @@ async def execute_script(
# Append arguments to command
command = f"{command} {' '.join(args)}"
# Execute in thread pool to not block
# Execute in dedicated thread pool to not block the default executor
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
None,
_script_executor,
lambda: _run_script(
command=command,
timeout=script_config.timeout,

View File

@@ -1,19 +0,0 @@
Unregister-ScheduledTask -TaskName "MediaServer" -Confirm:$false -ErrorAction SilentlyContinue
# Get the project root directory (two levels up from this script)
$projectRoot = (Get-Item $PSScriptRoot).Parent.Parent.FullName
# Find Python executable
$pythonPath = (Get-Command python -ErrorAction SilentlyContinue).Source
if (-not $pythonPath) {
Write-Error "Python not found in PATH. Please ensure Python is installed and accessible."
exit 1
}
$action = New-ScheduledTaskAction -Execute $pythonPath -Argument "-m media_server.main" -WorkingDirectory $projectRoot
$trigger = New-ScheduledTaskTrigger -AtStartup
$principal = New-ScheduledTaskPrincipal -UserId "$env:USERNAME" -LogonType S4U -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable
Register-ScheduledTask -TaskName "MediaServer" -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Description "Media Server for Home Assistant"
Write-Host "Scheduled task 'MediaServer' created with working directory: $projectRoot"

View File

@@ -0,0 +1,318 @@
"""Audio spectrum analyzer service using system loopback capture."""
import logging
import platform
import threading
import time
logger = logging.getLogger(__name__)
_np = None
_sc = None
def _load_numpy():
global _np
if _np is None:
try:
import numpy as np
_np = np
except ImportError:
logger.info("numpy not installed - audio visualizer unavailable")
return _np
def _load_soundcard():
global _sc
if _sc is None:
try:
import soundcard as sc
_sc = sc
except ImportError:
logger.info("soundcard not installed - audio visualizer unavailable")
return _sc
class AudioAnalyzer:
"""Captures system audio loopback and performs real-time FFT analysis."""
def __init__(
self,
num_bins: int = 32,
sample_rate: int = 44100,
chunk_size: int = 1024,
target_fps: int = 30,
device_name: str | None = None,
):
self.num_bins = num_bins
self.sample_rate = sample_rate
self.chunk_size = chunk_size
self.target_fps = target_fps
self.device_name = device_name
self._running = False
self._thread: threading.Thread | None = None
self._lock = threading.Lock()
self._lifecycle_lock = threading.Lock()
self._data: dict | None = None
self._current_device_name: str | None = None
# Pre-compute logarithmic bin edges
self._bin_edges = self._compute_bin_edges()
def _compute_bin_edges(self) -> list[int]:
"""Compute logarithmic frequency bin boundaries for perceptual grouping."""
np = _load_numpy()
if np is None:
return []
fft_size = self.chunk_size // 2 + 1
min_freq = 20.0
max_freq = min(16000.0, self.sample_rate / 2)
edges = []
for i in range(self.num_bins + 1):
freq = min_freq * (max_freq / min_freq) ** (i / self.num_bins)
bin_idx = int(freq * self.chunk_size / self.sample_rate)
edges.append(min(bin_idx, fft_size - 1))
return edges
@property
def available(self) -> bool:
"""Whether audio capture dependencies are available."""
return _load_numpy() is not None and _load_soundcard() is not None
@property
def running(self) -> bool:
"""Whether capture is currently active."""
return self._running
def start(self) -> bool:
"""Start audio capture in a background thread. Returns False if unavailable."""
with self._lifecycle_lock:
if self._running:
return True
if not self.available:
return False
self._running = True
self._thread = threading.Thread(target=self._capture_loop, daemon=True)
self._thread.start()
return True
def stop(self) -> None:
"""Stop audio capture and cleanup."""
with self._lifecycle_lock:
self._running = False
if self._thread:
self._thread.join(timeout=3.0)
self._thread = None
with self._lock:
self._data = None
def get_frequency_data(self) -> dict | None:
"""Return latest frequency data (thread-safe). None if not running."""
with self._lock:
return self._data
@staticmethod
def list_loopback_devices() -> list[dict[str, str]]:
"""List all available loopback audio devices."""
sc = _load_soundcard()
if sc is None:
return []
devices = []
try:
# COM may be needed on Windows for WASAPI
if platform.system() == "Windows":
try:
import comtypes
comtypes.CoInitializeEx(comtypes.COINIT_MULTITHREADED)
except Exception:
pass
loopback_mics = sc.all_microphones(include_loopback=True)
for mic in loopback_mics:
if mic.isloopback:
devices.append({"id": mic.id, "name": mic.name})
except Exception as e:
logger.warning("Failed to list loopback devices: %s", e)
return devices
def _find_loopback_device(self):
"""Find a loopback device for system audio capture."""
sc = _load_soundcard()
if sc is None:
return None
try:
loopback_mics = sc.all_microphones(include_loopback=True)
# If a specific device is requested, find it by name (partial match)
if self.device_name:
target = self.device_name.lower()
for mic in loopback_mics:
if mic.isloopback and target in mic.name.lower():
logger.info("Found requested loopback device: %s", mic.name)
self._current_device_name = mic.name
return mic
logger.warning("Requested device '%s' not found, falling back to default", self.device_name)
# Default: first loopback device
for mic in loopback_mics:
if mic.isloopback:
logger.info("Found loopback device: %s", mic.name)
self._current_device_name = mic.name
return mic
# Fallback: try to get default speaker's loopback
default_speaker = sc.default_speaker()
if default_speaker:
for mic in loopback_mics:
if default_speaker.name in mic.name:
logger.info("Found speaker loopback: %s", mic.name)
self._current_device_name = mic.name
return mic
except Exception as e:
logger.warning("Failed to find loopback device: %s", e)
return None
def set_device(self, device_name: str | None) -> bool:
"""Change the loopback device. Restarts capture if running. Returns True on success."""
was_running = self._running
if was_running:
self.stop()
self.device_name = device_name
self._current_device_name = None
if was_running:
return self.start()
return True
@property
def current_device(self) -> str | None:
"""Return the name of the currently active loopback device."""
return self._current_device_name
def _capture_loop(self) -> None:
"""Background thread: capture audio and compute FFT continuously."""
# Initialize COM on Windows (required for WASAPI/SoundCard)
if platform.system() == "Windows":
try:
import comtypes
comtypes.CoInitializeEx(comtypes.COINIT_MULTITHREADED)
except Exception:
try:
import ctypes
ctypes.windll.ole32.CoInitializeEx(0, 0)
except Exception as e:
logger.warning("Failed to initialize COM: %s", e)
np = _load_numpy()
sc = _load_soundcard()
if np is None or sc is None:
self._running = False
return
device = self._find_loopback_device()
if device is None:
logger.warning("No loopback audio device found - visualizer disabled")
self._running = False
return
interval = 1.0 / self.target_fps
window = np.hanning(self.chunk_size)
# Pre-compute bin edge pairs for vectorized grouping
edges = self._bin_edges
bin_starts = np.array([edges[i] for i in range(self.num_bins)], dtype=np.intp)
bin_ends = np.array([max(edges[i + 1], edges[i] + 1) for i in range(self.num_bins)], dtype=np.intp)
try:
with device.recorder(
samplerate=self.sample_rate,
channels=1,
blocksize=self.chunk_size,
) as recorder:
logger.info("Audio capture started on: %s", device.name)
while self._running:
t0 = time.monotonic()
try:
data = recorder.record(numframes=self.chunk_size)
except Exception as e:
logger.debug("Audio capture read error: %s", e)
time.sleep(interval)
continue
# Mono mix if needed
if data.ndim > 1:
mono = data.mean(axis=1)
else:
mono = data.ravel()
if len(mono) < self.chunk_size:
time.sleep(interval)
continue
# Apply window and compute FFT
windowed = mono[:self.chunk_size] * window
fft_mag = np.abs(np.fft.rfft(windowed))
# Group into logarithmic bins (vectorized via cumsum)
cumsum = np.concatenate(([0.0], np.cumsum(fft_mag)))
counts = bin_ends - bin_starts
bins = (cumsum[bin_ends] - cumsum[bin_starts]) / counts
# Normalize to 0-1
max_val = bins.max()
if max_val > 0:
bins *= (1.0 / max_val)
# Bass energy: average of first 4 bins (~20-200Hz)
bass = float(bins[:4].mean()) if self.num_bins >= 4 else 0.0
# Round for compact JSON
frequencies = np.round(bins, 3).tolist()
bass = round(bass, 3)
with self._lock:
self._data = {"frequencies": frequencies, "bass": bass}
# Throttle to target FPS
elapsed = time.monotonic() - t0
if elapsed < interval:
time.sleep(interval - elapsed)
except Exception as e:
logger.error("Audio capture loop error: %s", e)
finally:
self._running = False
logger.info("Audio capture stopped")
# Global singleton
_analyzer: AudioAnalyzer | None = None
def get_audio_analyzer(
num_bins: int = 32,
sample_rate: int = 44100,
target_fps: int = 25,
device_name: str | None = None,
) -> AudioAnalyzer:
"""Get or create the global AudioAnalyzer instance."""
global _analyzer
if _analyzer is None:
_analyzer = AudioAnalyzer(
num_bins=num_bins,
sample_rate=sample_rate,
target_fps=target_fps,
device_name=device_name,
)
return _analyzer

View File

@@ -2,12 +2,28 @@
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
# Directory listing cache: {resolved_path_str: (timestamp, all_items)}
_dir_cache: dict[str, tuple[float, list[dict]]] = {}
DIR_CACHE_TTL = 300 # 5 minutes
# Media info cache: {(file_path_str, mtime): {duration, bitrate, title}}
_media_info_cache: dict[tuple[str, float], dict] = {}
_MEDIA_INFO_CACHE_MAX = 5000
try:
from mutagen import File as MutagenFile
HAS_MUTAGEN = True
except ImportError:
HAS_MUTAGEN = False
logger = logging.getLogger(__name__)
# Media file extensions
@@ -109,12 +125,77 @@ class BrowserService:
else:
return "other"
@staticmethod
def get_media_info(file_path: Path, mtime: float | None = None) -> dict:
"""Get duration, bitrate, and title of a media file (header-only read).
Results are cached by (path, mtime) to avoid re-reading unchanged files.
Args:
file_path: Path to the media file.
mtime: File modification time (avoids an extra stat call).
Returns:
Dict with 'duration' (float or None), 'bitrate' (int or None),
and 'title' (str or None).
"""
result = {"duration": None, "bitrate": None, "title": None}
if not HAS_MUTAGEN:
return result
# Use mtime-based cache to skip mutagen reads for unchanged files
if mtime is None:
try:
mtime = file_path.stat().st_mtime
except (OSError, PermissionError):
pass
if mtime is not None:
cache_key = (str(file_path), mtime)
cached = _media_info_cache.get(cache_key)
if cached is not None:
return cached
try:
audio = MutagenFile(str(file_path), easy=True)
if audio is not None and hasattr(audio, "info"):
if hasattr(audio.info, "length"):
result["duration"] = round(audio.info.length, 2)
if hasattr(audio.info, "bitrate") and audio.info.bitrate:
result["bitrate"] = audio.info.bitrate
if audio is not None and hasattr(audio, "tags") and audio.tags:
tags = audio.tags
title = None
artist = None
if "title" in tags:
title = tags["title"][0] if isinstance(tags["title"], list) else tags["title"]
if "artist" in tags:
artist = tags["artist"][0] if isinstance(tags["artist"], list) else tags["artist"]
if artist and title:
result["title"] = f"{artist} \u2013 {title}"
elif title:
result["title"] = title
except Exception:
pass
# Cache result (evict oldest entries if cache is full)
if mtime is not None:
if len(_media_info_cache) >= _MEDIA_INFO_CACHE_MAX:
# Remove oldest ~20% of entries
to_remove = list(_media_info_cache.keys())[:_MEDIA_INFO_CACHE_MAX // 5]
for k in to_remove:
del _media_info_cache[k]
_media_info_cache[(str(file_path), mtime)] = result
return result
@staticmethod
def browse_directory(
folder_id: str,
path: str = "",
offset: int = 0,
limit: int = 100,
nocache: bool = False,
) -> dict:
"""Browse a directory and return items with metadata.
@@ -163,45 +244,77 @@ class BrowserService:
parent_relative = full_path.parent.relative_to(base_path)
parent_path = "/" + str(parent_relative).replace("\\", "/") if str(parent_relative) != "." else "/"
# List directory contents
# List directory contents (with caching)
try:
all_items = []
for item in full_path.iterdir():
# Skip hidden files (starting with .)
if item.name.startswith("."):
continue
cache_key = str(full_path)
now = time.monotonic()
# Get file type
file_type = BrowserService.get_file_type(item)
# Check cache
if not nocache and cache_key in _dir_cache:
cached_time, cached_items = _dir_cache[cache_key]
if now - cached_time < DIR_CACHE_TTL:
all_items = cached_items
else:
del _dir_cache[cache_key]
all_items = None
else:
all_items = None
# Skip non-media files (but include folders)
if file_type == "other" and not item.is_dir():
continue
# Enumerate directory if not cached
if all_items is None:
all_items = []
for item in full_path.iterdir():
if item.name.startswith("."):
continue
# Get file info
try:
stat = item.stat()
size = stat.st_size if item.is_file() else None
modified = datetime.fromtimestamp(stat.st_mtime).isoformat()
except (OSError, PermissionError):
size = None
modified = None
# Single stat() call per item — reuse for type check and metadata
try:
st = item.stat()
except (OSError, PermissionError):
continue
all_items.append({
"name": item.name,
"type": file_type,
"size": size,
"modified": modified,
"is_media": file_type in ("audio", "video"),
})
if stat_module.S_ISDIR(st.st_mode):
file_type = "folder"
else:
suffix = item.suffix.lower()
if suffix in AUDIO_EXTENSIONS:
file_type = "audio"
elif suffix in VIDEO_EXTENSIONS:
file_type = "video"
else:
continue
# Sort: folders first, then by name
all_items.sort(key=lambda x: (x["type"] != "folder", x["name"].lower()))
all_items.append({
"name": item.name,
"type": file_type,
"is_media": file_type in ("audio", "video"),
"_size": st.st_size,
"_mtime": st.st_mtime,
})
all_items.sort(key=lambda x: (x["type"] != "folder", x["name"].lower()))
_dir_cache[cache_key] = (now, all_items)
# Apply pagination
total = len(all_items)
items = all_items[offset:offset + limit]
# Enrich items on the current page with metadata
for item in items:
item["size"] = item["_size"] if item["type"] != "folder" else None
item["modified"] = datetime.fromtimestamp(item["_mtime"]).isoformat()
if item["is_media"]:
item_path = full_path / item["name"]
info = BrowserService.get_media_info(item_path, item["_mtime"])
item["duration"] = info["duration"]
item["bitrate"] = info["bitrate"]
item["title"] = info["title"]
else:
item["duration"] = None
item["bitrate"] = None
item["title"] = None
return {
"folder_id": folder_id,
"current_path": current_path,

View File

@@ -0,0 +1,271 @@
"""Display brightness and power control service."""
import ctypes
import ctypes.wintypes
import logging
import platform
import struct
import time
from dataclasses import dataclass, field
logger = logging.getLogger(__name__)
_sbc = None
_monitorcontrol = None
def _load_sbc():
global _sbc
if _sbc is None:
try:
import screen_brightness_control as sbc
_sbc = sbc
except ImportError:
logger.warning("screen_brightness_control not installed - brightness control unavailable")
return _sbc
def _load_monitorcontrol():
global _monitorcontrol
if _monitorcontrol is None:
try:
import monitorcontrol
_monitorcontrol = monitorcontrol
except ImportError:
logger.warning("monitorcontrol not installed - display power control unavailable")
return _monitorcontrol
def _parse_edid_resolution(edid_hex: str) -> str | None:
"""Parse resolution from EDID hex string (first detailed timing descriptor)."""
try:
edid = bytes.fromhex(edid_hex)
if len(edid) < 58:
return None
dtd = edid[54:]
pixel_clock = struct.unpack('<H', dtd[0:2])[0]
if pixel_clock == 0:
return None
h_active = dtd[2] | ((dtd[4] >> 4) << 8)
v_active = dtd[5] | ((dtd[7] >> 4) << 8)
return f"{h_active}x{v_active}"
except Exception:
return None
@dataclass
class MonitorInfo:
id: int
name: str
brightness: int | None
power_supported: bool
power_on: bool = True
model: str = ""
manufacturer: str = ""
resolution: str | None = None
is_primary: bool = False
def to_dict(self) -> dict:
return {
"id": self.id,
"name": self.name,
"brightness": self.brightness,
"power_supported": self.power_supported,
"power_on": self.power_on,
"model": self.model,
"manufacturer": self.manufacturer,
"resolution": self.resolution,
"is_primary": self.is_primary,
}
def _detect_primary_resolution() -> str | None:
"""Detect the primary display resolution via Windows API."""
if platform.system() != "Windows":
return None
try:
class MONITORINFO(ctypes.Structure):
_fields_ = [
("cbSize", ctypes.wintypes.DWORD),
("rcMonitor", ctypes.wintypes.RECT),
("rcWork", ctypes.wintypes.RECT),
("dwFlags", ctypes.wintypes.DWORD),
]
MONITORINFOF_PRIMARY = 1
primary_res = None
def callback(hmon, hdc, rect, data):
nonlocal primary_res
mi = MONITORINFO()
mi.cbSize = ctypes.sizeof(mi)
ctypes.windll.user32.GetMonitorInfoW(hmon, ctypes.byref(mi))
if mi.dwFlags & MONITORINFOF_PRIMARY:
w = mi.rcMonitor.right - mi.rcMonitor.left
h = mi.rcMonitor.bottom - mi.rcMonitor.top
primary_res = f"{w}x{h}"
return True
MONITORENUMPROC = ctypes.WINFUNCTYPE(
ctypes.c_int,
ctypes.wintypes.HMONITOR,
ctypes.wintypes.HDC,
ctypes.POINTER(ctypes.wintypes.RECT),
ctypes.wintypes.LPARAM,
)
ctypes.windll.user32.EnumDisplayMonitors(
None, None, MONITORENUMPROC(callback), 0
)
return primary_res
except Exception:
return None
def _mark_primary(monitors: list[MonitorInfo]) -> None:
"""Mark the primary display in the monitor list."""
if not monitors:
return
primary_res = _detect_primary_resolution()
if primary_res:
for m in monitors:
if m.resolution == primary_res:
m.is_primary = True
return
# Fallback: mark first monitor as primary
monitors[0].is_primary = True
# Cache for monitor list
_monitor_cache: list[MonitorInfo] | None = None
_cache_time: float = 0
_CACHE_TTL = 5.0 # seconds
def list_monitors(force_refresh: bool = False) -> list[MonitorInfo]:
"""List all connected monitors with their current brightness."""
global _monitor_cache, _cache_time
if not force_refresh and _monitor_cache is not None and (time.time() - _cache_time) < _CACHE_TTL:
return _monitor_cache
sbc = _load_sbc()
if sbc is None:
return []
monitors = []
try:
info_list = sbc.list_monitors_info()
brightnesses = sbc.get_brightness()
# Get DDC/CI monitors for power state
mc = _load_monitorcontrol()
ddc_monitors = []
if mc:
try:
ddc_monitors = mc.get_monitors()
except Exception:
pass
for i, info in enumerate(info_list):
name = info.get("name", f"Monitor {i}")
model = info.get("model", "")
manufacturer = info.get("manufacturer", "")
brightness = brightnesses[i] if i < len(brightnesses) else None
# VCP method monitors support DDC/CI power control
method = str(info.get("method", "")).lower()
power_supported = "vcp" in method
# Parse resolution from EDID
edid = info.get("edid", "")
resolution = _parse_edid_resolution(edid) if edid else None
# Read power state via DDC/CI
power_on = True
if power_supported and i < len(ddc_monitors):
try:
with ddc_monitors[i] as mon:
power_mode = mon.get_power_mode()
power_on = power_mode == mc.PowerMode.on
except Exception:
pass
monitors.append(MonitorInfo(
id=i,
name=name,
brightness=brightness,
power_supported=power_supported,
power_on=power_on,
model=model,
manufacturer=manufacturer,
resolution=resolution,
))
except Exception as e:
logger.error("Failed to enumerate monitors: %s", e)
_mark_primary(monitors)
_monitor_cache = monitors
_cache_time = time.time()
return monitors
def get_brightness(monitor_id: int) -> int | None:
"""Get brightness for a specific monitor."""
sbc = _load_sbc()
if sbc is None:
return None
try:
result = sbc.get_brightness(display=monitor_id)
if isinstance(result, list):
return result[0] if result else None
return result
except Exception as e:
logger.error("Failed to get brightness for monitor %d: %s", monitor_id, e)
return None
def set_brightness(monitor_id: int, value: int) -> bool:
"""Set brightness for a specific monitor (0-100)."""
sbc = _load_sbc()
if sbc is None:
return False
value = max(0, min(100, value))
try:
sbc.set_brightness(value, display=monitor_id)
# Invalidate cache
global _monitor_cache
_monitor_cache = None
return True
except Exception as e:
logger.error("Failed to set brightness for monitor %d: %s", monitor_id, e)
return False
def set_power(monitor_id: int, on: bool) -> bool:
"""Turn a monitor on or off via DDC/CI."""
mc = _load_monitorcontrol()
if mc is None:
logger.error("monitorcontrol not available for power control")
return False
try:
ddc_monitors = mc.get_monitors()
if monitor_id >= len(ddc_monitors):
logger.error("Monitor %d not found in DDC/CI monitors", monitor_id)
return False
with ddc_monitors[monitor_id] as monitor:
if on:
monitor.set_power_mode(mc.PowerMode.on)
else:
monitor.set_power_mode(mc.PowerMode.off_soft)
# Invalidate cache
global _monitor_cache
_monitor_cache = None
return True
except Exception as e:
logger.error("Failed to set power for monitor %d: %s", monitor_id, e)
return False

View File

@@ -28,61 +28,65 @@ class MetadataService:
if audio is None:
return {"error": "Unable to read audio file"}
metadata = {
"type": "audio",
"filename": file_path.name,
"path": str(file_path),
}
try:
metadata = {
"type": "audio",
"filename": file_path.name,
"path": str(file_path),
}
# Extract duration
if hasattr(audio.info, "length"):
metadata["duration"] = round(audio.info.length, 2)
# Extract duration
if hasattr(audio.info, "length"):
metadata["duration"] = round(audio.info.length, 2)
# Extract bitrate
if hasattr(audio.info, "bitrate"):
metadata["bitrate"] = audio.info.bitrate
# Extract bitrate
if hasattr(audio.info, "bitrate"):
metadata["bitrate"] = audio.info.bitrate
# Extract sample rate
if hasattr(audio.info, "sample_rate"):
metadata["sample_rate"] = audio.info.sample_rate
elif hasattr(audio.info, "samplerate"):
metadata["sample_rate"] = audio.info.samplerate
# Extract sample rate
if hasattr(audio.info, "sample_rate"):
metadata["sample_rate"] = audio.info.sample_rate
elif hasattr(audio.info, "samplerate"):
metadata["sample_rate"] = audio.info.samplerate
# Extract channels
if hasattr(audio.info, "channels"):
metadata["channels"] = audio.info.channels
# Extract channels
if hasattr(audio.info, "channels"):
metadata["channels"] = audio.info.channels
# Extract tags (use easy=True for consistent tag names)
if audio is not None and hasattr(audio, "tags") and audio.tags:
# Easy tags provide lists, so we take the first item
tags = audio.tags
# Extract tags (use easy=True for consistent tag names)
if audio is not None and hasattr(audio, "tags") and audio.tags:
# Easy tags provide lists, so we take the first item
tags = audio.tags
if "title" in tags:
metadata["title"] = tags["title"][0] if isinstance(tags["title"], list) else tags["title"]
if "title" in tags:
metadata["title"] = tags["title"][0] if isinstance(tags["title"], list) else tags["title"]
if "artist" in tags:
metadata["artist"] = tags["artist"][0] if isinstance(tags["artist"], list) else tags["artist"]
if "artist" in tags:
metadata["artist"] = tags["artist"][0] if isinstance(tags["artist"], list) else tags["artist"]
if "album" in tags:
metadata["album"] = tags["album"][0] if isinstance(tags["album"], list) else tags["album"]
if "album" in tags:
metadata["album"] = tags["album"][0] if isinstance(tags["album"], list) else tags["album"]
if "albumartist" in tags:
metadata["album_artist"] = tags["albumartist"][0] if isinstance(tags["albumartist"], list) else tags["albumartist"]
if "albumartist" in tags:
metadata["album_artist"] = tags["albumartist"][0] if isinstance(tags["albumartist"], list) else tags["albumartist"]
if "date" in tags:
metadata["date"] = tags["date"][0] if isinstance(tags["date"], list) else tags["date"]
if "date" in tags:
metadata["date"] = tags["date"][0] if isinstance(tags["date"], list) else tags["date"]
if "genre" in tags:
metadata["genre"] = tags["genre"][0] if isinstance(tags["genre"], list) else tags["genre"]
if "genre" in tags:
metadata["genre"] = tags["genre"][0] if isinstance(tags["genre"], list) else tags["genre"]
if "tracknumber" in tags:
metadata["track_number"] = tags["tracknumber"][0] if isinstance(tags["tracknumber"], list) else tags["tracknumber"]
if "tracknumber" in tags:
metadata["track_number"] = tags["tracknumber"][0] if isinstance(tags["tracknumber"], list) else tags["tracknumber"]
# If no title tag, use filename
if "title" not in metadata:
metadata["title"] = file_path.stem
# If no title tag, use filename
if "title" not in metadata:
metadata["title"] = file_path.stem
return metadata
return metadata
finally:
if hasattr(audio, 'close'):
audio.close()
except ImportError:
logger.error("mutagen library not installed, cannot extract metadata")
@@ -117,40 +121,44 @@ class MetadataService:
"title": file_path.stem,
}
metadata = {
"type": "video",
"filename": file_path.name,
"path": str(file_path),
}
try:
metadata = {
"type": "video",
"filename": file_path.name,
"path": str(file_path),
}
# Extract duration
if hasattr(video.info, "length"):
metadata["duration"] = round(video.info.length, 2)
# Extract duration
if hasattr(video.info, "length"):
metadata["duration"] = round(video.info.length, 2)
# Extract bitrate
if hasattr(video.info, "bitrate"):
metadata["bitrate"] = video.info.bitrate
# Extract bitrate
if hasattr(video.info, "bitrate"):
metadata["bitrate"] = video.info.bitrate
# Extract video-specific properties if available
if hasattr(video.info, "width"):
metadata["width"] = video.info.width
# Extract video-specific properties if available
if hasattr(video.info, "width"):
metadata["width"] = video.info.width
if hasattr(video.info, "height"):
metadata["height"] = video.info.height
if hasattr(video.info, "height"):
metadata["height"] = video.info.height
# Try to extract title from tags
if hasattr(video, "tags") and video.tags:
tags = video.tags
if hasattr(tags, "get"):
title = tags.get("title") or tags.get("TITLE") or tags.get("\xa9nam")
if title:
metadata["title"] = title[0] if isinstance(title, list) else str(title)
# Try to extract title from tags
if hasattr(video, "tags") and video.tags:
tags = video.tags
if hasattr(tags, "get"):
title = tags.get("title") or tags.get("TITLE") or tags.get("\xa9nam")
if title:
metadata["title"] = title[0] if isinstance(title, list) else str(title)
# If no title tag, use filename
if "title" not in metadata:
metadata["title"] = file_path.stem
# If no title tag, use filename
if "title" not in metadata:
metadata["title"] = file_path.stem
return metadata
return metadata
finally:
if hasattr(video, 'close'):
video.close()
except ImportError:
logger.error("mutagen library not installed, cannot extract metadata")

View File

@@ -51,6 +51,9 @@ class ThumbnailService:
absolute_path = str(file_path.resolve())
return hashlib.sha256(absolute_path.encode()).hexdigest()
# Sentinel value indicating "no thumbnail available" is cached
NO_THUMBNAIL = b""
@staticmethod
def get_cached_thumbnail(file_path: Path, size: str) -> Optional[bytes]:
"""Get cached thumbnail if valid.
@@ -60,11 +63,24 @@ class ThumbnailService:
size: Thumbnail size ("small" or "medium").
Returns:
Thumbnail bytes if cached and valid, None otherwise.
Thumbnail bytes if cached, empty bytes if cached as "no thumbnail",
None if not cached.
"""
cache_dir = ThumbnailService.get_cache_dir()
cache_key = ThumbnailService.get_cache_key(file_path)
cache_path = cache_dir / cache_key / f"{size}.jpg"
cache_folder = cache_dir / cache_key
cache_path = cache_folder / f"{size}.jpg"
no_thumb_path = cache_folder / ".no_thumbnail"
# Check negative cache first (no thumbnail available)
if no_thumb_path.exists():
try:
file_mtime = file_path.stat().st_mtime
cache_mtime = no_thumb_path.stat().st_mtime
if file_mtime <= cache_mtime:
return ThumbnailService.NO_THUMBNAIL
except (OSError, PermissionError):
pass
if not cache_path.exists():
return None
@@ -109,6 +125,20 @@ class ThumbnailService:
except (OSError, PermissionError) as e:
logger.error(f"Error caching thumbnail: {e}")
@staticmethod
def cache_no_thumbnail(file_path: Path) -> None:
"""Cache that a file has no thumbnail (negative cache)."""
cache_dir = ThumbnailService.get_cache_dir()
cache_key = ThumbnailService.get_cache_key(file_path)
cache_folder = cache_dir / cache_key
cache_folder.mkdir(parents=True, exist_ok=True)
try:
(cache_folder / ".no_thumbnail").touch()
logger.debug(f"Cached no-thumbnail for {file_path.name}")
except (OSError, PermissionError) as e:
logger.error(f"Error caching no-thumbnail marker: {e}")
@staticmethod
def generate_audio_thumbnail(file_path: Path, size: str) -> Optional[bytes]:
"""Generate thumbnail from audio file (extract album art).
@@ -277,10 +307,10 @@ class ThumbnailService:
if size not in THUMBNAIL_SIZES:
size = "medium"
# Check cache first
# Check cache first (returns bytes, empty bytes for negative cache, or None)
cached = ThumbnailService.get_cached_thumbnail(file_path, size)
if cached:
return cached
if cached is not None:
return cached if cached else None
# Generate thumbnail based on file type
suffix = file_path.suffix.lower()
@@ -299,9 +329,11 @@ class ThumbnailService:
# Video files - already async
thumbnail_data = await ThumbnailService.generate_video_thumbnail(file_path, size)
# Cache if generated successfully
# Cache result (positive or negative)
if thumbnail_data:
ThumbnailService.cache_thumbnail(file_path, size, thumbnail_data)
else:
ThumbnailService.cache_no_thumbnail(file_path)
return thumbnail_data

View File

@@ -1,6 +1,7 @@
"""WebSocket connection manager and status broadcaster."""
import asyncio
import json
import logging
import time
from typing import Any, Callable, Coroutine
@@ -23,6 +24,10 @@ class ConnectionManager:
self._position_broadcast_interval: float = 5.0 # Send position updates every 5s during playback
self._last_broadcast_time: float = 0.0
self._running: bool = False
# Audio visualizer
self._visualizer_subscribers: set[WebSocket] = set()
self._audio_task: asyncio.Task | None = None
self._audio_analyzer = None
async def connect(self, websocket: WebSocket) -> None:
"""Accept a new WebSocket connection."""
@@ -41,32 +46,42 @@ class ConnectionManager:
logger.debug("Failed to send initial status: %s", e)
async def disconnect(self, websocket: WebSocket) -> None:
"""Remove a WebSocket connection."""
"""Remove a WebSocket connection. Stops audio capture if last visualizer subscriber."""
should_stop = False
async with self._lock:
self._active_connections.discard(websocket)
was_subscriber = websocket in self._visualizer_subscribers
self._visualizer_subscribers.discard(websocket)
if was_subscriber and len(self._visualizer_subscribers) == 0:
should_stop = True
if should_stop:
await self._maybe_stop_capture()
logger.info(
"WebSocket client disconnected. Total: %d", len(self._active_connections)
)
async def broadcast(self, message: dict[str, Any]) -> None:
"""Broadcast a message to all connected clients."""
"""Broadcast a message to all connected clients concurrently."""
async with self._lock:
connections = list(self._active_connections)
if not connections:
return
disconnected = []
for websocket in connections:
async def _send(ws: WebSocket) -> WebSocket | None:
try:
await websocket.send_json(message)
await ws.send_json(message)
return None
except Exception as e:
logger.debug("Failed to send to client: %s", e)
disconnected.append(websocket)
return ws
results = await asyncio.gather(*(_send(ws) for ws in connections))
# Clean up disconnected clients
for ws in disconnected:
await self.disconnect(ws)
for ws in results:
if ws is not None:
await self.disconnect(ws)
async def broadcast_scripts_changed(self) -> None:
"""Notify all connected clients that scripts have changed."""
@@ -74,6 +89,114 @@ class ConnectionManager:
await self.broadcast(message)
logger.info("Broadcast sent: scripts_changed")
async def broadcast_links_changed(self) -> None:
"""Notify all connected clients that links have changed."""
message = {"type": "links_changed", "data": {}}
await self.broadcast(message)
logger.info("Broadcast sent: links_changed")
async def subscribe_visualizer(self, websocket: WebSocket) -> None:
"""Subscribe a client to audio visualizer data. Starts capture on first subscriber."""
should_start = False
async with self._lock:
self._visualizer_subscribers.add(websocket)
if len(self._visualizer_subscribers) == 1 and self._audio_analyzer:
should_start = True
if should_start:
await self._maybe_start_capture()
logger.debug("Visualizer subscriber added. Total: %d", len(self._visualizer_subscribers))
async def unsubscribe_visualizer(self, websocket: WebSocket) -> None:
"""Unsubscribe a client from audio visualizer data. Stops capture on last subscriber."""
should_stop = False
async with self._lock:
self._visualizer_subscribers.discard(websocket)
if len(self._visualizer_subscribers) == 0:
should_stop = True
if should_stop:
await self._maybe_stop_capture()
logger.debug("Visualizer subscriber removed. Total: %d", len(self._visualizer_subscribers))
async def _maybe_start_capture(self) -> None:
"""Start audio capture if not already running (called on first subscriber)."""
if self._audio_analyzer and not self._audio_analyzer.running:
loop = asyncio.get_event_loop()
started = await loop.run_in_executor(None, self._audio_analyzer.start)
if started:
logger.info("Audio capture started (first subscriber)")
else:
logger.warning("Audio capture failed to start")
async def _maybe_stop_capture(self) -> None:
"""Stop audio capture if running (called when last subscriber leaves)."""
if self._audio_analyzer and self._audio_analyzer.running:
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, self._audio_analyzer.stop)
logger.info("Audio capture stopped (no subscribers)")
async def start_audio_monitor(self, analyzer) -> None:
"""Register the audio analyzer. Capture starts on-demand when clients subscribe."""
self._audio_analyzer = analyzer
if analyzer and analyzer.available:
self._audio_task = asyncio.create_task(self._audio_broadcast_loop())
logger.info("Audio visualizer broadcast loop started (capture on-demand)")
async def stop_audio_monitor(self) -> None:
"""Stop audio frequency broadcasting."""
if self._audio_task:
self._audio_task.cancel()
try:
await self._audio_task
except asyncio.CancelledError:
pass
self._audio_task = None
async def _audio_broadcast_loop(self) -> None:
"""Background loop: read frequency data from analyzer and broadcast to subscribers."""
from ..config import settings
interval = 1.0 / settings.visualizer_fps
_last_data = None
while True:
try:
async with self._lock:
subscribers = list(self._visualizer_subscribers)
if not subscribers or not self._audio_analyzer or not self._audio_analyzer.running:
await asyncio.sleep(interval)
continue
data = self._audio_analyzer.get_frequency_data()
if data is None or data is _last_data:
await asyncio.sleep(interval)
continue
_last_data = data
# Pre-serialize once for all subscribers (avoids per-client JSON encoding)
text = json.dumps({"type": "audio_data", "data": data}, separators=(',', ':'))
async def _send(ws: WebSocket) -> WebSocket | None:
try:
await ws.send_text(text)
return None
except Exception:
return ws
results = await asyncio.gather(*(_send(ws) for ws in subscribers))
failed = [ws for ws in results if ws is not None]
for ws in failed:
await self.disconnect(ws)
await asyncio.sleep(interval)
except asyncio.CancelledError:
break
except Exception as e:
logger.error("Error in audio broadcast: %s", e)
await asyncio.sleep(interval)
def status_changed(
self, old: dict[str, Any] | None, new: dict[str, Any]
) -> bool:
@@ -156,26 +279,25 @@ class ConnectionManager:
async with self._lock:
has_clients = len(self._active_connections) > 0
if has_clients:
status = await get_status_func()
status_dict = status.model_dump()
if not has_clients:
await asyncio.sleep(2.0) # Sleep longer when no clients connected
continue
# Only broadcast on actual state changes
# Let HA handle position interpolation during playback
if self.status_changed(self._last_status, status_dict):
self._last_status = status_dict
self._last_broadcast_time = time.time()
await self.broadcast(
{"type": "status_update", "data": status_dict}
)
logger.debug("Broadcast sent: status change")
else:
# Update cached status even without broadcast
self._last_status = status_dict
status = await get_status_func()
status_dict = status.model_dump()
# Only broadcast on actual state changes
# Let HA handle position interpolation during playback
if self.status_changed(self._last_status, status_dict):
self._last_status = status_dict
self._last_broadcast_time = time.time()
await self.broadcast(
{"type": "status_update", "data": status_dict}
)
logger.debug("Broadcast sent: status change")
else:
# Still update cache for when clients connect
status = await get_status_func()
self._last_status = status.model_dump()
# Update cached status even without broadcast
self._last_status = status_dict
await asyncio.sleep(self._poll_interval)

View File

@@ -2,6 +2,8 @@
import asyncio
import logging
import threading
import time as _time
from concurrent.futures import ThreadPoolExecutor
from typing import Optional, Any
@@ -16,8 +18,10 @@ _executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="winrt")
# Global storage for current album art (as bytes)
_current_album_art_bytes: bytes | None = None
# Lock protecting _position_cache and _track_skip_pending from concurrent access
_position_lock = threading.Lock()
# Global storage for position tracking
import time as _time
_position_cache = {
"track_id": "",
"base_position": 0.0,
@@ -224,124 +228,125 @@ def _sync_get_media_status() -> dict[str, Any]:
is_playing = result["state"] == "playing"
current_title = result.get('title', '')
# Check if track skip is pending and title changed
skip_just_completed = False
if _track_skip_pending["active"]:
if current_title and current_title != _track_skip_pending["old_title"]:
# Title changed - clear the skip flag and start grace period
_track_skip_pending["active"] = False
_track_skip_pending["old_title"] = ""
_track_skip_pending["grace_until"] = current_time + 300.0 # Long grace period
_track_skip_pending["stale_pos"] = -999 # Reset stale position tracking
skip_just_completed = True
# Reset position cache for new track
new_track_id = f"{current_title}:{result.get('artist', '')}:{result.get('duration', 0)}"
_position_cache["track_id"] = new_track_id
with _position_lock:
# Check if track skip is pending and title changed
skip_just_completed = False
if _track_skip_pending["active"]:
if current_title and current_title != _track_skip_pending["old_title"]:
# Title changed - clear the skip flag and start grace period
_track_skip_pending["active"] = False
_track_skip_pending["old_title"] = ""
_track_skip_pending["grace_until"] = current_time + 300.0 # Long grace period
_track_skip_pending["stale_pos"] = -999 # Reset stale position tracking
skip_just_completed = True
# Reset position cache for new track
new_track_id = f"{current_title}:{result.get('artist', '')}:{result.get('duration', 0)}"
_position_cache["track_id"] = new_track_id
_position_cache["base_position"] = 0.0
_position_cache["base_time"] = current_time
_position_cache["last_smtc_pos"] = -999 # Force fresh start
_position_cache["is_playing"] = is_playing
logger.debug(f"Track skip complete, new title: {current_title}, grace until: {_track_skip_pending['grace_until']}")
elif current_time - _track_skip_pending["skip_time"] > 5.0:
# Timeout after 5 seconds
_track_skip_pending["active"] = False
logger.debug("Track skip timeout")
# Check if we're in grace period (after skip, ignore high SMTC positions)
in_grace_period = current_time < _track_skip_pending.get("grace_until", 0)
# If track skip is pending or just completed, use cached/reset position
if _track_skip_pending["active"]:
pos = 0.0
_position_cache["base_position"] = 0.0
_position_cache["base_time"] = current_time
_position_cache["last_smtc_pos"] = -999 # Force fresh start
_position_cache["is_playing"] = is_playing
logger.debug(f"Track skip complete, new title: {current_title}, grace until: {_track_skip_pending['grace_until']}")
elif current_time - _track_skip_pending["skip_time"] > 5.0:
# Timeout after 5 seconds
_track_skip_pending["active"] = False
logger.debug("Track skip timeout")
elif skip_just_completed:
# Just completed skip - interpolate from 0
if is_playing:
elapsed = current_time - _position_cache["base_time"]
pos = elapsed
else:
pos = 0.0
elif in_grace_period:
# Grace period after track skip
# SMTC position is stale (from old track) and won't update until seek/pause
# We interpolate from 0 and only trust SMTC when it changes or reports low value
# Check if we're in grace period (after skip, ignore high SMTC positions)
in_grace_period = current_time < _track_skip_pending.get("grace_until", 0)
# Calculate interpolated position from start of new track
if is_playing:
elapsed = current_time - _position_cache.get("base_time", current_time)
interpolated_pos = _position_cache.get("base_position", 0.0) + elapsed
else:
interpolated_pos = _position_cache.get("base_position", 0.0)
# If track skip is pending or just completed, use cached/reset position
if _track_skip_pending["active"]:
pos = 0.0
_position_cache["base_position"] = 0.0
_position_cache["base_time"] = current_time
_position_cache["is_playing"] = is_playing
elif skip_just_completed:
# Just completed skip - interpolate from 0
if is_playing:
elapsed = current_time - _position_cache["base_time"]
pos = elapsed
# Get the stale position we've been tracking
stale_pos = _track_skip_pending.get("stale_pos", -999)
# Detect if SMTC position changed significantly from the stale value (user seeked)
smtc_changed = stale_pos >= 0 and abs(smtc_pos - stale_pos) > 3.0
# Trust SMTC if:
# 1. It reports a low position (indicating new track started)
# 2. It changed from the stale value (user seeked)
if smtc_pos < 10.0 or smtc_changed:
# SMTC is now trustworthy
_position_cache["base_position"] = smtc_pos
_position_cache["base_time"] = current_time
_position_cache["last_smtc_pos"] = smtc_pos
_position_cache["is_playing"] = is_playing
pos = smtc_pos
_track_skip_pending["grace_until"] = 0
_track_skip_pending["stale_pos"] = -999
logger.debug(f"Grace period: accepting SMTC pos {smtc_pos} (low={smtc_pos < 10}, changed={smtc_changed})")
else:
# SMTC is stale - keep interpolating
pos = interpolated_pos
# Record the stale position for change detection
if stale_pos < 0:
_track_skip_pending["stale_pos"] = smtc_pos
# Keep grace period active indefinitely while SMTC is stale
_track_skip_pending["grace_until"] = current_time + 300.0
logger.debug(f"Grace period: SMTC stale ({smtc_pos}), using interpolated {interpolated_pos}")
else:
pos = 0.0
elif in_grace_period:
# Grace period after track skip
# SMTC position is stale (from old track) and won't update until seek/pause
# We interpolate from 0 and only trust SMTC when it changes or reports low value
# Normal position tracking
# Create track ID from title + artist + duration
track_id = f"{current_title}:{result.get('artist', '')}:{result.get('duration', 0)}"
# Calculate interpolated position from start of new track
if is_playing:
elapsed = current_time - _position_cache.get("base_time", current_time)
interpolated_pos = _position_cache.get("base_position", 0.0) + elapsed
else:
interpolated_pos = _position_cache.get("base_position", 0.0)
# Detect if SMTC position changed (new track, seek, or state change)
smtc_pos_changed = abs(smtc_pos - _position_cache.get("last_smtc_pos", -999)) > 0.5
track_changed = track_id != _position_cache.get("track_id", "")
# Get the stale position we've been tracking
stale_pos = _track_skip_pending.get("stale_pos", -999)
if smtc_pos_changed or track_changed:
# SMTC updated - store new baseline
_position_cache["track_id"] = track_id
_position_cache["last_smtc_pos"] = smtc_pos
_position_cache["base_position"] = smtc_pos
_position_cache["base_time"] = current_time
_position_cache["is_playing"] = is_playing
pos = smtc_pos
elif is_playing:
# Interpolate position based on elapsed time
elapsed = current_time - _position_cache.get("base_time", current_time)
pos = _position_cache.get("base_position", smtc_pos) + elapsed
else:
# Paused - use base position
pos = _position_cache.get("base_position", smtc_pos)
# Detect if SMTC position changed significantly from the stale value (user seeked)
smtc_changed = stale_pos >= 0 and abs(smtc_pos - stale_pos) > 3.0
# Update playing state
if _position_cache.get("is_playing") != is_playing:
_position_cache["base_position"] = pos if is_playing else _position_cache.get("base_position", smtc_pos)
_position_cache["base_time"] = current_time
_position_cache["is_playing"] = is_playing
# Trust SMTC if:
# 1. It reports a low position (indicating new track started)
# 2. It changed from the stale value (user seeked)
if smtc_pos < 10.0 or smtc_changed:
# SMTC is now trustworthy
_position_cache["base_position"] = smtc_pos
_position_cache["base_time"] = current_time
_position_cache["last_smtc_pos"] = smtc_pos
_position_cache["is_playing"] = is_playing
pos = smtc_pos
_track_skip_pending["grace_until"] = 0
_track_skip_pending["stale_pos"] = -999
logger.debug(f"Grace period: accepting SMTC pos {smtc_pos} (low={smtc_pos < 10}, changed={smtc_changed})")
else:
# SMTC is stale - keep interpolating
pos = interpolated_pos
# Record the stale position for change detection
if stale_pos < 0:
_track_skip_pending["stale_pos"] = smtc_pos
# Keep grace period active indefinitely while SMTC is stale
_track_skip_pending["grace_until"] = current_time + 300.0
logger.debug(f"Grace period: SMTC stale ({smtc_pos}), using interpolated {interpolated_pos}")
else:
# Normal position tracking
# Create track ID from title + artist + duration
track_id = f"{current_title}:{result.get('artist', '')}:{result.get('duration', 0)}"
# Detect if SMTC position changed (new track, seek, or state change)
smtc_pos_changed = abs(smtc_pos - _position_cache.get("last_smtc_pos", -999)) > 0.5
track_changed = track_id != _position_cache.get("track_id", "")
if smtc_pos_changed or track_changed:
# SMTC updated - store new baseline
_position_cache["track_id"] = track_id
_position_cache["last_smtc_pos"] = smtc_pos
_position_cache["base_position"] = smtc_pos
_position_cache["base_time"] = current_time
_position_cache["is_playing"] = is_playing
pos = smtc_pos
elif is_playing:
# Interpolate position based on elapsed time
elapsed = current_time - _position_cache.get("base_time", current_time)
pos = _position_cache.get("base_position", smtc_pos) + elapsed
else:
# Paused - use base position
pos = _position_cache.get("base_position", smtc_pos)
# Update playing state
if _position_cache.get("is_playing") != is_playing:
_position_cache["base_position"] = pos if is_playing else _position_cache.get("base_position", smtc_pos)
_position_cache["base_time"] = current_time
_position_cache["is_playing"] = is_playing
# Sanity check: position should be non-negative and <= duration
if pos >= 0:
if result["duration"] and pos <= result["duration"]:
result["position"] = pos
elif result["duration"] and pos > result["duration"]:
result["position"] = result["duration"]
elif not result["duration"]:
result["position"] = pos
# Sanity check: position should be non-negative and <= duration
if pos >= 0:
if result["duration"] and pos <= result["duration"]:
result["position"] = pos
elif result["duration"] and pos > result["duration"]:
result["position"] = result["duration"]
elif not result["duration"]:
result["position"] = pos
logger.debug(f"Timeline: duration={result['duration']}, position={result['position']}")
except Exception as e:
@@ -483,6 +488,11 @@ def _sync_seek(position: float) -> bool:
return False
def shutdown_executor() -> None:
"""Shut down the WinRT thread pool executor."""
_executor.shutdown(wait=False)
class WindowsMediaController(MediaController):
"""Media controller for Windows using WinRT and pycaw."""
@@ -602,10 +612,10 @@ class WindowsMediaController(MediaController):
result = await self._run_command("next")
if result:
# Set flag to force position to 0 until title changes
_track_skip_pending["active"] = True
_track_skip_pending["old_title"] = old_title
_track_skip_pending["skip_time"] = _time.time()
with _position_lock:
_track_skip_pending["active"] = True
_track_skip_pending["old_title"] = old_title
_track_skip_pending["skip_time"] = _time.time()
logger.debug(f"Track skip initiated, old title: {old_title}")
return result
@@ -620,10 +630,10 @@ class WindowsMediaController(MediaController):
result = await self._run_command("previous")
if result:
# Set flag to force position to 0 until title changes
_track_skip_pending["active"] = True
_track_skip_pending["old_title"] = old_title
_track_skip_pending["skip_time"] = _time.time()
with _position_lock:
_track_skip_pending["active"] = True
_track_skip_pending["old_title"] = old_title
_track_skip_pending["skip_time"] = _time.time()
logger.debug(f"Track skip initiated, old title: {old_title}")
return result

File diff suppressed because it is too large Load Diff

View File

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

After

Width:  |  Height:  |  Size: 421 B

View File

@@ -2,15 +2,19 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Media Server</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Cdefs%3E%3ClinearGradient id='grad' x1='0%25' y1='0%25' x2='100%25' y2='100%25'%3E%3Cstop offset='0%25' style='stop-color:%231db954;stop-opacity:1' /%3E%3Cstop offset='100%25' style='stop-color:%231ed760;stop-opacity:1' /%3E%3C/linearGradient%3E%3C/defs%3E%3Ccircle cx='50' cy='50' r='45' fill='url(%23grad)'/%3E%3Cpath fill='white' d='M35 25 L35 75 L75 50 Z'/%3E%3C/svg%3E">
<meta name="description" content="Remote media player control and file browser">
<meta name="theme-color" content="#121212">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Media Server">
<link rel="manifest" href="/static/manifest.json">
<link rel="icon" type="image/svg+xml" href="/static/icons/icon.svg">
<link rel="apple-touch-icon" href="/static/icons/icon.svg">
<link rel="stylesheet" href="/static/css/styles.css">
</head>
<body class="loading-translations">
<!-- Clear Token Button -->
<button class="clear-token-btn" onclick="clearToken()" data-i18n-title="auth.logout.title" data-i18n="auth.logout" title="Clear saved token">Logout</button>
<!-- Mini Player (sticky) -->
<div class="mini-player hidden" id="mini-player">
<div class="mini-player-info">
@@ -21,18 +25,24 @@
</div>
</div>
<div class="mini-controls">
<button class="mini-control-btn mini-nav-btn" onclick="previousTrack()" data-i18n-title="player.previous" title="Previous">
<svg viewBox="0 0 24 24"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
</button>
<button class="mini-control-btn" onclick="togglePlayPause()" id="mini-btn-play-pause" title="Play/Pause">
<svg viewBox="0 0 24 24" id="mini-play-pause-icon">
<path d="M8 5v14l11-7z"/>
</svg>
</button>
<button class="mini-control-btn mini-nav-btn" onclick="nextTrack()" data-i18n-title="player.next" title="Next">
<svg viewBox="0 0 24 24"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
</button>
</div>
<div class="mini-progress-container">
<div class="mini-time-display">
<span id="mini-current-time">0:00</span>
<span id="mini-total-time">0:00</span>
</div>
<div class="mini-progress-bar" id="mini-progress-bar">
<div class="mini-progress-bar" id="mini-progress-bar" role="slider" aria-label="Playback position" aria-valuemin="0" aria-valuemax="0" aria-valuenow="0">
<div class="mini-progress-fill" id="mini-progress-fill"></div>
</div>
</div>
@@ -42,11 +52,14 @@
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
</svg>
</button>
<input type="range" id="mini-volume-slider" class="mini-volume-slider" min="0" max="100" value="50">
<input type="range" id="mini-volume-slider" class="mini-volume-slider" min="0" max="100" value="50" aria-label="Volume">
<div class="mini-volume-display" id="mini-volume-display">50%</div>
</div>
</div>
<!-- Dynamic Background -->
<canvas id="bg-shader-canvas" class="bg-shader-canvas"></canvas>
<!-- Auth Modal -->
<div id="auth-overlay" class="hidden">
<div class="auth-modal">
@@ -64,9 +77,22 @@
<div class="container">
<header>
<h1 data-i18n="app.title">Media Server</h1>
<div style="display: flex; align-items: center; gap: 1rem;">
<button class="theme-toggle" onclick="toggleTheme()" data-i18n-title="player.theme" title="Toggle theme" id="theme-toggle">
<div style="display: flex; align-items: center; gap: 0.5rem;">
<span class="status-dot" id="status-dot" aria-live="polite"></span>
<span class="version-label" id="version-label"></span>
</div>
<div class="header-toolbar">
<div id="headerLinks" class="header-links"></div>
<div class="accent-picker">
<button class="header-btn" onclick="toggleAccentPicker()" title="Accent color" aria-label="Accent color">
<span class="accent-dot" id="accentDot"></span>
</button>
<div class="accent-picker-dropdown" id="accentDropdown"></div>
</div>
<button class="header-btn" onclick="toggleDynamicBackground()" data-i18n-title="player.background" title="Dynamic background" aria-label="Dynamic background" id="bgToggle">
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M12 3v10.55A4 4 0 1 0 14 17V7h4V3h-6z"/></svg>
</button>
<button class="header-btn" onclick="toggleTheme()" data-i18n-title="player.theme" title="Toggle theme" aria-label="Toggle theme" id="theme-toggle">
<svg id="theme-icon-sun" viewBox="0 0 24 24" style="display: none;">
<path d="M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zM2 13h2c.55 0 1-.45 1-1s-.45-1-1-1H2c-.55 0-1 .45-1 1s.45 1 1 1zm18 0h2c.55 0 1-.45 1-1s-.45-1-1-1h-2c-.55 0-1 .45-1 1s.45 1 1 1zM11 2v2c0 .55.45 1 1 1s1-.45 1-1V2c0-.55-.45-1-1-1s-1 .45-1 1zm0 18v2c0 .55.45 1 1 1s1-.45 1-1v-2c0-.55-.45-1-1-1s-1 .45-1 1zM5.99 4.58c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0s.39-1.03 0-1.41L5.99 4.58zm12.37 12.37c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0 .39-.39.39-1.03 0-1.41l-1.06-1.06zm1.06-10.96c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06zM7.05 18.36c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06z"/>
</svg>
@@ -74,160 +100,322 @@
<path d="M9 2c-1.05 0-2.05.16-3 .46 4.06 1.27 7 5.06 7 9.54 0 4.48-2.94 8.27-7 9.54.95.3 1.95.46 3 .46 5.52 0 10-4.48 10-10S14.52 2 9 2z"/>
</svg>
</button>
<select id="locale-select" onchange="changeLocale()" title="Change language">
<option value="en">English</option>
<option value="ru">Русский</option>
<select id="locale-select" class="header-locale" onchange="changeLocale()" title="Change language">
<option value="en">EN</option>
<option value="ru">RU</option>
</select>
<div class="status-indicator">
<span class="status-dot" id="status-dot"></span>
<span id="status-text" data-i18n="player.status.disconnected">Disconnected</span>
</div>
<span class="header-toolbar-sep"></span>
<button class="header-btn header-btn-logout" onclick="clearToken()" data-i18n-title="auth.logout.title" title="Clear saved token" aria-label="Logout">
<svg viewBox="0 0 24 24"><path d="M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5zM4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4V5z"/></svg>
</button>
</div>
</header>
<div class="player-container">
<div class="album-art-container">
<img id="album-art" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Crect fill='%23282828' width='300' height='300'/%3E%3Cpath fill='%236a6a6a' d='M150 80c-38.66 0-70 31.34-70 70s31.34 70 70 70 70-31.34 70-70-31.34-70-70-70zm0 20c27.614 0 50 22.386 50 50s-22.386 50-50 50-50-22.386-50-50 22.386-50 50-50zm0 30a20 20 0 100 40 20 20 0 000-40z'/%3E%3C/svg%3E" alt="Album Art">
</div>
<!-- Connection Banner -->
<div class="connection-banner hidden" id="connectionBanner">
<span id="connectionBannerText"></span>
<button class="connection-banner-btn" id="connectionBannerBtn" onclick="manualReconnect()" style="display: none;" data-i18n="connection.reconnect">Reconnect</button>
</div>
<div class="track-info">
<div id="track-title" data-i18n="player.no_media">No media playing</div>
<div id="artist"></div>
<div id="album"></div>
<div class="playback-state">
<svg class="state-icon" id="state-icon" viewBox="0 0 24 24">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"/>
</svg>
<span id="playback-state" data-i18n="state.idle">Idle</span>
<!-- Tab Bar -->
<div class="tab-bar" id="tabBar" role="tablist">
<div class="tab-indicator" id="tabIndicator"></div>
<button class="tab-btn active" data-tab="player" onclick="switchTab('player')" role="tab" aria-selected="true" aria-controls="panel-player" tabindex="0">
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/></svg>
<span data-i18n="tab.player">Player</span>
</button>
<button class="tab-btn" data-tab="display" onclick="switchTab('display')" role="tab" aria-selected="false" aria-controls="panel-display" tabindex="-1">
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M20 3H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h6v2H8v2h8v-2h-2v-2h6c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 12H4V5h16v10z"/></svg>
<span data-i18n="tab.display">Display</span>
</button>
<button class="tab-btn" data-tab="browser" onclick="switchTab('browser')" role="tab" aria-selected="false" aria-controls="panel-browser" tabindex="-1">
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>
<span data-i18n="tab.browser">Browser</span>
</button>
<button class="tab-btn" data-tab="quick-actions" onclick="switchTab('quick-actions')" role="tab" aria-selected="false" aria-controls="panel-quick-actions" tabindex="-1">
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M7 2v11h3v9l7-12h-4l4-8z"/></svg>
<span data-i18n="tab.quick_access">Quick Access</span>
</button>
<button class="tab-btn" data-tab="settings" onclick="switchTab('settings')" role="tab" aria-selected="false" aria-controls="panel-settings" tabindex="-1">
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.488.488 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>
<span data-i18n="tab.settings">Settings</span>
</button>
</div>
<div class="player-container" data-tab-content="player" role="tabpanel" id="panel-player">
<div class="player-layout">
<div class="album-art-container">
<img id="album-art-glow" class="album-art-glow" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Crect fill='%23282828' width='300' height='300'/%3E%3C/svg%3E" alt="" aria-hidden="true">
<img id="album-art" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Crect fill='%23282828' width='300' height='300'/%3E%3Cpath fill='%236a6a6a' d='M150 80c-38.66 0-70 31.34-70 70s31.34 70 70 70 70-31.34 70-70-31.34-70-70-70zm0 20c27.614 0 50 22.386 50 50s-22.386 50-50 50-50-22.386-50-50 22.386-50 50-50zm0 30a20 20 0 100 40 20 20 0 000-40z'/%3E%3C/svg%3E" alt="Album Art">
<canvas id="spectrogram-canvas" class="spectrogram-canvas" width="300" height="64"></canvas>
</div>
</div>
<div class="progress-container">
<div class="time-display">
<span id="current-time">0:00</span>
<span id="total-time">0:00</span>
<div class="player-details">
<div class="track-info">
<div id="track-title" data-i18n="player.no_media">No media playing</div>
<div id="artist"></div>
<div id="album"></div>
<div class="playback-state">
<svg class="state-icon" id="state-icon" viewBox="0 0 24 24">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"/>
</svg>
<span id="playback-state" data-i18n="state.idle">Idle</span>
</div>
</div>
<div class="progress-container">
<div class="time-display">
<span id="current-time">0:00</span>
<span id="total-time">0:00</span>
</div>
<div class="progress-bar" id="progress-bar" data-duration="0" role="slider" aria-label="Playback position" aria-valuemin="0" aria-valuemax="0" aria-valuenow="0">
<div class="progress-fill" id="progress-fill"></div>
</div>
</div>
<div class="controls">
<button onclick="previousTrack()" data-i18n-title="player.previous" title="Previous" id="btn-previous">
<svg viewBox="0 0 24 24">
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/>
</svg>
</button>
<button class="primary" onclick="togglePlayPause()" data-i18n-title="player.play" title="Play/Pause" id="btn-play-pause">
<svg viewBox="0 0 24 24" id="play-pause-icon">
<path d="M8 5v14l11-7z"/>
</svg>
</button>
<button onclick="nextTrack()" data-i18n-title="player.next" title="Next" id="btn-next">
<svg viewBox="0 0 24 24">
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/>
</svg>
</button>
</div>
<div class="volume-container">
<button class="mute-btn" onclick="toggleMute()" data-i18n-title="player.mute" title="Mute" id="btn-mute">
<svg viewBox="0 0 24 24" id="mute-icon">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
</svg>
</button>
<input type="range" id="volume-slider" min="0" max="100" value="50" aria-label="Volume">
<div class="volume-display" id="volume-display">50%</div>
</div>
<div class="source-info">
<span class="source-label"><span class="source-icon" id="sourceIcon"></span><span id="source" data-i18n="player.unknown_source">Unknown</span></span>
<div class="player-toggles">
<button class="vinyl-toggle-btn" onclick="toggleVinylMode()" id="vinylToggle" data-i18n-title="player.vinyl" title="Vinyl mode">
<svg viewBox="0 0 24 24" width="16" height="16"><circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="12" cy="12" r="3" fill="currentColor"/><circle cx="12" cy="12" r="6.5" fill="none" stroke="currentColor" stroke-width="0.5" opacity="0.5"/></svg>
</button>
<button class="vinyl-toggle-btn" onclick="toggleVisualizer()" id="visualizerToggle" data-i18n-title="player.visualizer" title="Audio visualizer" style="display:none">
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M3 18h2v-8H3v8zm4 0h2V6H7v12zm4 0h2V2h-2v16zm4 0h2v-6h-2v6zm4 0h2V9h-2v9z"/></svg>
</button>
</div>
</div>
</div>
<div class="progress-bar" id="progress-bar" data-duration="0">
<div class="progress-fill" id="progress-fill"></div>
</div>
</div>
<div class="controls">
<button onclick="previousTrack()" data-i18n-title="player.previous" title="Previous" id="btn-previous">
<svg viewBox="0 0 24 24">
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/>
</svg>
</button>
<button class="primary" onclick="togglePlayPause()" data-i18n-title="player.play" title="Play/Pause" id="btn-play-pause">
<svg viewBox="0 0 24 24" id="play-pause-icon">
<path d="M8 5v14l11-7z"/>
</svg>
</button>
<button onclick="nextTrack()" data-i18n-title="player.next" title="Next" id="btn-next">
<svg viewBox="0 0 24 24">
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/>
</svg>
</button>
</div>
<div class="volume-container">
<button class="mute-btn" onclick="toggleMute()" data-i18n-title="player.mute" title="Mute" id="btn-mute">
<svg viewBox="0 0 24 24" id="mute-icon">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
</svg>
</button>
<input type="range" id="volume-slider" min="0" max="100" value="50">
<div class="volume-display" id="volume-display">50%</div>
</div>
<div class="source-info">
<span data-i18n="player.source">Source:</span> <span id="source" data-i18n="player.unknown_source">Unknown</span>
</div>
</div>
<!-- Media Browser Section -->
<div class="browser-container">
<h2 data-i18n="browser.title">Media Browser</h2>
<!-- Folder Selection -->
<div class="browser-controls">
<select id="folderSelect" onchange="onFolderSelected()" data-i18n-empty="browser.select_folder">
<option value="" data-i18n="browser.select_folder_option">Select a folder...</option>
</select>
</div>
<div class="browser-container" data-tab-content="browser" role="tabpanel" id="panel-browser">
<!-- Breadcrumb Navigation -->
<div class="breadcrumb" id="breadcrumb"></div>
<!-- Browser Toolbar -->
<div class="browser-toolbar" id="browserToolbar">
<div class="browser-toolbar-left">
<div class="view-toggle">
<button class="view-toggle-btn active" id="viewGridBtn" onclick="setViewMode('grid')" data-i18n-title="browser.view_grid" title="Grid view">
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M3 3h8v8H3V3zm0 10h8v8H3v-8zm10-10h8v8h-8V3zm0 10h8v8h-8v-8z"/></svg>
</button>
<button class="view-toggle-btn" id="viewCompactBtn" onclick="setViewMode('compact')" data-i18n-title="browser.view_compact" title="Compact view">
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M2 2h5v5H2V2zm0 8h5v5H2v-5zm0 8h5v5H2v-5zm7-16h5v5H9V2zm0 8h5v5H9v-5zm0 8h5v5H9v-5zm7-16h5v5h-5V2zm0 8h5v5h-5v-5zm0 8h5v5h-5v-5z"/></svg>
</button>
<button class="view-toggle-btn" id="viewListBtn" onclick="setViewMode('list')" data-i18n-title="browser.view_list" title="List view">
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M3 4h18v2H3V4zm0 7h18v2H3v-2zm0 7h18v2H3v-2z"/></svg>
</button>
</div>
<button class="view-toggle-btn browser-refresh-btn" id="refreshBtn" onclick="refreshBrowser()" data-i18n-title="browser.refresh" title="Refresh">
<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M17.65 6.35A7.958 7.958 0 0012 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0112 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
</button>
<button class="browser-play-all-btn" id="playAllBtn" onclick="playAllFolder()" data-i18n-title="browser.play_all" title="Play All" style="display: none;">
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M8 5v14l11-7z"/></svg>
</button>
</div>
<div class="browser-search-wrapper" id="browserSearchWrapper" style="display: none;">
<svg class="browser-search-icon" viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0016 9.5 6.5 6.5 0 109.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
<input type="text" id="browserSearchInput" class="browser-search-input" data-i18n-placeholder="browser.search" placeholder="Search..." oninput="onBrowserSearch()">
<button class="browser-search-clear" id="browserSearchClear" onclick="clearBrowserSearch()" style="display: none;">
<svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
</button>
</div>
<div class="browser-toolbar-right">
<label class="items-per-page-label">
<span data-i18n="browser.items_per_page">Items per page:</span>
<select id="itemsPerPageSelect" onchange="onItemsPerPageChanged()">
<option value="25">25</option>
<option value="50">50</option>
<option value="100" selected>100</option>
<option value="200">200</option>
<option value="500">500</option>
</select>
</label>
</div>
</div>
<!-- File/Folder Grid -->
<div class="browser-grid" id="browserGrid">
<div class="browser-empty" data-i18n="browser.no_folder_selected">Select a folder to browse media files</div>
<div class="browser-empty empty-state-illustration">
<svg viewBox="0 0 64 64"><path d="M8 16h20l4-6h20a4 4 0 014 4v36a4 4 0 01-4 4H8a4 4 0 01-4-4V20a4 4 0 014-4z"/><path d="M4 24h56" stroke-dasharray="4 3" opacity="0.4"/></svg>
<p data-i18n="browser.no_folder_selected">Select a folder to browse media files</p>
</div>
</div>
<!-- Pagination -->
<div class="pagination" id="browserPagination" style="display: none;">
<button id="prevPage" onclick="previousPage()" data-i18n="browser.previous">Previous</button>
<span id="pageInfo">1 / 1</span>
<div class="pagination-center">
<span data-i18n="browser.page">Page</span>
<input type="number" id="pageInput" class="page-input" min="1" value="1" onchange="goToPage()">
<span id="pageTotal">/ 1</span>
</div>
<button id="nextPage" onclick="nextPage()" data-i18n="browser.next">Next</button>
</div>
</div>
<!-- Scripts Section -->
<div class="scripts-container" id="scripts-container" style="display: none;">
<h2 data-i18n="scripts.quick_actions">Quick Actions</h2>
<!-- Scripts Section (Quick Actions) -->
<div class="scripts-container" data-tab-content="quick-actions" role="tabpanel" id="panel-quick-actions">
<div class="scripts-grid" id="scripts-grid">
<div class="scripts-empty" data-i18n="scripts.no_scripts">No scripts configured</div>
<div class="scripts-empty empty-state-illustration">
<svg viewBox="0 0 64 64"><path d="M20 8l-8 48"/><path d="M44 8l8 48"/><path d="M10 24h44"/><path d="M8 40h44"/></svg>
<p data-i18n="quick_access.no_items">No quick actions or links configured</p>
</div>
</div>
</div>
<!-- Script Management Section -->
<div class="script-management">
<div class="script-management-header">
<h2 data-i18n="scripts.management">Script Management</h2>
<button class="add-script-btn" onclick="showAddScriptDialog()" data-i18n="scripts.add">+ Add</button>
</div>
<table class="scripts-table">
<thead>
<tr>
<th data-i18n="scripts.table.name">Name</th>
<th data-i18n="scripts.table.label">Label</th>
<th data-i18n="scripts.table.command">Command</th>
<th data-i18n="scripts.table.timeout">Timeout</th>
<th data-i18n="scripts.table.actions">Actions</th>
</tr>
</thead>
<tbody id="scriptsTableBody">
<tr>
<td colspan="5" class="empty-state" data-i18n="scripts.empty">No scripts configured. Click "Add" to create one.</td>
</tr>
</tbody>
</table>
<!-- Settings Section (Scripts, Callbacks, Links management) -->
<div class="settings-container" data-tab-content="settings" role="tabpanel" id="panel-settings">
<details class="settings-section" open id="audioDeviceSection" style="display: none;">
<summary data-i18n="settings.section.audio">Audio</summary>
<div class="settings-section-content">
<p class="settings-section-description" data-i18n="settings.audio.description">
Select which audio output device to capture for the visualizer.
</p>
<div class="audio-device-selector">
<label>
<span data-i18n="settings.audio.device">Loopback Device</span>
<select id="audioDeviceSelect" onchange="onAudioDeviceChanged()">
<option value="" data-i18n="settings.audio.auto">Auto-detect</option>
</select>
</label>
<div class="audio-device-status" id="audioDeviceStatus"></div>
</div>
</div>
</details>
<details class="settings-section" open>
<summary data-i18n="settings.section.scripts">Scripts</summary>
<div class="settings-section-content">
<table class="scripts-table">
<thead>
<tr>
<th data-i18n="scripts.table.name">Name</th>
<th data-i18n="scripts.table.label">Label</th>
<th data-i18n="scripts.table.command">Command</th>
<th data-i18n="scripts.table.timeout">Timeout</th>
<th data-i18n="scripts.table.actions">Actions</th>
</tr>
</thead>
<tbody id="scriptsTableBody">
<tr>
<td colspan="5" class="empty-state">
<div class="empty-state-illustration">
<svg viewBox="0 0 64 64"><rect x="8" y="4" width="48" height="56" rx="4"/><path d="M20 20h24M20 32h16M20 44h20"/></svg>
<p data-i18n="scripts.empty">No scripts configured. Click "Add" to create one.</p>
</div>
</td>
</tr>
</tbody>
</table>
<div class="add-card" onclick="showAddScriptDialog()">
<span class="add-card-icon">+</span>
</div>
</div>
</details>
<details class="settings-section" open>
<summary data-i18n="settings.section.links">Links</summary>
<div class="settings-section-content">
<p class="settings-section-description" data-i18n="links.description">
Quick links displayed as icons in the header bar. Click an icon to open the URL in a new tab.
</p>
<table class="scripts-table">
<thead>
<tr>
<th data-i18n="links.table.name">Name</th>
<th data-i18n="links.table.url">URL</th>
<th data-i18n="links.table.label">Label</th>
<th data-i18n="links.table.actions">Actions</th>
</tr>
</thead>
<tbody id="linksTableBody">
<tr>
<td colspan="4" class="empty-state">
<div class="empty-state-illustration">
<svg viewBox="0 0 64 64"><path d="M26 20a10 10 0 010 14l-6 6a10 10 0 01-14-14l6-6a10 10 0 0114 0" fill="none" stroke="currentColor" stroke-width="2"/><path d="M38 44a10 10 0 010-14l6-6a10 10 0 0114 14l-6 6a10 10 0 01-14 0" fill="none" stroke="currentColor" stroke-width="2"/><path d="M24 40l16-16" stroke="currentColor" stroke-width="2"/></svg>
<p data-i18n="links.empty">No links configured. Click "Add" to create one.</p>
</div>
</td>
</tr>
</tbody>
</table>
<div class="add-card" onclick="showAddLinkDialog()">
<span class="add-card-icon">+</span>
</div>
</div>
</details>
<details class="settings-section" open>
<summary data-i18n="settings.section.callbacks">Callbacks</summary>
<div class="settings-section-content">
<p class="settings-section-description" data-i18n="callbacks.description">
Callbacks are scripts triggered automatically by media control events (play, pause, stop, etc.)
</p>
<table class="scripts-table">
<thead>
<tr>
<th data-i18n="callbacks.table.event">Event</th>
<th data-i18n="callbacks.table.command">Command</th>
<th data-i18n="callbacks.table.timeout">Timeout</th>
<th data-i18n="callbacks.table.actions">Actions</th>
</tr>
</thead>
<tbody id="callbacksTableBody">
<tr>
<td colspan="4" class="empty-state">
<div class="empty-state-illustration">
<svg viewBox="0 0 64 64"><circle cx="32" cy="32" r="24"/><path d="M32 20v12l8 8"/></svg>
<p>No callbacks configured. Click "Add" to create one.</p>
</div>
</td>
</tr>
</tbody>
</table>
<div class="add-card" onclick="showAddCallbackDialog()">
<span class="add-card-icon">+</span>
</div>
</div>
</details>
</div>
<!-- Callback Management Section -->
<div class="script-management">
<div class="script-management-header">
<h2 data-i18n="callbacks.management">Callback Management</h2>
<button class="add-script-btn" onclick="showAddCallbackDialog()" data-i18n="callbacks.add">+ Add</button>
<!-- Display Control Section -->
<div class="display-container" data-tab-content="display" role="tabpanel" id="panel-display">
<div class="display-monitors" id="displayMonitors">
<div class="empty-state-illustration">
<svg viewBox="0 0 64 64"><rect x="8" y="10" width="48" height="32" rx="3" fill="none" stroke="currentColor" stroke-width="2"/><line x1="32" y1="42" x2="32" y2="50" stroke="currentColor" stroke-width="2"/><line x1="22" y1="50" x2="42" y2="50" stroke="currentColor" stroke-width="2"/></svg>
<p data-i18n="display.loading">Loading monitors...</p>
</div>
</div>
<p style="color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 1rem;" data-i18n="callbacks.description">
Callbacks are scripts triggered automatically by media control events (play, pause, stop, etc.)
</p>
<table class="scripts-table">
<thead>
<tr>
<th data-i18n="callbacks.table.event">Event</th>
<th data-i18n="callbacks.table.command">Command</th>
<th data-i18n="callbacks.table.timeout">Timeout</th>
<th data-i18n="callbacks.table.actions">Actions</th>
</tr>
</thead>
<tbody id="callbacksTableBody">
<tr>
<td colspan="4" class="empty-state">No callbacks configured. Click "Add Callback" to create one.</td>
</tr>
</tbody>
</table>
</div>
</div>
@@ -264,7 +452,10 @@
<label>
<span data-i18n="scripts.field.icon">Icon (MDI)</span>
<input type="text" id="scriptIcon" data-i18n-placeholder="scripts.placeholder.icon" placeholder="e.g., mdi:power">
<div class="icon-input-wrapper">
<input type="text" id="scriptIcon" data-i18n-placeholder="scripts.placeholder.icon" placeholder="e.g., mdi:power">
<div class="icon-preview" id="scriptIconPreview"></div>
</div>
</label>
<label>
@@ -328,28 +519,74 @@
</form>
</dialog>
<!-- Add/Edit Link Dialog -->
<dialog id="linkDialog">
<div class="dialog-header">
<h3 id="linkDialogTitle" data-i18n="links.dialog.add">Add Link</h3>
</div>
<form id="linkForm" onsubmit="saveLink(event)">
<div class="dialog-body">
<input type="hidden" id="linkOriginalName">
<input type="hidden" id="linkIsEdit">
<label>
<span data-i18n="links.field.name">Link Name *</span>
<input type="text" id="linkName" required pattern="[a-zA-Z0-9_]+"
data-i18n-title="links.placeholder.name" title="Only letters, numbers, and underscores allowed" maxlength="64">
</label>
<label>
<span data-i18n="links.field.url">URL *</span>
<input type="url" id="linkUrl" required data-i18n-placeholder="links.placeholder.url" placeholder="https://example.com">
</label>
<label>
<span data-i18n="links.field.icon">Icon (MDI)</span>
<div class="icon-input-wrapper">
<input type="text" id="linkIcon" data-i18n-placeholder="links.placeholder.icon" placeholder="mdi:link">
<div class="icon-preview" id="linkIconPreview"></div>
</div>
</label>
<label>
<span data-i18n="links.field.label">Label</span>
<input type="text" id="linkLabel" data-i18n-placeholder="links.placeholder.label" placeholder="Tooltip text">
</label>
<label>
<span data-i18n="links.field.description">Description</span>
<textarea id="linkDescription" data-i18n-placeholder="links.placeholder.description" placeholder="What does this link point to?"></textarea>
</label>
</div>
<div class="dialog-footer">
<button type="button" class="btn-secondary" onclick="closeLinkDialog()" data-i18n="links.button.cancel">Cancel</button>
<button type="submit" class="btn-primary" data-i18n="links.button.save">Save</button>
</div>
</form>
</dialog>
<!-- Execution Result Dialog -->
<dialog id="executionDialog">
<div class="dialog-header">
<h3 id="executionDialogTitle">Execution Result</h3>
<h3 id="executionDialogTitle" data-i18n="scripts.execution.title">Execution Result</h3>
</div>
<div class="dialog-body">
<div class="execution-status" id="executionStatus"></div>
<div class="result-section" id="outputSection" style="display: none;">
<h4>Output</h4>
<h4 data-i18n="scripts.execution.output">Output</h4>
<div class="execution-result">
<pre id="executionOutput"></pre>
</div>
</div>
<div class="result-section" id="errorSection" style="display: none;">
<h4>Error Output</h4>
<h4 data-i18n="scripts.execution.error_output">Error Output</h4>
<div class="execution-result">
<pre id="executionError"></pre>
</div>
</div>
</div>
<div class="dialog-footer">
<button type="button" class="btn-secondary" onclick="closeExecutionDialog()">Close</button>
<button type="button" class="btn-secondary" onclick="closeExecutionDialog()" data-i18n="scripts.execution.close">Close</button>
</div>
</dialog>
@@ -391,20 +628,37 @@
</form>
</dialog>
<!-- Toast Notification -->
<div class="toast" id="toast"></div>
<!-- Confirm Dialog -->
<dialog id="confirmDialog" class="confirm-dialog">
<p id="confirmDialogMessage"></p>
<div class="confirm-dialog-actions">
<button type="button" class="btn-cancel" id="confirmDialogCancel" data-i18n="dialog.cancel">Cancel</button>
<button type="button" class="btn-danger" id="confirmDialogConfirm" data-i18n="dialog.confirm">Confirm</button>
</div>
</dialog>
<!-- Toast Notifications -->
<div class="toast-container" id="toast-container"></div>
<!-- Footer -->
<footer>
<div>
Created by <strong>Alexei Dolgolyov</strong>
<span data-i18n="footer.created_by">Created by</span> <strong>Alexei Dolgolyov</strong>
<span class="separator"></span>
<a href="mailto:dolgolyov.alexei@gmail.com">dolgolyov.alexei@gmail.com</a>
<span class="separator"></span>
<a href="https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server" target="_blank" rel="noopener noreferrer">Source Code</a>
<a href="https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server" target="_blank" rel="noopener noreferrer" data-i18n="footer.source_code">Source Code</a>
</div>
</footer>
<script src="/static/js/app.js"></script>
<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>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,314 @@
// ============================================================
// Background: WebGL shader-based dynamic background
// ============================================================
let bgCanvas = null;
let bgGL = null;
let bgProgram = null;
let bgUniforms = null; // Cached uniform locations
let bgAnimFrame = null;
let bgEnabled = localStorage.getItem('dynamicBackground') === 'true';
let bgStartTime = 0;
let bgSmoothedBands = new Float32Array(16);
let bgSmoothedBass = 0;
let bgAccentRGB = [0.114, 0.725, 0.329]; // Cached accent color (default green)
let bgBgColorRGB = [0.071, 0.071, 0.071]; // Cached page background (#121212)
const BG_BAND_COUNT = 16;
const BG_SMOOTHING = 0.12;
// ---- Shaders ----
const BG_VERT_SRC = `
attribute vec2 a_position;
void main() {
gl_Position = vec4(a_position, 0.0, 1.0);
}
`;
const BG_FRAG_SRC = `
precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;
uniform float u_bass;
uniform float u_bands[16];
uniform vec3 u_accent;
uniform vec3 u_bgColor;
// Smooth noise
float hash(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}
float noise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
f = f * f * (3.0 - 2.0 * f);
float a = hash(i);
float b = hash(i + vec2(1.0, 0.0));
float c = hash(i + vec2(0.0, 1.0));
float d = hash(i + vec2(1.0, 1.0));
return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
}
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution;
float aspect = u_resolution.x / u_resolution.y;
// Center coordinates for radial effects
vec2 center = (uv - 0.5) * vec2(aspect, 1.0);
float dist = length(center);
float angle = atan(center.y, center.x);
// Slow base animation
float t = u_time * 0.15;
// === Layer 1: Flowing wave field ===
float waves = 0.0;
for (int i = 0; i < 5; i++) {
float fi = float(i);
float freq = 1.5 + fi * 0.8;
float speed = t * (0.6 + fi * 0.15);
// Sample a band for this wave layer
int bandIdx = i * 3;
float bandVal = 0.0;
// Manual indexing (GLSL ES doesn't allow variable array index in some drivers)
for (int j = 0; j < 16; j++) {
if (j == bandIdx) bandVal = u_bands[j];
}
float amp = 0.015 + bandVal * 0.06;
waves += amp * sin(uv.x * freq * 6.2832 + speed + sin(uv.y * 3.0 + t) * 2.0);
waves += amp * 0.5 * sin(uv.y * freq * 4.0 - speed * 0.7 + cos(uv.x * 2.5 + t) * 1.5);
}
// === Layer 2: Radial pulse (bass-driven) ===
float pulse = smoothstep(0.6 + u_bass * 0.3, 0.0, dist) * (0.08 + u_bass * 0.15);
// === Layer 3: Frequency ring arcs ===
float rings = 0.0;
for (int i = 0; i < 8; i++) {
float fi = float(i);
float bandVal = 0.0;
for (int j = 0; j < 16; j++) {
if (j == i * 2) bandVal = u_bands[j];
}
float radius = 0.15 + fi * 0.1;
float ringWidth = 0.008 + bandVal * 0.025;
float ring = smoothstep(ringWidth, 0.0, abs(dist - radius - bandVal * 0.05));
// Fade ring by angle sector for variety
float angleFade = 0.5 + 0.5 * sin(angle * (2.0 + fi) + t * (1.0 + fi * 0.3));
rings += ring * angleFade * (0.3 + bandVal * 0.7);
}
// === Layer 4: Subtle noise texture ===
float n = noise(uv * 4.0 + t * 0.5) * 0.03;
// Combine layers
float intensity = waves + pulse + rings * 0.5 + n;
// Color: accent color with varying brightness
vec3 col = u_accent * intensity;
// Subtle secondary hue shift for depth
vec3 shifted = u_accent.gbr; // Rotated accent
col += shifted * rings * 0.15;
// Vignette
float vignette = 1.0 - smoothstep(0.3, 1.2, dist);
col *= vignette;
// Blend over page background
col = clamp(col, 0.0, 1.0);
float colBright = (col.r + col.g + col.b) / 3.0;
float bgLum = dot(u_bgColor, vec3(0.299, 0.587, 0.114));
// Dark bg: add accent light. Light bg: tint white toward accent via multiply.
vec3 darkResult = u_bgColor + col;
vec3 lightResult = u_bgColor * mix(vec3(1.0), u_accent, colBright * 2.0);
vec3 finalColor = clamp(mix(darkResult, lightResult, bgLum), 0.0, 1.0);
gl_FragColor = vec4(finalColor, 1.0);
}
`;
// ---- WebGL setup ----
function initBackgroundGL() {
bgCanvas = document.getElementById('bg-shader-canvas');
if (!bgCanvas) return false;
bgGL = bgCanvas.getContext('webgl', { alpha: false, antialias: false, depth: false, stencil: false });
if (!bgGL) {
console.warn('WebGL not available for background shader');
return false;
}
const gl = bgGL;
// Compile shaders
const vs = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(vs, BG_VERT_SRC);
gl.compileShader(vs);
if (!gl.getShaderParameter(vs, gl.COMPILE_STATUS)) {
console.error('BG vertex shader:', gl.getShaderInfoLog(vs));
return false;
}
const fs = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fs, BG_FRAG_SRC);
gl.compileShader(fs);
if (!gl.getShaderParameter(fs, gl.COMPILE_STATUS)) {
console.error('BG fragment shader:', gl.getShaderInfoLog(fs));
return false;
}
bgProgram = gl.createProgram();
gl.attachShader(bgProgram, vs);
gl.attachShader(bgProgram, fs);
gl.linkProgram(bgProgram);
if (!gl.getProgramParameter(bgProgram, gl.LINK_STATUS)) {
console.error('BG program link:', gl.getProgramInfoLog(bgProgram));
return false;
}
// Fullscreen quad
const buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
-1, -1, 1, -1, -1, 1,
-1, 1, 1, -1, 1, 1
]), gl.STATIC_DRAW);
const aPos = gl.getAttribLocation(bgProgram, 'a_position');
gl.enableVertexAttribArray(aPos);
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
gl.useProgram(bgProgram);
// Cache uniform locations once (avoids per-frame lookups)
bgUniforms = {
resolution: gl.getUniformLocation(bgProgram, 'u_resolution'),
time: gl.getUniformLocation(bgProgram, 'u_time'),
bass: gl.getUniformLocation(bgProgram, 'u_bass'),
bands: gl.getUniformLocation(bgProgram, 'u_bands'),
accent: gl.getUniformLocation(bgProgram, 'u_accent'),
bgColor: gl.getUniformLocation(bgProgram, 'u_bgColor'),
};
bgStartTime = performance.now() / 1000;
updateBackgroundColors();
resizeBackgroundCanvas();
window.addEventListener('resize', resizeBackgroundCanvas);
return true;
}
function resizeBackgroundCanvas() {
if (!bgCanvas) return;
const dpr = Math.min(window.devicePixelRatio || 1, 1.5); // Cap DPR for performance
const w = Math.floor(window.innerWidth * dpr);
const h = Math.floor(window.innerHeight * dpr);
if (bgCanvas.width !== w || bgCanvas.height !== h) {
bgCanvas.width = w;
bgCanvas.height = h;
}
}
// ---- Cached color/theme updates (called on accent or theme change, not per-frame) ----
function updateBackgroundColors() {
const style = getComputedStyle(document.documentElement);
const accentHex = style.getPropertyValue('--accent').trim();
if (accentHex && accentHex.length >= 7) {
bgAccentRGB[0] = parseInt(accentHex.slice(1, 3), 16) / 255;
bgAccentRGB[1] = parseInt(accentHex.slice(3, 5), 16) / 255;
bgAccentRGB[2] = parseInt(accentHex.slice(5, 7), 16) / 255;
}
const bgHex = style.getPropertyValue('--bg-primary').trim();
if (bgHex && bgHex.length >= 7) {
bgBgColorRGB[0] = parseInt(bgHex.slice(1, 3), 16) / 255;
bgBgColorRGB[1] = parseInt(bgHex.slice(3, 5), 16) / 255;
bgBgColorRGB[2] = parseInt(bgHex.slice(5, 7), 16) / 255;
}
}
// ---- Render loop ----
function renderBackgroundFrame() {
bgAnimFrame = requestAnimationFrame(renderBackgroundFrame);
const gl = bgGL;
if (!gl || !bgUniforms) return;
resizeBackgroundCanvas();
gl.viewport(0, 0, bgCanvas.width, bgCanvas.height);
const time = performance.now() / 1000 - bgStartTime;
// Smooth audio data from the global frequencyData (shared with visualizer)
if (typeof frequencyData !== 'undefined' && frequencyData && frequencyData.frequencies) {
const bins = frequencyData.frequencies;
const step = Math.max(1, Math.floor(bins.length / BG_BAND_COUNT));
for (let i = 0; i < BG_BAND_COUNT; i++) {
const idx = Math.min(i * step, bins.length - 1);
const target = bins[idx] || 0;
bgSmoothedBands[i] += (target - bgSmoothedBands[i]) * (1 - BG_SMOOTHING);
}
const targetBass = frequencyData.bass || 0;
bgSmoothedBass += (targetBass - bgSmoothedBass) * (1 - BG_SMOOTHING);
} else {
// Gentle decay when no audio
for (let i = 0; i < BG_BAND_COUNT; i++) {
bgSmoothedBands[i] *= 0.95;
}
bgSmoothedBass *= 0.95;
}
// Set uniforms (locations cached at init, colors cached on change)
gl.uniform2f(bgUniforms.resolution, bgCanvas.width, bgCanvas.height);
gl.uniform1f(bgUniforms.time, time);
gl.uniform1f(bgUniforms.bass, bgSmoothedBass);
gl.uniform1fv(bgUniforms.bands, bgSmoothedBands);
gl.uniform3f(bgUniforms.accent, bgAccentRGB[0], bgAccentRGB[1], bgAccentRGB[2]);
gl.uniform3f(bgUniforms.bgColor, bgBgColorRGB[0], bgBgColorRGB[1], bgBgColorRGB[2]);
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
function startBackground() {
if (bgAnimFrame) return;
if (!bgGL && !initBackgroundGL()) return;
bgCanvas.classList.add('visible');
document.body.classList.add('dynamic-bg-active');
renderBackgroundFrame();
}
function stopBackground() {
if (bgAnimFrame) {
cancelAnimationFrame(bgAnimFrame);
bgAnimFrame = null;
}
if (bgCanvas) {
bgCanvas.classList.remove('visible');
}
document.body.classList.remove('dynamic-bg-active');
}
// ---- Public API ----
function toggleDynamicBackground() {
bgEnabled = !bgEnabled;
localStorage.setItem('dynamicBackground', bgEnabled);
applyDynamicBackground();
}
function applyDynamicBackground() {
const btn = document.getElementById('bgToggle');
if (bgEnabled) {
startBackground();
if (btn) btn.classList.add('active');
} else {
stopBackground();
if (btn) btn.classList.remove('active');
}
}

View File

@@ -0,0 +1,882 @@
// ============================================================
// Media Browser: Navigation, rendering, search, pagination
// ============================================================
// Browser state
let currentFolderId = null;
let currentPath = '';
let currentOffset = 0;
let itemsPerPage = parseInt(localStorage.getItem('mediaBrowser.itemsPerPage')) || 100;
let totalItems = 0;
let mediaFolders = {};
let viewMode = localStorage.getItem('mediaBrowser.viewMode') || 'grid';
let cachedItems = null;
let browserSearchTerm = '';
let browserSearchTimer = null;
const thumbnailCache = new Map();
const THUMBNAIL_CACHE_MAX = 200;
// Load media folders on page load
async function loadMediaFolders() {
try {
const token = localStorage.getItem('media_server_token');
if (!token) {
console.error('No API token found');
return;
}
const response = await fetch('/api/browser/folders', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) throw new Error('Failed to load folders');
mediaFolders = await response.json();
// Load last browsed path or show root folder list
loadLastBrowserPath();
} catch (error) {
console.error('Error loading media folders:', error);
showToast(t('browser.error_loading_folders'), 'error');
}
}
function showRootFolders() {
currentFolderId = '';
currentPath = '';
currentOffset = 0;
cachedItems = null;
// Hide search at root level
showBrowserSearch(false);
// Render breadcrumb with just "Home" (not clickable at root)
const breadcrumb = document.getElementById('breadcrumb');
breadcrumb.innerHTML = '';
const root = document.createElement('span');
root.className = 'breadcrumb-item breadcrumb-home';
root.innerHTML = '<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>';
breadcrumb.appendChild(root);
// Hide play all button and pagination
document.getElementById('playAllBtn').style.display = 'none';
document.getElementById('browserPagination').style.display = 'none';
// Render folders as grid cards
const container = document.getElementById('browserGrid');
revokeBlobUrls(container);
if (viewMode === 'list') {
container.className = 'browser-list';
} else if (viewMode === 'compact') {
container.className = 'browser-grid browser-grid-compact';
} else {
container.className = 'browser-grid';
}
container.innerHTML = '';
Object.entries(mediaFolders).forEach(([id, folder]) => {
if (!folder.enabled) return;
if (viewMode === 'list') {
const row = document.createElement('div');
row.className = 'browser-list-item';
row.onclick = () => {
currentFolderId = id;
browsePath(id, '');
};
row.innerHTML = `
<div class="browser-list-icon">\u{1F4C1}</div>
<div class="browser-list-name">${folder.label}</div>
`;
container.appendChild(row);
} else {
const card = document.createElement('div');
card.className = 'browser-item';
card.onclick = () => {
currentFolderId = id;
browsePath(id, '');
};
card.innerHTML = `
<div class="browser-thumb-wrapper">
<div class="browser-icon">\u{1F4C1}</div>
</div>
<div class="browser-item-info">
<div class="browser-item-name">${folder.label}</div>
</div>
`;
container.appendChild(card);
}
});
}
async function browsePath(folderId, path, offset = 0, nocache = false) {
// Clear search when navigating
showBrowserSearch(false);
try {
const token = localStorage.getItem('media_server_token');
if (!token) {
console.error('No API token found');
return;
}
// Show loading spinner
const container = document.getElementById('browserGrid');
container.className = 'browser-grid';
container.innerHTML = '<div class="browser-loading"><div class="loading-spinner"></div></div>';
const encodedPath = encodeURIComponent(path);
let url = `/api/browser/browse?folder_id=${folderId}&path=${encodedPath}&offset=${offset}&limit=${itemsPerPage}`;
if (nocache) url += '&nocache=true';
const response = await fetch(
url,
{ headers: { 'Authorization': `Bearer ${token}` } }
);
if (!response.ok) {
let errorMsg = 'Failed to browse path';
if (response.status === 503) {
const errorData = await response.json().catch(() => ({}));
errorMsg = errorData.detail || 'Folder is temporarily unavailable (network share not accessible)';
}
throw new Error(errorMsg);
}
const data = await response.json();
currentPath = data.current_path;
currentOffset = offset;
totalItems = data.total;
cachedItems = data.items;
renderBreadcrumbs(data.current_path, data.parent_path);
renderBrowserItems(cachedItems);
renderPagination();
// Show search bar when inside a folder
showBrowserSearch(true);
// Show/hide Play All button based on whether media items exist
const hasMedia = data.items.some(item => item.is_media);
document.getElementById('playAllBtn').style.display = hasMedia ? '' : 'none';
// Save last path
saveLastBrowserPath(folderId, currentPath);
} catch (error) {
console.error('Error browsing path:', error);
const errorMsg = error.message || t('browser.error_loading');
showToast(errorMsg, 'error');
clearBrowserGrid();
}
}
function renderBreadcrumbs(currentPath, parentPath) {
const breadcrumb = document.getElementById('breadcrumb');
breadcrumb.innerHTML = '';
const parts = (currentPath || '').split('/').filter(p => p);
let path = '/';
// Home link (back to folder list)
const home = document.createElement('span');
home.className = 'breadcrumb-item breadcrumb-home';
home.innerHTML = '<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>';
home.onclick = () => showRootFolders();
breadcrumb.appendChild(home);
// Separator + Folder name
const sep = document.createElement('span');
sep.className = 'breadcrumb-separator';
sep.textContent = '\u203A';
breadcrumb.appendChild(sep);
const folderItem = document.createElement('span');
folderItem.className = 'breadcrumb-item';
folderItem.textContent = mediaFolders[currentFolderId]?.label || 'Root';
if (parts.length > 0) {
folderItem.onclick = () => browsePath(currentFolderId, '');
}
breadcrumb.appendChild(folderItem);
// Path parts
parts.forEach((part, index) => {
// Separator
const separator = document.createElement('span');
separator.className = 'breadcrumb-separator';
separator.textContent = '\u203A';
breadcrumb.appendChild(separator);
// Part
path += (path === '/' ? '' : '/') + part;
const item = document.createElement('span');
item.className = 'breadcrumb-item';
item.textContent = part;
const itemPath = path;
item.onclick = () => browsePath(currentFolderId, itemPath);
breadcrumb.appendChild(item);
});
}
function revokeBlobUrls(container) {
const cachedUrls = new Set(thumbnailCache.values());
container.querySelectorAll('img[src^="blob:"]').forEach(img => {
// Don't revoke URLs managed by the thumbnail cache
if (!cachedUrls.has(img.src)) {
URL.revokeObjectURL(img.src);
}
});
}
function renderBrowserItems(items) {
const container = document.getElementById('browserGrid');
revokeBlobUrls(container);
// Switch container class based on view mode
if (viewMode === 'list') {
container.className = 'browser-list';
renderBrowserList(items, container);
} else if (viewMode === 'compact') {
container.className = 'browser-grid browser-grid-compact';
renderBrowserGrid(items, container);
} else {
container.className = 'browser-grid';
renderBrowserGrid(items, container);
}
}
function renderBrowserList(items, container) {
container.innerHTML = '';
if (!items || items.length === 0) {
container.innerHTML = `<div class="browser-empty">${emptyStateHtml(EMPTY_SVG_FILE, t('browser.no_items'))}</div>`;
return;
}
items.forEach((item, idx) => {
const row = document.createElement('div');
row.className = 'browser-list-item';
row.style.setProperty('--item-index', Math.min(idx, 20));
row.dataset.name = item.name;
row.dataset.type = item.type;
// Icon (small) with play overlay
const icon = document.createElement('div');
icon.className = 'browser-list-icon';
if (item.is_media && item.type === 'audio') {
const thumbnail = document.createElement('img');
thumbnail.className = 'browser-list-thumbnail loading';
thumbnail.alt = item.name;
icon.appendChild(thumbnail);
loadThumbnail(thumbnail, item.name);
} else {
icon.textContent = getFileIcon(item.type);
}
if (item.is_media) {
const overlay = document.createElement('div');
overlay.className = 'browser-list-play-overlay';
overlay.innerHTML = '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M8 5v14l11-7z"/></svg>';
icon.appendChild(overlay);
}
row.appendChild(icon);
// Name (show media title if available)
const name = document.createElement('div');
name.className = 'browser-list-name';
name.textContent = item.title || item.name;
row.appendChild(name);
// Bitrate
const br = document.createElement('div');
br.className = 'browser-list-bitrate';
br.textContent = formatBitrate(item.bitrate) || '';
row.appendChild(br);
// Duration
const dur = document.createElement('div');
dur.className = 'browser-list-duration';
dur.textContent = formatDuration(item.duration) || '';
row.appendChild(dur);
// Size
const size = document.createElement('div');
size.className = 'browser-list-size';
size.textContent = (item.size !== null && item.type !== 'folder') ? formatFileSize(item.size) : '';
row.appendChild(size);
// Download button
if (item.is_media) {
row.appendChild(createDownloadBtn(item.name, 'browser-list-download'));
} else {
row.appendChild(document.createElement('div'));
}
// Tooltip: show filename when title is displayed, or when name is ellipsed
row.addEventListener('mouseenter', () => {
if (item.title || name.scrollWidth > name.clientWidth) {
row.title = item.name;
} else {
row.title = '';
}
});
// Single click: play media or navigate folder
row.onclick = () => {
if (item.type === 'folder') {
const newPath = currentPath === '/'
? '/' + item.name
: currentPath + '/' + item.name;
browsePath(currentFolderId, newPath);
} else if (item.is_media) {
playMediaFile(item.name);
}
};
container.appendChild(row);
});
}
function renderBrowserGrid(items, container) {
container = container || document.getElementById('browserGrid');
container.innerHTML = '';
if (!items || items.length === 0) {
container.innerHTML = `<div class="browser-empty">${emptyStateHtml(EMPTY_SVG_FILE, t('browser.no_items'))}</div>`;
return;
}
items.forEach((item, idx) => {
const div = document.createElement('div');
div.className = 'browser-item';
div.style.setProperty('--item-index', Math.min(idx, 20));
div.dataset.name = item.name;
div.dataset.type = item.type;
// Type badge
if (item.type !== 'folder') {
const typeBadge = document.createElement('div');
typeBadge.className = `browser-item-type ${item.type}`;
typeBadge.innerHTML = getTypeBadgeIcon(item.type);
div.appendChild(typeBadge);
}
// Thumbnail wrapper (for play overlay)
const thumbWrapper = document.createElement('div');
thumbWrapper.className = 'browser-thumb-wrapper';
// Thumbnail or icon
if (item.is_media && item.type === 'audio') {
const thumbnail = document.createElement('img');
thumbnail.className = 'browser-thumbnail loading';
thumbnail.alt = item.name;
thumbWrapper.appendChild(thumbnail);
// 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);
}
// Play overlay for media files
if (item.is_media) {
const overlay = document.createElement('div');
overlay.className = 'browser-play-overlay';
overlay.innerHTML = '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M8 5v14l11-7z"/></svg>';
thumbWrapper.appendChild(overlay);
}
div.appendChild(thumbWrapper);
// Info
const info = document.createElement('div');
info.className = 'browser-item-info';
const name = document.createElement('div');
name.className = 'browser-item-name';
name.textContent = item.title || item.name;
info.appendChild(name);
if (item.type !== 'folder') {
const meta = document.createElement('div');
meta.className = 'browser-item-meta';
const parts = [];
const duration = formatDuration(item.duration);
if (duration) parts.push(duration);
const bitrate = formatBitrate(item.bitrate);
if (bitrate) parts.push(bitrate);
if (item.size !== null) parts.push(formatFileSize(item.size));
meta.textContent = parts.join(' \u00B7 ');
if (parts.length) info.appendChild(meta);
}
div.appendChild(info);
// Tooltip: show filename when title is displayed, or when name is ellipsed
div.addEventListener('mouseenter', () => {
if (item.title || name.scrollWidth > name.clientWidth || name.scrollHeight > name.clientHeight) {
div.title = item.name;
} else {
div.title = '';
}
});
// Single click: play media or navigate folder
div.onclick = () => {
if (item.type === 'folder') {
const newPath = currentPath === '/'
? '/' + item.name
: currentPath + '/' + item.name;
browsePath(currentFolderId, newPath);
} else if (item.is_media) {
playMediaFile(item.name);
}
};
container.appendChild(div);
});
}
function getTypeBadgeIcon(type) {
const svgs = {
'audio': '<svg viewBox="0 0 24 24" width="10" height="10"><path fill="currentColor" d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/></svg>',
'video': '<svg viewBox="0 0 24 24" width="10" height="10"><path fill="currentColor" d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg>',
};
return svgs[type] || '';
}
function getFileIcon(type) {
const icons = {
'folder': '\u{1F4C1}',
'audio': '\u{1F3B5}',
'video': '\u{1F3AC}',
'other': '\u{1F4C4}'
};
return icons[type] || icons.other;
}
function formatFileSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return (bytes / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i];
}
function formatDuration(seconds) {
if (seconds == null || seconds <= 0) return null;
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) {
return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
return `${m}:${String(s).padStart(2, '0')}`;
}
function formatBitrate(bps) {
if (bps == null || bps <= 0) return null;
return Math.round(bps / 1000) + ' kbps';
}
async function loadThumbnail(imgElement, fileName) {
try {
const token = localStorage.getItem('media_server_token');
if (!token) {
console.error('No API token found');
return;
}
const absolutePath = buildAbsolutePath(currentFolderId, currentPath, fileName);
// Check cache first
if (thumbnailCache.has(absolutePath)) {
const cachedUrl = thumbnailCache.get(absolutePath);
imgElement.onload = () => {
imgElement.classList.remove('loading');
imgElement.classList.add('loaded');
};
imgElement.src = cachedUrl;
return;
}
const encodedPath = encodeURIComponent(absolutePath);
const response = await fetch(
`/api/browser/thumbnail?path=${encodedPath}&size=medium`,
{ headers: { 'Authorization': `Bearer ${token}` } }
);
if (response.status === 200) {
const blob = await response.blob();
const url = URL.createObjectURL(blob);
thumbnailCache.set(absolutePath, url);
// Evict oldest entries when cache exceeds limit
if (thumbnailCache.size > THUMBNAIL_CACHE_MAX) {
const oldest = thumbnailCache.keys().next().value;
URL.revokeObjectURL(thumbnailCache.get(oldest));
thumbnailCache.delete(oldest);
}
// Wait for image to actually load before showing it
imgElement.onload = () => {
imgElement.classList.remove('loading');
imgElement.classList.add('loaded');
};
// Revoke previous blob URL if not managed by cache
// (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; }
}
if (!isCached) URL.revokeObjectURL(imgElement.src);
}
imgElement.src = url;
} else {
// Fallback to icon (204 = no thumbnail available)
const parent = imgElement.parentElement;
const isList = parent.classList.contains('browser-list-icon');
imgElement.remove();
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);
}
}
} catch (error) {
console.error('Error loading thumbnail:', error);
imgElement.classList.remove('loading');
}
}
function buildAbsolutePath(folderId, relativePath, fileName) {
const folderPath = mediaFolders[folderId].path;
// Detect separator from folder path
const sep = folderPath.includes('/') ? '/' : '\\';
const fullRelative = relativePath === '/'
? sep + fileName
: relativePath.replace(/[/\\]/g, sep) + sep + fileName;
return folderPath + fullRelative;
}
let playInProgress = false;
async function playMediaFile(fileName) {
if (playInProgress) return;
playInProgress = true;
try {
const token = localStorage.getItem('media_server_token');
if (!token) {
console.error('No API token found');
return;
}
const absolutePath = buildAbsolutePath(currentFolderId, currentPath, fileName);
const response = await fetch('/api/browser/play', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ path: absolutePath })
});
if (!response.ok) throw new Error('Failed to play file');
showToast(t('browser.play_success', { filename: fileName }), 'success');
} catch (error) {
console.error('Error playing file:', error);
showToast(t('browser.play_error'), 'error');
} finally {
playInProgress = false;
}
}
async function playAllFolder() {
if (playInProgress) return;
playInProgress = true;
const btn = document.getElementById('playAllBtn');
if (btn) btn.disabled = true;
try {
const token = localStorage.getItem('media_server_token');
if (!token || !currentFolderId) return;
const response = await fetch('/api/browser/play-folder', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ folder_id: currentFolderId, path: currentPath })
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.detail || 'Failed to play folder');
}
const data = await response.json();
showToast(t('browser.play_all_success', { count: data.count }), 'success');
} catch (error) {
console.error('Error playing folder:', error);
showToast(t('browser.play_all_error'), 'error');
} finally {
playInProgress = false;
if (btn) btn.disabled = false;
}
}
async function downloadFile(fileName, event) {
if (event) event.stopPropagation();
const token = localStorage.getItem('media_server_token');
if (!token) return;
const fullPath = currentPath === '/'
? '/' + fileName
: currentPath + '/' + fileName;
const encodedPath = encodeURIComponent(fullPath);
try {
const response = await fetch(
`/api/browser/download?folder_id=${currentFolderId}&path=${encodedPath}`,
{ headers: { 'Authorization': `Bearer ${token}` } }
);
if (!response.ok) throw new Error('Download failed');
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (error) {
console.error('Download error:', error);
showToast(t('browser.download_error'), 'error');
}
}
function createDownloadBtn(fileName, cssClass) {
const btn = document.createElement('button');
btn.className = cssClass;
btn.innerHTML = '<svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/></svg>';
btn.title = t('browser.download');
btn.onclick = (e) => downloadFile(fileName, e);
return btn;
}
function renderPagination() {
const pagination = document.getElementById('browserPagination');
const prevBtn = document.getElementById('prevPage');
const nextBtn = document.getElementById('nextPage');
const pageInput = document.getElementById('pageInput');
const pageTotal = document.getElementById('pageTotal');
const totalPages = Math.ceil(totalItems / itemsPerPage);
const currentPage = Math.floor(currentOffset / itemsPerPage) + 1;
if (totalPages <= 1) {
pagination.style.display = 'none';
return;
}
pagination.style.display = 'flex';
pageInput.value = currentPage;
pageInput.max = totalPages;
pageTotal.textContent = `/ ${totalPages}`;
prevBtn.disabled = currentPage === 1;
nextBtn.disabled = currentPage === totalPages;
}
function previousPage() {
if (currentOffset >= itemsPerPage) {
browsePath(currentFolderId, currentPath, currentOffset - itemsPerPage);
}
}
function nextPage() {
if (currentOffset + itemsPerPage < totalItems) {
browsePath(currentFolderId, currentPath, currentOffset + itemsPerPage);
}
}
function refreshBrowser() {
if (currentFolderId) {
browsePath(currentFolderId, currentPath, currentOffset, true);
} else {
loadMediaFolders();
}
}
// Browser search
function onBrowserSearch() {
const input = document.getElementById('browserSearchInput');
const clearBtn = document.getElementById('browserSearchClear');
const term = input.value.trim();
clearBtn.style.display = term ? 'flex' : 'none';
// Debounce: wait 200ms after typing stops
if (browserSearchTimer) clearTimeout(browserSearchTimer);
browserSearchTimer = setTimeout(() => {
browserSearchTerm = term.toLowerCase();
applyBrowserSearch();
}, SEARCH_DEBOUNCE_MS);
}
function clearBrowserSearch() {
const input = document.getElementById('browserSearchInput');
input.value = '';
document.getElementById('browserSearchClear').style.display = 'none';
browserSearchTerm = '';
applyBrowserSearch();
input.focus();
}
function applyBrowserSearch() {
if (!cachedItems) return;
if (!browserSearchTerm) {
renderBrowserItems(cachedItems);
return;
}
const filtered = cachedItems.filter(item =>
item.name.toLowerCase().includes(browserSearchTerm) ||
(item.title && item.title.toLowerCase().includes(browserSearchTerm))
);
renderBrowserItems(filtered);
}
function showBrowserSearch(visible) {
document.getElementById('browserSearchWrapper').style.display = visible ? '' : 'none';
if (!visible) {
document.getElementById('browserSearchInput').value = '';
document.getElementById('browserSearchClear').style.display = 'none';
browserSearchTerm = '';
}
}
function setViewMode(mode) {
if (mode === viewMode) return;
viewMode = mode;
localStorage.setItem('mediaBrowser.viewMode', mode);
// Update toggle buttons
document.querySelectorAll('.view-toggle-btn').forEach(btn => btn.classList.remove('active'));
const btnId = mode === 'list' ? 'viewListBtn' : mode === 'compact' ? 'viewCompactBtn' : 'viewGridBtn';
document.getElementById(btnId).classList.add('active');
// Re-render current view from cache (no network request)
if (currentFolderId && cachedItems) {
applyBrowserSearch();
} else {
showRootFolders();
}
}
function onItemsPerPageChanged() {
const select = document.getElementById('itemsPerPageSelect');
itemsPerPage = parseInt(select.value);
localStorage.setItem('mediaBrowser.itemsPerPage', itemsPerPage);
// Reset to first page and reload
if (currentFolderId) {
currentOffset = 0;
browsePath(currentFolderId, currentPath, 0);
}
}
function goToPage() {
const pageInput = document.getElementById('pageInput');
const totalPages = Math.ceil(totalItems / itemsPerPage);
let page = parseInt(pageInput.value);
if (isNaN(page) || page < 1) page = 1;
if (page > totalPages) page = totalPages;
pageInput.value = page;
const newOffset = (page - 1) * itemsPerPage;
if (newOffset !== currentOffset) {
browsePath(currentFolderId, currentPath, newOffset);
}
}
function initBrowserToolbar() {
// Restore view mode
const savedViewMode = localStorage.getItem('mediaBrowser.viewMode') || 'grid';
viewMode = savedViewMode;
document.querySelectorAll('.view-toggle-btn').forEach(btn => btn.classList.remove('active'));
const btnId = savedViewMode === 'list' ? 'viewListBtn' : savedViewMode === 'compact' ? 'viewCompactBtn' : 'viewGridBtn';
document.getElementById(btnId).classList.add('active');
// Restore items per page
const savedItemsPerPage = localStorage.getItem('mediaBrowser.itemsPerPage');
if (savedItemsPerPage) {
itemsPerPage = parseInt(savedItemsPerPage);
document.getElementById('itemsPerPageSelect').value = savedItemsPerPage;
}
}
function clearBrowserGrid() {
const grid = document.getElementById('browserGrid');
grid.innerHTML = `<div class="browser-empty">${emptyStateHtml(EMPTY_SVG_FOLDER, t('browser.no_folder_selected'))}</div>`;
document.getElementById('breadcrumb').innerHTML = '';
document.getElementById('browserPagination').style.display = 'none';
document.getElementById('playAllBtn').style.display = 'none';
}
// LocalStorage for last path
function saveLastBrowserPath(folderId, path) {
try {
localStorage.setItem('mediaBrowser.lastFolderId', folderId);
localStorage.setItem('mediaBrowser.lastPath', path);
} catch (e) {
console.error('Failed to save last browser path:', e);
}
}
function loadLastBrowserPath() {
try {
const lastFolderId = localStorage.getItem('mediaBrowser.lastFolderId');
const lastPath = localStorage.getItem('mediaBrowser.lastPath');
if (lastFolderId && mediaFolders[lastFolderId]) {
currentFolderId = lastFolderId;
browsePath(lastFolderId, lastPath || '');
} else {
showRootFolders();
}
} catch (e) {
console.error('Failed to load last browser path:', e);
showRootFolders();
}
}
// Folder Management
function showManageFoldersDialog() {
// TODO: Implement folder management UI
// For now, show a simple alert
showToast(t('browser.manage_folders_hint'), 'info');
}
function closeFolderDialog() {
closeDialog(document.getElementById('folderDialog'));
}
async function saveFolder(event) {
event.preventDefault();
// TODO: Implement folder save functionality
closeFolderDialog();
}

View File

@@ -0,0 +1,209 @@
// ============================================================
// Callbacks: CRUD management
// ============================================================
let callbackFormDirty = false;
let _loadCallbacksPromise = null;
async function loadCallbacksTable() {
if (_loadCallbacksPromise) return _loadCallbacksPromise;
_loadCallbacksPromise = _loadCallbacksTableImpl();
_loadCallbacksPromise.finally(() => { _loadCallbacksPromise = null; });
return _loadCallbacksPromise;
}
async function _loadCallbacksTableImpl() {
const token = localStorage.getItem('media_server_token');
const tbody = document.getElementById('callbacksTableBody');
try {
const response = await fetch('/api/callbacks/list', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) {
throw new Error('Failed to fetch callbacks');
}
const callbacksList = await response.json();
if (callbacksList.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="empty-state"><div class="empty-state-illustration"><svg viewBox="0 0 64 64"><circle cx="32" cy="32" r="24"/><path d="M32 20v12l8 8"/></svg><p>' + t('callbacks.empty') + '</p></div></td></tr>';
return;
}
tbody.innerHTML = callbacksList.map(callback => `
<tr>
<td><code>${escapeHtml(callback.name)}</code></td>
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"
title="${escapeHtml(callback.command)}">${escapeHtml(callback.command)}</td>
<td>${callback.timeout}s</td>
<td>
<div class="action-buttons">
<button class="action-btn execute" data-action="execute" data-callback-name="${escapeHtml(callback.name)}" title="Execute callback">
<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
</button>
<button class="action-btn" data-action="edit" data-callback-name="${escapeHtml(callback.name)}" title="Edit callback">
<svg viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
</button>
<button class="action-btn delete" data-action="delete" data-callback-name="${escapeHtml(callback.name)}" title="Delete callback">
<svg viewBox="0 0 24 24"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
</button>
</div>
</td>
</tr>
`).join('');
} catch (error) {
console.error('Error loading callbacks:', error);
tbody.innerHTML = '<tr><td colspan="4" class="empty-state" style="color: var(--error);">Failed to load callbacks</td></tr>';
}
}
function showAddCallbackDialog() {
const dialog = document.getElementById('callbackDialog');
const form = document.getElementById('callbackForm');
const title = document.getElementById('callbackDialogTitle');
form.reset();
document.getElementById('callbackIsEdit').value = 'false';
document.getElementById('callbackName').disabled = false;
title.textContent = t('callbacks.dialog.add');
callbackFormDirty = false;
document.body.classList.add('dialog-open');
dialog.showModal();
}
async function showEditCallbackDialog(callbackName) {
const token = localStorage.getItem('media_server_token');
const dialog = document.getElementById('callbackDialog');
const title = document.getElementById('callbackDialogTitle');
try {
const response = await fetch('/api/callbacks/list', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) {
throw new Error('Failed to fetch callback details');
}
const callbacksList = await response.json();
const callback = callbacksList.find(c => c.name === callbackName);
if (!callback) {
showToast('Callback not found', 'error');
return;
}
document.getElementById('callbackIsEdit').value = 'true';
document.getElementById('callbackName').value = callbackName;
document.getElementById('callbackName').disabled = true;
document.getElementById('callbackCommand').value = callback.command;
document.getElementById('callbackTimeout').value = callback.timeout;
document.getElementById('callbackWorkingDir').value = callback.working_dir || '';
title.textContent = t('callbacks.dialog.edit');
callbackFormDirty = false;
document.body.classList.add('dialog-open');
dialog.showModal();
} catch (error) {
console.error('Error loading callback for edit:', error);
showToast('Failed to load callback details', 'error');
}
}
async function closeCallbackDialog() {
if (callbackFormDirty) {
if (!await showConfirm(t('callbacks.confirm.unsaved'))) {
return;
}
}
const dialog = document.getElementById('callbackDialog');
callbackFormDirty = false;
closeDialog(dialog);
document.body.classList.remove('dialog-open');
}
async function saveCallback(event) {
event.preventDefault();
const submitBtn = event.target.querySelector('button[type="submit"]');
if (submitBtn) submitBtn.disabled = true;
const token = localStorage.getItem('media_server_token');
const isEdit = document.getElementById('callbackIsEdit').value === 'true';
const callbackName = document.getElementById('callbackName').value;
const data = {
command: document.getElementById('callbackCommand').value,
timeout: parseInt(document.getElementById('callbackTimeout').value) || 30,
working_dir: document.getElementById('callbackWorkingDir').value || null,
shell: true
};
const endpoint = isEdit ?
`/api/callbacks/update/${callbackName}` :
`/api/callbacks/create/${callbackName}`;
const method = isEdit ? 'PUT' : 'POST';
try {
const response = await fetch(endpoint, {
method,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
const result = await response.json();
if (response.ok && result.success) {
showToast(`Callback ${isEdit ? 'updated' : 'created'} successfully`, 'success');
callbackFormDirty = false;
closeCallbackDialog();
loadCallbacksTable();
} else {
showToast(result.detail || `Failed to ${isEdit ? 'update' : 'create'} callback`, 'error');
}
} catch (error) {
console.error('Error saving callback:', error);
showToast(`Error ${isEdit ? 'updating' : 'creating'} callback`, 'error');
} finally {
if (submitBtn) submitBtn.disabled = false;
}
}
async function deleteCallbackConfirm(callbackName) {
if (!await showConfirm(t('callbacks.confirm.delete').replace('{name}', callbackName))) {
return;
}
const token = localStorage.getItem('media_server_token');
try {
const response = await fetch(`/api/callbacks/delete/${callbackName}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
});
const result = await response.json();
if (response.ok && result.success) {
showToast('Callback deleted successfully', 'success');
loadCallbacksTable();
} else {
showToast(result.detail || 'Failed to delete callback', 'error');
}
} catch (error) {
console.error('Error deleting callback:', error);
showToast('Error deleting callback', 'error');
}
}

View File

@@ -0,0 +1,503 @@
// ============================================================
// Core: Shared state, constants, utilities, i18n, API commands
// ============================================================
// 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"/>';
// 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) {
return `<div class="empty-state-illustration">${svgStr}<p>${text}</p></div>`;
}
// Media source registry: substring key → { name, icon }
const MEDIA_SOURCES = {
'spotify': {
name: 'Spotify',
icon: '<svg viewBox="0 0 24 24"><path fill="#1DB954" d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z"/></svg>'
},
'yandex music': {
name: 'Yandex Music',
icon: '<svg viewBox="0 0 24 24"><path fill="#FFCC00" d="M12 0C5.376 0 0 5.376 0 12s5.376 12 12 12 12-5.376 12-12S18.624 0 12 0zm0 2.4a9.6 9.6 0 110 19.2 9.6 9.6 0 010-19.2z"/><path fill="#FFCC00" d="M13.2 6h-2.4v7.2L7.2 6H4.8l5.4 12h1.2l.6-1.35V6z"/></svg>'
},
'яндекс музыка': {
name: 'Яндекс Музыка',
icon: '<svg viewBox="0 0 24 24"><path fill="#FFCC00" d="M12 0C5.376 0 0 5.376 0 12s5.376 12 12 12 12-5.376 12-12S18.624 0 12 0zm0 2.4a9.6 9.6 0 110 19.2 9.6 9.6 0 010-19.2z"/><path fill="#FFCC00" d="M13.2 6h-2.4v7.2L7.2 6H4.8l5.4 12h1.2l.6-1.35V6z"/></svg>'
},
'chrome': {
name: 'Google Chrome',
icon: '<svg viewBox="0 0 24 24"><circle fill="#4587F3" cx="12" cy="12" r="11"/><path fill="#DB4437" d="M12 1C7.2 1 3.1 3.8 1.3 7.9L7.7 12l1.8-3.1c.7-1.1 1.9-1.9 3.3-1.9h9.7C21 3.5 16.9 1 12 1z"/><path fill="#0F9D58" d="M7.7 12L1.3 7.9C.5 9.2 0 10.6 0 12c0 4.5 2.8 8.4 6.8 10l3.8-6.6L7.7 12z"/><path fill="#FFCD40" d="M6.8 22c2.7 1.5 6.4 1.7 9.4.2 2.8-1.4 4.9-3.9 5.8-6.8l-6.5-3.4-1.8 3.1c-.7 1.1-1.9 1.9-3.3 1.9-.9 0-1.7-.3-2.4-.7L6.8 22z"/><circle fill="#F1F1F1" cx="12" cy="12" r="4.8"/><circle fill="#4587F3" cx="12" cy="12" r="3.8"/></svg>'
},
'msedge': {
name: 'Microsoft Edge',
icon: '<svg viewBox="0 0 24 24"><path fill="#0078D4" d="M21.86 17.86q.14 0 .25-.12.1-.13.1-.25 0-.06 0-.13-.12-.76-.39-1.49-.26-.72-.65-1.39-.4-.66-.92-1.25-.53-.58-1.15-1.06-.61-.48-1.3-.85-.69-.37-1.44-.6-.75-.22-1.53-.3-.8-.07-1.6 0h-.04q-.51.03-1.03.14-.5.12-1 .31-.49.2-.95.46-.46.27-.89.6-.42.32-.8.7-.37.4-.69.83-.31.44-.57.92-.25.49-.44 1 .09-.14.21-.28.12-.14.26-.27.14-.12.3-.23.16-.1.33-.18.18-.08.37-.14.18-.06.38-.08.2-.02.4-.01.21.01.41.06.28.07.53.2.25.12.47.3.21.18.39.4.18.21.32.45.14.25.23.52.1.26.14.54.04.28.02.56-.02.36-.12.72-.1.35-.27.68-.17.33-.4.62-.24.3-.52.56-.28.25-.6.46-.32.2-.67.35.44.1.9.14.44.03.89-.02.45-.05.88-.17.44-.12.85-.3.41-.2.79-.44.37-.25.71-.55.34-.3.63-.65.3-.35.54-.73.24-.39.42-.8.18-.42.3-.86.12-.43.18-.88.06-.45.06-.9 0-.48-.07-.95-.07-.47-.22-.93z"/><path fill="#50E6FF" d="M11.89.03Q10.03.17 8.3.88 6.57 1.59 5.1 2.77 3.65 3.94 2.55 5.5 1.44 7.06.79 8.88.14 10.7 0 12.65q.01.22.02.45 0 .22.03.44.04.42.12.83.08.42.2.83.12.4.28.79.16.39.36.76.2.37.43.72.24.34.51.66.27.32.57.6.3.29.63.54.33.25.68.46.35.21.72.38.38.17.77.28.39.12.79.18.41.06.82.05.41 0 .82-.07.41-.08.79-.22.39-.14.74-.34.36-.2.68-.44.33-.25.6-.54.28-.3.5-.63.23-.33.4-.7.17-.36.27-.75-1.1.9-2.44 1.36-1.33.46-2.77.46-1.26 0-2.44-.39-1.18-.39-2.17-1.08-1-1.08-1.6-2.02-.6-.94-.87-2-.27-1.07-.25-2.2.02-.55.12-1.08.1-.54.29-1.05.18-.52.44-1 .27-.49.6-.94.34-.44.74-.83.4-.38.85-.71.45-.32.94-.57.49-.25 1.02-.42.52-.16 1.07-.24.55-.07 1.1-.05.81.04 1.57.25.77.2 1.46.56.7.36 1.29.85.6.5 1.07 1.1.48.6.82 1.29.34.69.54 1.44.2.76.24 1.55.04.79-.08 1.57-.11.78-.37 1.52-.26.74-.66 1.4-.39.67-.91 1.24-.52.57-1.14 1.02-.62.44-1.32.76-.7.32-1.45.49-.75.16-1.52.18z"/></svg>'
},
'firefox': {
name: 'Firefox',
icon: '<svg viewBox="0 0 24 24"><path fill="#FF7139" d="M12 0C5.37 0 0 5.37 0 12s5.37 12 12 12 12-5.37 12-12S18.63 0 12 0zm6.73 7.27c-.47-.77-1.22-1.6-1.7-1.87.54.97.86 2.07.93 3.15 0 0-.02.03-.02.05-.38-1.34-1.14-2.15-1.78-3.05-.03-.05-.06-.1-.1-.15-.03-.05-.05-.1-.06-.15 0-.02-.01-.04-.02-.05l-.01.02c-.02.03-.03.05-.04.08 0 0 0 .01-.01.02l.01-.02c-.64 1.07-1.72 2.2-2.1 3.56-.46.01-.9.09-1.32.23l-.06.03c-.03-.2-.04-.4-.04-.6 0-.67.15-1.3.4-1.87-1.08.4-1.93 1.12-2.53 1.72-.33-.36-.36-1.56-.34-1.8-.01 0-.03.02-.04.02-.27.2-.52.42-.75.66-.28.3-.53.62-.76.96-.12.2-.24.4-.34.6-.15.32-.27.66-.36 1-.02.07-.03.14-.05.21v.03c-.06.3-.1.6-.12.9v.1c0 .07 0 .14-.01.21C7.3 13.8 7.52 16.37 9 18.26l.04.05c-1.55-1-2.57-2.64-2.87-4.42-.04.2-.06.4-.07.6-.01.2-.02.4-.01.6.02.6.13 1.2.3 1.77.2.57.46 1.12.8 1.62.17.25.36.48.56.7.2.22.42.43.66.62 1.83 1.47 4.17 1.87 6.34 1.21.26-.08.5-.17.74-.28 1.1-.5 2.06-1.27 2.78-2.23.03-.03.05-.07.07-.1.08-.1.15-.2.22-.32.5-.77.84-1.62 1.02-2.5.02-.1.04-.2.05-.3.1-.57.14-1.15.12-1.73 0-.1-.01-.19-.02-.29.06-1.2-.15-2.42-.63-3.53-.1-.23-.2-.45-.32-.67z"/></svg>'
},
'opera': {
name: 'Opera',
icon: '<svg viewBox="0 0 24 24"><path fill="#FF1B2D" d="M12 0C5.37 0 0 5.37 0 12s5.37 12 12 12c2.75 0 5.28-.93 7.3-2.49-1.24.77-2.68 1.22-4.22 1.22-2.2 0-4.17-1.1-5.55-2.83C8.1 18.1 7.2 15.22 7.2 12s.9-6.1 2.33-7.9C10.91 2.37 12.88 1.27 15.08 1.27c1.54 0 2.98.45 4.22 1.22C17.28.93 14.75 0 12 0z"/><path fill="#FF1B2D" d="M15.08 1.27c-2.2 0-4.17 1.1-5.55 2.83C8.1 5.9 7.2 8.78 7.2 12s.9 6.1 2.33 7.9c1.38 1.73 3.35 2.83 5.55 2.83 2.2 0 4.17-1.1 5.55-2.83C22.06 18.1 22.96 15.22 22.96 12s-.9-6.1-2.33-7.9c-1.38-1.73-3.35-2.83-5.55-2.83z" opacity=".75"/></svg>'
},
'brave': {
name: 'Brave',
icon: '<svg viewBox="0 0 24 24"><path fill="#FB542B" d="M12 0L3.6 4.8v9.6L12 24l8.4-9.6V4.8L12 0zm5.7 14.1l-1.2 1.8c-.3.3-.6.6-.9.9l-2.1 1.5-1.5.9-1.5-.9-2.1-1.5c-.3-.3-.6-.6-.9-.9l-1.2-1.8c-.3-.6-.3-1.2 0-1.5l.6-1.5.6-1.2.6-1.2.3-.6c.15-.3.45-.3.6 0l.6.9c.15.3.45.3.6 0l.6-.9.6-.9c.15-.3.45-.3.6 0l.6.9.6.9c.15.3.45.3.6 0l.6-.9c.15-.3.45-.3.6 0l.3.6.6 1.2.6 1.2.6 1.5c.3.3.3.9 0 1.5z"/></svg>'
},
'yandex': {
name: 'Yandex Browser',
icon: '<svg viewBox="0 0 24 24"><path fill="#FF0000" d="M12 0C5.37 0 0 5.37 0 12s5.37 12 12 12 12-5.37 12-12S18.63 0 12 0z"/><path fill="#FFF" d="M13.5 5h-2.1l-3.9 8.1V5H5.4v14h2.1l4.05-8.55V19h2.1V5z"/></svg>'
},
'vlc': {
name: 'VLC',
icon: '<svg viewBox="0 0 24 24"><path fill="#FF8800" d="M12 1.5L7.5 16h9L12 1.5z"/><path fill="#FF5722" d="M6 18.5c-1.5 0-2.5.5-2.5 1.5s2.5 2.5 8.5 2.5 8.5-1.5 8.5-2.5-1-1.5-2.5-1.5H6z"/><path fill="#FF8800" d="M6 18.5h12l-1.5-2.5h-9L6 18.5z"/></svg>'
},
'aimp': {
name: 'AIMP',
icon: '<svg viewBox="0 0 24 24"><path fill="#F7A600" d="M12 0C5.37 0 0 5.37 0 12s5.37 12 12 12 12-5.37 12-12S18.63 0 12 0z"/><path fill="#FFF" d="M12 4l-7 14h3l1.5-3h5l1.5 3h3L12 4zm0 5l1.75 3.5h-3.5L12 9z"/></svg>'
},
'foobar': {
name: 'foobar2000',
icon: '<svg viewBox="0 0 24 24"><rect fill="#1F1A17" width="24" height="24" rx="4"/><path fill="#D89B2B" d="M6 6h3v12H6V6zm4.5 0H13v12h-2.5V6zm4 0H17v12h-2.5V6z"/></svg>'
},
'music.ui': {
name: 'Groove Music',
icon: '<svg viewBox="0 0 24 24"><circle fill="#7B83EB" cx="12" cy="12" r="11"/><path fill="#FFF" d="M15 7v7a3 3 0 11-2-2.83V7h2z"/></svg>'
},
'itunes': {
name: 'iTunes',
icon: '<svg viewBox="0 0 24 24"><path fill="#EA4CC0" d="M12 0C5.37 0 0 5.37 0 12s5.37 12 12 12 12-5.37 12-12S18.63 0 12 0z"/><path fill="#FFF" d="M16.5 6.5l-7 1.75v7.25a2.5 2.5 0 11-1.5-2.29V9.5l7-1.75v4.75a2.5 2.5 0 11-1.5-2.29V6.5z" opacity=".9"/></svg>'
},
'apple music': {
name: 'Apple Music',
icon: '<svg viewBox="0 0 24 24"><path fill="#FC3C44" d="M12 0C5.37 0 0 5.37 0 12s5.37 12 12 12 12-5.37 12-12S18.63 0 12 0z"/><path fill="#FFF" d="M16.5 6.5l-7 1.75v7.25a2.5 2.5 0 11-1.5-2.29V9.5l7-1.75v4.75a2.5 2.5 0 11-1.5-2.29V6.5z" opacity=".9"/></svg>'
},
'deezer': {
name: 'Deezer',
icon: '<svg viewBox="0 0 24 24"><rect fill="#000" width="24" height="24" rx="4"/><g fill="#A238FF"><rect x="2" y="16" width="3" height="2" rx=".5"/><rect x="6.5" y="14" width="3" height="4" rx=".5"/><rect x="11" y="10" width="3" height="8" rx=".5"/><rect x="15.5" y="12" width="3" height="6" rx=".5"/><rect x="19" y="8" width="3" height="10" rx=".5"/></g></svg>'
},
'tidal': {
name: 'TIDAL',
icon: '<svg viewBox="0 0 24 24"><path fill="#000" d="M12 4.8L8 8.8l4 4-4 4-4-4 4-4-4-4 4-4 4 4zm4 0l4 4-4 4-4-4 4-4z"/></svg>'
},
};
function resolveMediaSource(raw) {
if (!raw) return null;
const lower = raw.toLowerCase();
for (const [key, info] of Object.entries(MEDIA_SOURCES)) {
if (lower.includes(key)) return info;
}
return { name: raw.replace(/\.exe$/i, ''), icon: null };
}
// Cached DOM references (populated once after DOMContentLoaded)
const dom = {};
function cacheDom() {
dom.trackTitle = document.getElementById('track-title');
dom.artist = document.getElementById('artist');
dom.album = document.getElementById('album');
dom.miniTrackTitle = document.getElementById('mini-track-title');
dom.miniArtist = document.getElementById('mini-artist');
dom.albumArt = document.getElementById('album-art');
dom.albumArtGlow = document.getElementById('album-art-glow');
dom.miniAlbumArt = document.getElementById('mini-album-art');
dom.volumeSlider = document.getElementById('volume-slider');
dom.volumeDisplay = document.getElementById('volume-display');
dom.miniVolumeSlider = document.getElementById('mini-volume-slider');
dom.miniVolumeDisplay = document.getElementById('mini-volume-display');
dom.progressFill = document.getElementById('progress-fill');
dom.currentTime = document.getElementById('current-time');
dom.totalTime = document.getElementById('total-time');
dom.progressBar = document.getElementById('progress-bar');
dom.miniProgressFill = document.getElementById('mini-progress-fill');
dom.miniCurrentTime = document.getElementById('mini-current-time');
dom.miniTotalTime = document.getElementById('mini-total-time');
dom.playbackState = document.getElementById('playback-state');
dom.stateIcon = document.getElementById('state-icon');
dom.playPauseIcon = document.getElementById('play-pause-icon');
dom.miniPlayPauseIcon = document.getElementById('mini-play-pause-icon');
dom.muteIcon = document.getElementById('mute-icon');
dom.miniMuteIcon = document.getElementById('mini-mute-icon');
dom.statusDot = document.getElementById('status-dot');
dom.source = document.getElementById('source');
dom.sourceIcon = document.getElementById('sourceIcon');
dom.btnPlayPause = document.getElementById('btn-play-pause');
dom.btnNext = document.getElementById('btn-next');
dom.btnPrevious = document.getElementById('btn-previous');
dom.miniBtnPlayPause = document.getElementById('mini-btn-play-pause');
dom.miniPlayer = document.getElementById('mini-player');
}
// Timing constants
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;
// 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';
// ============================================================
// Internationalization (i18n)
// ============================================================
let currentLocale = 'en';
let translations = {};
const supportedLocales = {
'en': 'English',
'ru': 'Русский'
};
// Minimal inline fallback for critical UI elements
const fallbackTranslations = {
'app.title': 'Media Server',
'auth.connect': 'Connect',
'auth.placeholder': 'Enter API Token',
'player.status.connected': 'Connected',
'player.status.disconnected': 'Disconnected'
};
function t(key, params = {}) {
let text = translations[key] || fallbackTranslations[key] || key;
Object.keys(params).forEach(param => {
text = text.replace(new RegExp(`\\{${param}\\}`, 'g'), params[param]);
});
return text;
}
async function loadTranslations(locale) {
try {
const response = await fetch(`/static/locales/${locale}.json`);
if (!response.ok) {
throw new Error(`Failed to load ${locale}.json`);
}
return await response.json();
} catch (error) {
console.error(`Error loading translations for ${locale}:`, error);
if (locale !== 'en') {
return await loadTranslations('en');
}
return {};
}
}
function detectBrowserLocale() {
const browserLang = navigator.language || navigator.languages?.[0] || 'en';
const langCode = browserLang.split('-')[0];
return supportedLocales[langCode] ? langCode : 'en';
}
async function initLocale() {
const savedLocale = localStorage.getItem('locale') || detectBrowserLocale();
await setLocale(savedLocale);
}
async function setLocale(locale) {
if (!supportedLocales[locale]) {
locale = 'en';
}
translations = await loadTranslations(locale);
currentLocale = locale;
document.documentElement.setAttribute('data-locale', locale);
document.documentElement.setAttribute('lang', locale);
localStorage.setItem('locale', locale);
updateAllText();
updateLocaleSelect();
document.body.classList.remove('loading-translations');
document.body.classList.add('translations-loaded');
}
function changeLocale() {
const select = document.getElementById('locale-select');
const newLocale = select.value;
if (newLocale && newLocale !== currentLocale) {
localStorage.setItem('locale', newLocale);
setLocale(newLocale);
}
}
function updateLocaleSelect() {
const select = document.getElementById('locale-select');
if (select) {
select.value = currentLocale;
}
}
function updateAllText() {
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
el.textContent = t(key);
});
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const key = el.getAttribute('data-i18n-placeholder');
el.placeholder = t(key);
});
document.querySelectorAll('[data-i18n-title]').forEach(el => {
const key = el.getAttribute('data-i18n-title');
el.title = t(key);
});
// Re-apply dynamic content with new translations
updatePlaybackState(currentState);
const connected = ws && ws.readyState === WebSocket.OPEN;
updateConnectionStatus(connected);
if (lastStatus) {
const fallbackTitle = lastStatus.state === 'idle' ? t('player.no_media') : t('player.title_unavailable');
document.getElementById('track-title').textContent = lastStatus.title || fallbackTitle;
const initSrc = resolveMediaSource(lastStatus.source);
document.getElementById('source').textContent = initSrc ? initSrc.name : t('player.unknown_source');
document.getElementById('sourceIcon').innerHTML = initSrc?.icon || '';
}
const token = localStorage.getItem('media_server_token');
if (token) {
loadScriptsTable();
loadCallbacksTable();
loadLinksTable();
displayQuickAccess();
}
renderAccentSwatches();
}
async function fetchVersion() {
try {
const response = await fetch('/api/health');
if (response.ok) {
const data = await response.json();
const label = document.getElementById('version-label');
if (data.version) {
label.textContent = `v${data.version}`;
}
}
} catch (error) {
console.error('Error fetching version:', error);
}
}
// ============================================================
// Shared Utilities
// ============================================================
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) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function showToast(message, type = 'success') {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
container.appendChild(toast);
requestAnimationFrame(() => {
toast.classList.add('show');
});
setTimeout(() => {
toast.classList.remove('show');
toast.addEventListener('transitionend', () => toast.remove(), { once: true });
setTimeout(() => { if (toast.parentNode) toast.remove(); }, 500);
}, TOAST_DURATION_MS);
}
function closeDialog(dialog) {
dialog.classList.add('dialog-closing');
dialog.addEventListener('animationend', () => {
dialog.classList.remove('dialog-closing');
dialog.close();
}, { once: true });
}
function showConfirm(message) {
return new Promise((resolve) => {
const dialog = document.getElementById('confirmDialog');
const msg = document.getElementById('confirmDialogMessage');
const btnCancel = document.getElementById('confirmDialogCancel');
const btnConfirm = document.getElementById('confirmDialogConfirm');
msg.textContent = message;
function cleanup() {
btnCancel.removeEventListener('click', onCancel);
btnConfirm.removeEventListener('click', onConfirm);
dialog.removeEventListener('close', onClose);
closeDialog(dialog);
}
function onCancel() { cleanup(); resolve(false); }
function onConfirm() { cleanup(); resolve(true); }
function onClose() { cleanup(); resolve(false); }
btnCancel.addEventListener('click', onCancel);
btnConfirm.addEventListener('click', onConfirm);
dialog.addEventListener('close', onClose);
dialog.showModal();
});
}
// ============================================================
// API Commands
// ============================================================
async function sendCommand(endpoint, body = null) {
const token = localStorage.getItem('media_server_token');
const options = {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
};
if (body) {
options.body = JSON.stringify(body);
}
try {
const response = await fetch(`/api/media/${endpoint}`, options);
if (!response.ok) {
const data = await response.json().catch(() => ({}));
console.error(`Command ${endpoint} failed:`, response.status);
showToast(data.detail || `Command failed: ${endpoint}`, 'error');
}
} catch (error) {
console.error(`Error sending command ${endpoint}:`, error);
showToast(`Connection error: ${endpoint}`, 'error');
}
}
function togglePlayPause() {
if (currentState === 'playing') {
sendCommand('pause');
} else {
sendCommand('play');
}
}
function nextTrack() {
sendCommand('next');
}
function previousTrack() {
sendCommand('previous');
}
let lastSentVolume = -1;
function setVolume(volume) {
if (volume === lastSentVolume) return;
lastSentVolume = volume;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'volume', volume: volume }));
} else {
sendCommand('volume', { volume: volume });
}
}
function toggleMute() {
sendCommand('mute');
}
function seek(position) {
sendCommand('seek', { position: position });
}
// ============================================================
// MDI Icon System
// ============================================================
const mdiIconCache = (() => {
try {
return JSON.parse(localStorage.getItem('mdiIconCache') || '{}');
} catch { return {}; }
})();
function _persistMdiCache() {
try { localStorage.setItem('mdiIconCache', JSON.stringify(mdiIconCache)); } catch {}
}
async function fetchMdiIcon(iconName) {
const name = iconName.replace(/^mdi:/, '');
if (mdiIconCache[name]) return mdiIconCache[name];
try {
const response = await fetch(`https://api.iconify.design/mdi/${name}.svg?width=16&height=16`);
if (response.ok) {
const svg = await response.text();
mdiIconCache[name] = svg;
_persistMdiCache();
return svg;
}
} catch (e) {
console.warn('Failed to fetch MDI icon:', name, e);
}
return '<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>';
}
async function resolveMdiIcons(container) {
const els = container.querySelectorAll('[data-mdi-icon]');
await Promise.all(Array.from(els).map(async (el) => {
const icon = el.dataset.mdiIcon;
if (icon) {
el.innerHTML = await fetchMdiIcon(icon);
}
}));
}
function setupIconPreview(inputId, previewId) {
const input = document.getElementById(inputId);
const preview = document.getElementById(previewId);
if (!input || !preview) return;
let debounceTimer = null;
input.addEventListener('input', () => {
clearTimeout(debounceTimer);
const value = input.value.trim();
if (!value) {
preview.innerHTML = '';
return;
}
debounceTimer = setTimeout(async () => {
const svg = await fetchMdiIcon(value);
if (input.value.trim() === value) {
preview.innerHTML = svg;
}
}, 400);
});
}

View File

@@ -0,0 +1,414 @@
// ============================================================
// Display Brightness & Power Control
// ============================================================
let displayBrightnessTimers = {};
const DISPLAY_THROTTLE_MS = 50;
async function loadDisplayMonitors() {
const token = localStorage.getItem('media_server_token');
if (!token) return;
const container = document.getElementById('displayMonitors');
if (!container) return;
try {
const response = await fetch('/api/display/monitors?refresh=true', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) {
container.innerHTML = `<div class="empty-state-illustration">
<svg viewBox="0 0 64 64"><rect x="8" y="10" width="48" height="32" rx="3" fill="none" stroke="currentColor" stroke-width="2"/><line x1="32" y1="42" x2="32" y2="50" stroke="currentColor" stroke-width="2"/><line x1="22" y1="50" x2="42" y2="50" stroke="currentColor" stroke-width="2"/></svg>
<p data-i18n="display.error">Failed to load monitors</p>
</div>`;
return;
}
const monitors = await response.json();
if (monitors.length === 0) {
container.innerHTML = `<div class="empty-state-illustration">
<svg viewBox="0 0 64 64"><rect x="8" y="10" width="48" height="32" rx="3" fill="none" stroke="currentColor" stroke-width="2"/><line x1="32" y1="42" x2="32" y2="50" stroke="currentColor" stroke-width="2"/><line x1="22" y1="50" x2="42" y2="50" stroke="currentColor" stroke-width="2"/></svg>
<p data-i18n="display.no_monitors">No monitors detected</p>
</div>`;
return;
}
container.innerHTML = '';
monitors.forEach(monitor => {
const card = document.createElement('div');
card.className = 'display-monitor-card';
card.id = `monitor-card-${monitor.id}`;
const brightnessValue = monitor.brightness !== null ? monitor.brightness : 0;
const brightnessDisabled = monitor.brightness === null ? 'disabled' : '';
let powerBtn = '';
if (monitor.power_supported) {
powerBtn = `
<button class="display-power-btn ${monitor.power_on ? 'on' : 'off'}" id="power-btn-${monitor.id}"
onclick="toggleDisplayPower(${monitor.id}, '${monitor.name.replace(/'/g, "\\'")}')"
title="${monitor.power_on ? t('display.power_off') : t('display.power_on')}">
<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M13 3h-2v10h2V3zm4.83 2.17l-1.42 1.42A6.92 6.92 0 0119 12c0 3.87-3.13 7-7 7s-7-3.13-7-7c0-2.27 1.08-4.28 2.76-5.56L6.34 5.02A8.95 8.95 0 003 12c0 4.97 4.03 9 9 9s9-4.03 9-9a8.95 8.95 0 00-3.17-6.83z"/></svg>
</button>`;
}
const details = [monitor.resolution, monitor.manufacturer].filter(Boolean).join(' \u00B7 ');
const detailsHtml = details ? `<span class="display-monitor-details">${details}</span>` : '';
const primaryBadge = monitor.is_primary ? `<span class="display-primary-badge">${t('display.primary')}</span>` : '';
card.innerHTML = `
<div class="display-monitor-header">
<svg class="display-monitor-icon" viewBox="0 0 24 24" width="20" height="20">
<path fill="currentColor" d="M20 3H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h6v2H8v2h8v-2h-2v-2h6c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 12H4V5h16v10z"/>
</svg>
<div class="display-monitor-info">
<span class="display-monitor-name">${monitor.name}${primaryBadge}</span>
${detailsHtml}
</div>
${powerBtn}
</div>
<div class="display-brightness-control">
<svg class="display-brightness-icon" viewBox="0 0 24 24" width="16" height="16">
<path fill="currentColor" d="M20 8.69V4h-4.69L12 .69 8.69 4H4v4.69L.69 12 4 15.31V20h4.69L12 23.31 15.31 20H20v-4.69L23.31 12 20 8.69zM12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6 6 2.69 6 6-2.69 6-6 6zm0-10c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4z"/>
</svg>
<input type="range" class="display-brightness-slider" min="0" max="100" value="${brightnessValue}" ${brightnessDisabled}
oninput="onDisplayBrightnessInput(${monitor.id}, this.value)"
onchange="onDisplayBrightnessChange(${monitor.id}, this.value)">
<span class="display-brightness-value" id="brightness-val-${monitor.id}">${brightnessValue}%</span>
</div>`;
container.appendChild(card);
});
} catch (e) {
console.error('Failed to load display monitors:', e);
}
}
function onDisplayBrightnessInput(monitorId, value) {
const label = document.getElementById(`brightness-val-${monitorId}`);
if (label) label.textContent = `${value}%`;
if (displayBrightnessTimers[monitorId]) clearTimeout(displayBrightnessTimers[monitorId]);
displayBrightnessTimers[monitorId] = setTimeout(() => {
sendDisplayBrightness(monitorId, parseInt(value));
displayBrightnessTimers[monitorId] = null;
}, DISPLAY_THROTTLE_MS);
}
function onDisplayBrightnessChange(monitorId, value) {
if (displayBrightnessTimers[monitorId]) {
clearTimeout(displayBrightnessTimers[monitorId]);
displayBrightnessTimers[monitorId] = null;
}
sendDisplayBrightness(monitorId, parseInt(value));
}
async function sendDisplayBrightness(monitorId, brightness) {
const token = localStorage.getItem('media_server_token');
try {
await fetch(`/api/display/brightness/${monitorId}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ brightness })
});
} catch (e) {
console.error('Failed to set brightness:', e);
}
}
async function toggleDisplayPower(monitorId, monitorName) {
const btn = document.getElementById(`power-btn-${monitorId}`);
const isOn = btn && btn.classList.contains('on');
const newState = !isOn;
const token = localStorage.getItem('media_server_token');
try {
const response = await fetch(`/api/display/power/${monitorId}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ on: newState })
});
const data = await response.json();
if (data.success) {
if (btn) {
btn.classList.toggle('on', newState);
btn.classList.toggle('off', !newState);
btn.title = newState ? t('display.power_off') : t('display.power_on');
}
showToast(newState ? 'Monitor turned on' : 'Monitor turned off', 'success');
} else {
showToast('Failed to change monitor power', 'error');
}
} catch (e) {
console.error('Failed to set display power:', e);
showToast('Failed to change monitor power', 'error');
}
}
// ============================================================
// Header Quick Links
// ============================================================
async function loadHeaderLinks() {
const token = localStorage.getItem('media_server_token');
if (!token) return;
const container = document.getElementById('headerLinks');
if (!container) return;
try {
const response = await fetch('/api/links/list', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) return;
const links = await response.json();
container.innerHTML = '';
for (const link of links) {
const a = document.createElement('a');
a.href = link.url;
a.target = '_blank';
a.rel = 'noopener noreferrer';
a.className = 'header-link';
a.title = link.label || link.url;
const iconSvg = await fetchMdiIcon(link.icon || 'mdi:link');
a.innerHTML = iconSvg;
container.appendChild(a);
}
} catch (e) {
console.warn('Failed to load header links:', e);
}
}
// ============================================================
// Links Management
// ============================================================
let _loadLinksPromise = null;
let linkFormDirty = false;
async function loadLinksTable() {
if (_loadLinksPromise) return _loadLinksPromise;
_loadLinksPromise = _loadLinksTableImpl();
_loadLinksPromise.finally(() => { _loadLinksPromise = null; });
return _loadLinksPromise;
}
async function _loadLinksTableImpl() {
const token = localStorage.getItem('media_server_token');
const tbody = document.getElementById('linksTableBody');
try {
const response = await fetch('/api/links/list', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) {
throw new Error('Failed to fetch links');
}
const linksList = await response.json();
if (linksList.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="empty-state"><div class="empty-state-illustration"><svg viewBox="0 0 64 64"><path d="M26 20a10 10 0 010 14l-6 6a10 10 0 01-14-14l6-6a10 10 0 0114 0" fill="none" stroke="currentColor" stroke-width="2"/><path d="M38 44a10 10 0 010-14l6-6a10 10 0 0114 14l-6 6a10 10 0 01-14 0" fill="none" stroke="currentColor" stroke-width="2"/><path d="M24 40l16-16" stroke="currentColor" stroke-width="2"/></svg><p>' + t('links.empty') + '</p></div></td></tr>';
return;
}
tbody.innerHTML = linksList.map(link => `
<tr>
<td><span class="name-with-icon"><span class="table-icon" data-mdi-icon="${escapeHtml(link.icon || 'mdi:link')}"></span><code>${escapeHtml(link.name)}</code></span></td>
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"
title="${escapeHtml(link.url)}">${escapeHtml(link.url)}</td>
<td>${escapeHtml(link.label || '')}</td>
<td>
<div class="action-buttons">
<button class="action-btn" data-action="edit" data-link-name="${escapeHtml(link.name)}" title="${t('links.button.edit')}">
<svg viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
</button>
<button class="action-btn delete" data-action="delete" data-link-name="${escapeHtml(link.name)}" title="${t('links.button.delete')}">
<svg viewBox="0 0 24 24"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
</button>
</div>
</td>
</tr>
`).join('');
resolveMdiIcons(tbody);
} catch (error) {
console.error('Error loading links:', error);
tbody.innerHTML = '<tr><td colspan="4" class="empty-state" style="color: var(--error);">Failed to load links</td></tr>';
}
}
function showAddLinkDialog() {
const dialog = document.getElementById('linkDialog');
const form = document.getElementById('linkForm');
const title = document.getElementById('linkDialogTitle');
form.reset();
document.getElementById('linkOriginalName').value = '';
document.getElementById('linkIsEdit').value = 'false';
document.getElementById('linkName').disabled = false;
document.getElementById('linkIconPreview').innerHTML = '';
title.textContent = t('links.dialog.add');
linkFormDirty = false;
document.body.classList.add('dialog-open');
dialog.showModal();
}
async function showEditLinkDialog(linkName) {
const token = localStorage.getItem('media_server_token');
const dialog = document.getElementById('linkDialog');
const title = document.getElementById('linkDialogTitle');
try {
const response = await fetch('/api/links/list', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) {
throw new Error('Failed to fetch link details');
}
const linksList = await response.json();
const link = linksList.find(l => l.name === linkName);
if (!link) {
showToast(t('links.msg.not_found'), 'error');
return;
}
document.getElementById('linkOriginalName').value = linkName;
document.getElementById('linkIsEdit').value = 'true';
document.getElementById('linkName').value = linkName;
document.getElementById('linkName').disabled = true;
document.getElementById('linkUrl').value = link.url;
document.getElementById('linkIcon').value = link.icon || '';
document.getElementById('linkLabel').value = link.label || '';
document.getElementById('linkDescription').value = link.description || '';
// Update icon preview
const preview = document.getElementById('linkIconPreview');
if (link.icon) {
fetchMdiIcon(link.icon).then(svg => { preview.innerHTML = svg; });
} else {
preview.innerHTML = '';
}
title.textContent = t('links.dialog.edit');
linkFormDirty = false;
document.body.classList.add('dialog-open');
dialog.showModal();
} catch (error) {
console.error('Error loading link for edit:', error);
showToast(t('links.msg.load_failed'), 'error');
}
}
async function closeLinkDialog() {
if (linkFormDirty) {
if (!await showConfirm(t('links.confirm.unsaved'))) {
return;
}
}
const dialog = document.getElementById('linkDialog');
linkFormDirty = false;
closeDialog(dialog);
document.body.classList.remove('dialog-open');
}
async function saveLink(event) {
event.preventDefault();
const submitBtn = event.target.querySelector('button[type="submit"]');
if (submitBtn) submitBtn.disabled = true;
const token = localStorage.getItem('media_server_token');
const isEdit = document.getElementById('linkIsEdit').value === 'true';
const linkName = isEdit ?
document.getElementById('linkOriginalName').value :
document.getElementById('linkName').value;
const data = {
url: document.getElementById('linkUrl').value,
icon: document.getElementById('linkIcon').value || 'mdi:link',
label: document.getElementById('linkLabel').value || '',
description: document.getElementById('linkDescription').value || ''
};
const endpoint = isEdit ?
`/api/links/update/${linkName}` :
`/api/links/create/${linkName}`;
const method = isEdit ? 'PUT' : 'POST';
try {
const response = await fetch(endpoint, {
method,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
const result = await response.json();
if (response.ok && result.success) {
showToast(t(isEdit ? 'links.msg.updated' : 'links.msg.created'), 'success');
linkFormDirty = false;
closeLinkDialog();
} else {
showToast(result.detail || t(isEdit ? 'links.msg.update_failed' : 'links.msg.create_failed'), 'error');
}
} catch (error) {
console.error('Error saving link:', error);
showToast(t(isEdit ? 'links.msg.update_failed' : 'links.msg.create_failed'), 'error');
} finally {
if (submitBtn) submitBtn.disabled = false;
}
}
async function deleteLinkConfirm(linkName) {
if (!await showConfirm(t('links.confirm.delete').replace('{name}', linkName))) {
return;
}
const token = localStorage.getItem('media_server_token');
try {
const response = await fetch(`/api/links/delete/${linkName}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
});
const result = await response.json();
if (response.ok && result.success) {
showToast(t('links.msg.deleted'), 'success');
} else {
showToast(result.detail || t('links.msg.delete_failed'), 'error');
}
} catch (error) {
console.error('Error deleting link:', error);
showToast(t('links.msg.delete_failed'), 'error');
}
}

View File

@@ -0,0 +1,294 @@
// ============================================================
// Main: Initialization orchestrator (loaded last)
// ============================================================
window.addEventListener('DOMContentLoaded', async () => {
// Cache DOM references
cacheDom();
// Initialize theme and accent color
initTheme();
initAccentColor();
// Register service worker for PWA installability
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {});
}
// Initialize vinyl mode
applyVinylMode();
// Initialize audio visualizer
checkVisualizerAvailability().then(() => {
if (visualizerEnabled && visualizerAvailable) {
applyVisualizerMode();
}
});
// Initialize dynamic background
applyDynamicBackground();
// Initialize locale (async - loads JSON file)
await initLocale();
// Load version from health endpoint
fetchVersion();
const token = localStorage.getItem('media_server_token');
if (token) {
connectWebSocket(token);
loadScripts();
loadScriptsTable();
loadCallbacksTable();
loadLinksTable();
loadAudioDevices();
} else {
showAuthForm();
}
// Shared volume slider setup (avoids duplicate handler code)
function setupVolumeSlider(sliderId) {
const slider = document.getElementById(sliderId);
slider.addEventListener('input', (e) => {
isUserAdjustingVolume = true;
const volume = parseInt(e.target.value);
// Sync both sliders and displays
dom.volumeDisplay.textContent = `${volume}%`;
dom.miniVolumeDisplay.textContent = `${volume}%`;
dom.volumeSlider.value = volume;
dom.miniVolumeSlider.value = volume;
if (volumeUpdateTimer) clearTimeout(volumeUpdateTimer);
volumeUpdateTimer = setTimeout(() => {
setVolume(volume);
volumeUpdateTimer = null;
}, VOLUME_THROTTLE_MS);
});
slider.addEventListener('change', (e) => {
if (volumeUpdateTimer) {
clearTimeout(volumeUpdateTimer);
volumeUpdateTimer = null;
}
const volume = parseInt(e.target.value);
setVolume(volume);
setTimeout(() => { isUserAdjustingVolume = false; }, VOLUME_RELEASE_DELAY_MS);
});
}
setupVolumeSlider('volume-slider');
setupVolumeSlider('mini-volume-slider');
// Restore saved tab (migrate old tab names)
let savedTab = localStorage.getItem('activeTab') || 'player';
if (['scripts', 'callbacks', 'links'].includes(savedTab)) savedTab = 'settings';
switchTab(savedTab);
// Snap indicator to initial position without animation
const initialActiveBtn = document.querySelector('.tab-btn.active');
if (initialActiveBtn) updateTabIndicator(initialActiveBtn, false);
// Re-position tab indicator on window resize
window.addEventListener('resize', () => {
const activeBtn = document.querySelector('.tab-btn.active');
if (activeBtn) updateTabIndicator(activeBtn, false);
});
// Mini Player: Intersection Observer to show/hide when main player scrolls out of view
const playerContainer = document.querySelector('.player-container');
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (activeTab !== 'player') return;
setMiniPlayerVisible(!entry.isIntersecting);
});
}, { threshold: 0.1 });
observer.observe(playerContainer);
// Drag-to-seek for progress bars
setupProgressDrag(
document.getElementById('mini-progress-bar'),
document.getElementById('mini-progress-fill')
);
setupProgressDrag(
document.getElementById('progress-bar'),
document.getElementById('progress-fill')
);
// Enter key in token input
document.getElementById('token-input').addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
authenticate();
}
});
// Script form dirty state tracking
const scriptForm = document.getElementById('scriptForm');
scriptForm.addEventListener('input', () => {
scriptFormDirty = true;
});
scriptForm.addEventListener('change', () => {
scriptFormDirty = true;
});
// Callback form dirty state tracking
const callbackForm = document.getElementById('callbackForm');
callbackForm.addEventListener('input', () => {
callbackFormDirty = true;
});
callbackForm.addEventListener('change', () => {
callbackFormDirty = 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();
}
});
// 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();
}
});
// Delegated click handlers for script table actions (XSS-safe)
document.getElementById('scriptsTableBody').addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
const name = btn.dataset.scriptName;
if (action === 'execute') executeScriptDebug(name);
else if (action === 'edit') showEditScriptDialog(name);
else if (action === 'delete') deleteScriptConfirm(name);
});
// Delegated click handlers for callback table actions (XSS-safe)
document.getElementById('callbacksTableBody').addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
const name = btn.dataset.callbackName;
if (action === 'execute') executeCallbackDebug(name);
else if (action === 'edit') showEditCallbackDialog(name);
else if (action === 'delete') deleteCallbackConfirm(name);
});
// Link dialog backdrop click to close
const linkDialog = document.getElementById('linkDialog');
linkDialog.addEventListener('click', (e) => {
if (e.target === linkDialog) {
closeLinkDialog();
}
});
// Delegated click handlers for link table actions (XSS-safe)
document.getElementById('linksTableBody').addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
const name = btn.dataset.linkName;
if (action === 'edit') showEditLinkDialog(name);
else if (action === 'delete') deleteLinkConfirm(name);
});
// Track link form dirty state
const linkForm = document.getElementById('linkForm');
linkForm.addEventListener('input', () => {
linkFormDirty = true;
});
linkForm.addEventListener('change', () => {
linkFormDirty = true;
});
// Initialize browser toolbar and load folders
initBrowserToolbar();
if (token) {
loadMediaFolders();
}
// Icon preview for script and link dialogs
setupIconPreview('scriptIcon', 'scriptIconPreview');
setupIconPreview('linkIcon', 'linkIconPreview');
// Settings sections: restore collapse state and persist on toggle
document.querySelectorAll('.settings-section').forEach(details => {
const key = `settings_section_${details.querySelector('summary')?.getAttribute('data-i18n') || ''}`;
const saved = localStorage.getItem(key);
if (saved === 'closed') details.removeAttribute('open');
else if (saved === 'open') details.setAttribute('open', '');
details.addEventListener('toggle', () => {
localStorage.setItem(key, details.open ? 'open' : 'closed');
});
});
// Cleanup blob URLs on page unload
window.addEventListener('beforeunload', () => {
thumbnailCache.forEach(url => URL.revokeObjectURL(url));
thumbnailCache.clear();
});
// Tab bar keyboard navigation (WAI-ARIA Tabs pattern)
document.getElementById('tabBar').addEventListener('keydown', (e) => {
const tabs = Array.from(document.querySelectorAll('.tab-btn'));
const currentIdx = tabs.indexOf(document.activeElement);
if (currentIdx === -1) return;
let newIdx;
if (e.key === 'ArrowRight') {
newIdx = (currentIdx + 1) % tabs.length;
} else if (e.key === 'ArrowLeft') {
newIdx = (currentIdx - 1 + tabs.length) % tabs.length;
} else if (e.key === 'Home') {
newIdx = 0;
} else if (e.key === 'End') {
newIdx = tabs.length - 1;
} else {
return;
}
e.preventDefault();
tabs[newIdx].focus();
switchTab(tabs[newIdx].dataset.tab);
});
// Global keyboard shortcuts
document.addEventListener('keydown', (e) => {
// Skip when typing in inputs, textareas, selects, or when a dialog is open
const tag = e.target.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
if (document.querySelector('dialog[open]')) return;
switch (e.key) {
case ' ':
e.preventDefault();
togglePlayPause();
break;
case 'ArrowLeft':
e.preventDefault();
if (currentDuration > 0) seek(Math.max(0, currentPosition - 5));
break;
case 'ArrowRight':
e.preventDefault();
if (currentDuration > 0) seek(Math.min(currentDuration, currentPosition + 5));
break;
case 'ArrowUp':
e.preventDefault();
setVolume(Math.min(100, parseInt(dom.volumeSlider.value) + 5));
break;
case 'ArrowDown':
e.preventDefault();
setVolume(Math.max(0, parseInt(dom.volumeSlider.value) - 5));
break;
case 'm':
case 'M':
toggleMute();
break;
}
});
});

View File

@@ -0,0 +1,742 @@
// ============================================================
// Player: Tabs, theme, accent, vinyl, visualizer, UI updates
// ============================================================
// Tab management
let activeTab = 'player';
function setMiniPlayerVisible(visible) {
const miniPlayer = document.getElementById('mini-player');
if (visible) {
miniPlayer.classList.remove('hidden');
document.body.classList.add('mini-player-visible');
} else {
miniPlayer.classList.add('hidden');
document.body.classList.remove('mini-player-visible');
}
}
function updateTabIndicator(btn, animate = true) {
const indicator = document.getElementById('tabIndicator');
if (!indicator || !btn) return;
const tabBar = document.getElementById('tabBar');
const barRect = tabBar.getBoundingClientRect();
const btnRect = btn.getBoundingClientRect();
const offset = btnRect.left - barRect.left - parseFloat(getComputedStyle(tabBar).paddingLeft || 0);
if (!animate) indicator.style.transition = 'none';
indicator.style.width = btnRect.width + 'px';
indicator.style.transform = `translateX(${offset}px)`;
if (!animate) {
indicator.offsetHeight;
indicator.style.transition = '';
}
}
function switchTab(tabName) {
activeTab = tabName;
document.querySelectorAll('[data-tab-content]').forEach(el => {
el.classList.remove('active');
el.style.display = '';
});
const target = document.querySelector(`[data-tab-content="${tabName}"]`);
if (target) {
target.classList.add('active');
}
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.remove('active');
btn.setAttribute('aria-selected', 'false');
btn.setAttribute('tabindex', '-1');
});
const activeBtn = document.querySelector(`.tab-btn[data-tab="${tabName}"]`);
if (activeBtn) {
activeBtn.classList.add('active');
activeBtn.setAttribute('aria-selected', 'true');
activeBtn.setAttribute('tabindex', '0');
updateTabIndicator(activeBtn);
}
if (tabName === 'display') {
loadDisplayMonitors();
}
localStorage.setItem('activeTab', tabName);
if (tabName !== 'player') {
setMiniPlayerVisible(true);
} else {
const playerContainer = document.querySelector('.player-container');
const rect = playerContainer.getBoundingClientRect();
const inView = rect.top < window.innerHeight && rect.bottom > 0;
setMiniPlayerVisible(!inView);
}
}
// Theme management
function initTheme() {
const savedTheme = localStorage.getItem('theme') || 'dark';
setTheme(savedTheme);
}
function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
const sunIcon = document.getElementById('theme-icon-sun');
const moonIcon = document.getElementById('theme-icon-moon');
if (theme === 'light') {
sunIcon.style.display = 'none';
moonIcon.style.display = 'block';
} else {
sunIcon.style.display = 'block';
moonIcon.style.display = 'none';
}
const metaThemeColor = document.querySelector('meta[name="theme-color"]');
if (metaThemeColor) {
metaThemeColor.setAttribute('content', theme === 'light' ? '#ffffff' : '#121212');
}
if (typeof updateBackgroundColors === 'function') updateBackgroundColors();
}
function toggleTheme() {
const currentTheme = document.documentElement.getAttribute('data-theme') || 'dark';
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
setTheme(newTheme);
}
// Accent color management
const accentPresets = [
{ name: 'Green', color: '#1db954', hover: '#1ed760' },
{ name: 'Blue', color: '#3b82f6', hover: '#60a5fa' },
{ name: 'Purple', color: '#8b5cf6', hover: '#a78bfa' },
{ name: 'Pink', color: '#ec4899', hover: '#f472b6' },
{ name: 'Orange', color: '#f97316', hover: '#fb923c' },
{ name: 'Red', color: '#ef4444', hover: '#f87171' },
{ name: 'Teal', color: '#14b8a6', hover: '#2dd4bf' },
{ name: 'Cyan', color: '#06b6d4', hover: '#22d3ee' },
{ name: 'Yellow', color: '#eab308', hover: '#facc15' },
];
function lightenColor(hex, percent) {
const num = parseInt(hex.replace('#', ''), 16);
const r = Math.min(255, (num >> 16) + Math.round(255 * percent / 100));
const g = Math.min(255, ((num >> 8) & 0xff) + Math.round(255 * percent / 100));
const b = Math.min(255, (num & 0xff) + Math.round(255 * percent / 100));
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
}
function initAccentColor() {
const saved = localStorage.getItem('accentColor');
if (saved) {
const preset = accentPresets.find(p => p.color === saved);
if (preset) {
applyAccentColor(preset.color, preset.hover);
} else {
applyAccentColor(saved, lightenColor(saved, 15));
}
}
renderAccentSwatches();
}
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();
}
function renderAccentSwatches() {
const dropdown = document.getElementById('accentDropdown');
if (!dropdown) return;
const current = localStorage.getItem('accentColor') || '#1db954';
const isCustom = !accentPresets.some(p => p.color === current);
const swatches = accentPresets.map(p =>
`<div class="accent-swatch ${p.color === current ? 'active' : ''}"
style="background: ${p.color}"
onclick="selectAccentColor('${p.color}', '${p.hover}')"
title="${p.name}"></div>`
).join('');
const customRow = `
<div class="accent-custom-row ${isCustom ? 'active' : ''}" onclick="document.getElementById('accentCustomInput').click()">
<span class="accent-custom-swatch" style="background: ${isCustom ? current : '#888'}"></span>
<span class="accent-custom-label">${t('accent.custom')}</span>
<input type="color" id="accentCustomInput" value="${current}"
onclick="event.stopPropagation()"
onchange="selectAccentColor(this.value, lightenColor(this.value, 15))">
</div>`;
dropdown.innerHTML = swatches + customRow;
}
function selectAccentColor(color, hover) {
applyAccentColor(color, hover);
renderAccentSwatches();
document.getElementById('accentDropdown').classList.remove('open');
}
function toggleAccentPicker() {
document.getElementById('accentDropdown').classList.toggle('open');
}
document.addEventListener('click', (e) => {
if (!e.target.closest('.accent-picker')) {
document.getElementById('accentDropdown')?.classList.remove('open');
}
});
// Vinyl mode
let vinylMode = localStorage.getItem('vinylMode') === 'true';
function getVinylAngle() {
const art = document.getElementById('album-art');
if (!art) return 0;
const st = getComputedStyle(art);
const tr = st.transform;
if (!tr || tr === 'none') return 0;
const m = tr.match(/matrix\((.+)\)/);
if (!m) return 0;
const vals = m[1].split(',').map(Number);
const angle = Math.round(Math.atan2(vals[1], vals[0]) * (180 / Math.PI));
return ((angle % 360) + 360) % 360;
}
function saveVinylAngle() {
if (!vinylMode) return;
localStorage.setItem('vinylAngle', getVinylAngle());
}
function restoreVinylAngle() {
const saved = localStorage.getItem('vinylAngle');
if (saved) {
const art = document.getElementById('album-art');
if (art) art.style.setProperty('--vinyl-offset', `${saved}deg`);
}
}
setInterval(saveVinylAngle, 2000);
window.addEventListener('beforeunload', saveVinylAngle);
function toggleVinylMode() {
if (vinylMode) saveVinylAngle();
vinylMode = !vinylMode;
localStorage.setItem('vinylMode', vinylMode);
applyVinylMode();
}
function applyVinylMode() {
const container = document.querySelector('.album-art-container');
const btn = document.getElementById('vinylToggle');
if (!container) return;
if (vinylMode) {
container.classList.add('vinyl');
if (btn) btn.classList.add('active');
restoreVinylAngle();
updateVinylSpin();
} else {
saveVinylAngle();
container.classList.remove('vinyl', 'spinning', 'paused');
if (btn) btn.classList.remove('active');
}
}
function updateVinylSpin() {
const container = document.querySelector('.album-art-container');
if (!container || !vinylMode) return;
container.classList.remove('spinning', 'paused');
if (currentPlayState === 'playing') {
container.classList.add('spinning');
} else if (currentPlayState === 'paused') {
container.classList.add('paused');
}
}
// Audio Visualizer
let visualizerEnabled = localStorage.getItem('visualizerEnabled') === 'true';
let visualizerAvailable = false;
let visualizerCtx = null;
let visualizerAnimFrame = null;
let frequencyData = null;
let smoothedFrequencies = null;
const VISUALIZER_SMOOTHING = 0.15;
async function checkVisualizerAvailability() {
try {
const token = localStorage.getItem('media_server_token');
const resp = await fetch('/api/media/visualizer/status', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (resp.ok) {
const data = await resp.json();
visualizerAvailable = data.available;
}
} catch (e) {
visualizerAvailable = false;
}
const btn = document.getElementById('visualizerToggle');
if (btn) btn.style.display = visualizerAvailable ? '' : 'none';
}
function toggleVisualizer() {
visualizerEnabled = !visualizerEnabled;
localStorage.setItem('visualizerEnabled', visualizerEnabled);
applyVisualizerMode();
}
function applyVisualizerMode() {
const container = document.querySelector('.album-art-container');
const btn = document.getElementById('visualizerToggle');
if (!container) return;
if (visualizerEnabled && visualizerAvailable) {
container.classList.add('visualizer-active');
if (btn) btn.classList.add('active');
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'enable_visualizer' }));
}
initVisualizerCanvas();
startVisualizerRender();
} else {
container.classList.remove('visualizer-active');
if (btn) btn.classList.remove('active');
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'disable_visualizer' }));
}
stopVisualizerRender();
}
// Sync the audio device status badge with the new capture state
updateAudioDeviceStatus({
running: visualizerEnabled && visualizerAvailable,
available: visualizerAvailable
});
}
function initVisualizerCanvas() {
const canvas = document.getElementById('spectrogram-canvas');
if (!canvas) return;
visualizerCtx = canvas.getContext('2d');
canvas.width = 300;
canvas.height = 64;
}
function startVisualizerRender() {
if (visualizerAnimFrame) return;
renderVisualizerFrame();
}
function stopVisualizerRender() {
if (visualizerAnimFrame) {
cancelAnimationFrame(visualizerAnimFrame);
visualizerAnimFrame = null;
}
const canvas = document.getElementById('spectrogram-canvas');
if (visualizerCtx && canvas) {
visualizerCtx.clearRect(0, 0, canvas.width, canvas.height);
}
const art = document.getElementById('album-art');
if (art) {
art.style.transform = '';
art.style.removeProperty('--vinyl-scale');
}
const glow = document.getElementById('album-art-glow');
if (glow) glow.style.opacity = '';
frequencyData = null;
smoothedFrequencies = null;
}
function renderVisualizerFrame() {
visualizerAnimFrame = requestAnimationFrame(renderVisualizerFrame);
const canvas = document.getElementById('spectrogram-canvas');
if (!frequencyData || !visualizerCtx || !canvas) return;
const bins = frequencyData.frequencies;
const numBins = bins.length;
const w = canvas.width;
const h = canvas.height;
const gap = 2;
const barWidth = (w / numBins) - gap;
const accent = getComputedStyle(document.documentElement)
.getPropertyValue('--accent').trim();
if (!smoothedFrequencies || smoothedFrequencies.length !== numBins) {
smoothedFrequencies = new Array(numBins).fill(0);
}
for (let i = 0; i < numBins; i++) {
smoothedFrequencies[i] = smoothedFrequencies[i] * VISUALIZER_SMOOTHING
+ bins[i] * (1 - VISUALIZER_SMOOTHING);
}
visualizerCtx.clearRect(0, 0, w, h);
for (let i = 0; i < numBins; i++) {
const barHeight = Math.max(1, smoothedFrequencies[i] * h);
const x = i * (barWidth + gap) + gap / 2;
const y = h - barHeight;
const grad = visualizerCtx.createLinearGradient(x, y, x, h);
grad.addColorStop(0, accent);
grad.addColorStop(1, accent + '30');
visualizerCtx.fillStyle = grad;
visualizerCtx.beginPath();
visualizerCtx.roundRect(x, y, barWidth, barHeight, 1.5);
visualizerCtx.fill();
}
const bass = frequencyData.bass || 0;
const scale = 1 + bass * 0.04;
const art = document.getElementById('album-art');
if (art) {
if (vinylMode) {
art.style.setProperty('--vinyl-scale', scale);
} else {
art.style.transform = `scale(${scale})`;
}
}
const glow = document.getElementById('album-art-glow');
if (glow) {
glow.style.opacity = (0.4 + bass * 0.4).toFixed(2);
}
}
// Audio device selection
async function loadAudioDevices() {
const section = document.getElementById('audioDeviceSection');
const select = document.getElementById('audioDeviceSelect');
if (!section || !select) return;
try {
const token = localStorage.getItem('media_server_token');
const [devicesResp, statusResp] = await Promise.all([
fetch('/api/media/visualizer/devices', {
headers: { 'Authorization': `Bearer ${token}` }
}),
fetch('/api/media/visualizer/status', {
headers: { 'Authorization': `Bearer ${token}` }
})
]);
if (!devicesResp.ok || !statusResp.ok) return;
const devices = await devicesResp.json();
const status = await statusResp.json();
if (!status.available && devices.length === 0) {
section.style.display = 'none';
return;
}
section.style.display = '';
while (select.options.length > 1) select.remove(1);
for (const dev of devices) {
const opt = document.createElement('option');
opt.value = dev.name;
opt.textContent = dev.name;
select.appendChild(opt);
}
if (status.current_device) {
for (let i = 0; i < select.options.length; i++) {
if (select.options[i].value === status.current_device) {
select.selectedIndex = i;
break;
}
}
}
updateAudioDeviceStatus(status);
} catch (e) {
section.style.display = 'none';
}
}
function updateAudioDeviceStatus(status) {
const el = document.getElementById('audioDeviceStatus');
if (!el) return;
// Badge reflects local visualizer state (capture is on-demand per subscriber)
if (visualizerEnabled && status.available) {
el.className = 'audio-device-status active';
el.textContent = t('settings.audio.status_active');
} else if (status.available) {
el.className = 'audio-device-status available';
el.textContent = t('settings.audio.status_available');
} else {
el.className = 'audio-device-status unavailable';
el.textContent = t('settings.audio.status_unavailable');
}
}
async function onAudioDeviceChanged() {
const select = document.getElementById('audioDeviceSelect');
if (!select) return;
const deviceName = select.value || null;
const token = localStorage.getItem('media_server_token');
try {
const resp = await fetch('/api/media/visualizer/device', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ device_name: deviceName })
});
if (resp.ok) {
const result = await resp.json();
updateAudioDeviceStatus({ available: result.success, ...result });
await checkVisualizerAvailability();
if (visualizerEnabled) applyVisualizerMode();
showToast(t('settings.audio.device_changed'), 'success');
} else {
showToast(t('settings.audio.device_change_failed'), 'error');
}
} catch (e) {
showToast(t('settings.audio.device_change_failed'), 'error');
}
}
// ============================================================
// UI State Updates
// ============================================================
let lastArtworkKey = null;
let currentArtworkBlobUrl = null;
let lastPositionUpdate = 0;
let lastPositionValue = 0;
let interpolationInterval = null;
function setupProgressDrag(bar, fill) {
let dragging = false;
function getPercent(clientX) {
const rect = bar.getBoundingClientRect();
return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
}
function updatePreview(percent) {
fill.style.width = (percent * 100) + '%';
}
function handleStart(clientX) {
if (currentDuration <= 0) return;
dragging = true;
bar.classList.add('dragging');
updatePreview(getPercent(clientX));
}
function handleMove(clientX) {
if (!dragging) return;
updatePreview(getPercent(clientX));
}
function handleEnd(clientX) {
if (!dragging) return;
dragging = false;
bar.classList.remove('dragging');
const percent = getPercent(clientX);
seek(percent * currentDuration);
}
bar.addEventListener('mousedown', (e) => { e.preventDefault(); handleStart(e.clientX); });
document.addEventListener('mousemove', (e) => { handleMove(e.clientX); });
document.addEventListener('mouseup', (e) => { handleEnd(e.clientX); });
bar.addEventListener('touchstart', (e) => { handleStart(e.touches[0].clientX); }, { passive: true });
document.addEventListener('touchmove', (e) => { if (dragging) handleMove(e.touches[0].clientX); });
document.addEventListener('touchend', (e) => {
if (dragging) {
const touch = e.changedTouches[0];
handleEnd(touch.clientX);
}
});
bar.addEventListener('click', (e) => {
if (currentDuration > 0) {
seek(getPercent(e.clientX) * currentDuration);
}
});
}
function updateUI(status) {
lastStatus = status;
const fallbackTitle = status.state === 'idle' ? t('player.no_media') : t('player.title_unavailable');
dom.trackTitle.textContent = status.title || fallbackTitle;
dom.artist.textContent = status.artist || '';
dom.album.textContent = status.album || '';
dom.miniTrackTitle.textContent = status.title || fallbackTitle;
dom.miniArtist.textContent = status.artist || '';
const previousState = currentState;
currentState = status.state;
updatePlaybackState(status.state);
const altText = status.title && status.artist
? `${status.artist} ${status.title}`
: status.title || t('player.no_media');
dom.albumArt.alt = altText;
dom.miniAlbumArt.alt = altText;
const artworkSource = status.album_art_url || null;
const artworkKey = `${status.title || ''}|${status.artist || ''}|${artworkSource || ''}`;
if (artworkKey !== lastArtworkKey) {
lastArtworkKey = artworkKey;
const placeholderArt = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Crect fill='%23282828' width='300' height='300'/%3E%3Cpath fill='%236a6a6a' d='M150 80c-38.66 0-70 31.34-70 70s31.34 70 70 70 70-31.34 70-70-31.34-70-70-70zm0 20c27.614 0 50 22.386 50 50s-22.386 50-50 50-50-22.386-50-50 22.386-50 50-50zm0 30a20 20 0 100 40 20 20 0 000-40z'/%3E%3C/svg%3E";
const placeholderGlow = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Crect fill='%23282828' width='300' height='300'/%3E%3C/svg%3E";
if (artworkSource) {
const token = localStorage.getItem('media_server_token');
fetch(`/api/media/artwork?_=${Date.now()}`, {
headers: { 'Authorization': `Bearer ${token}` }
})
.then(r => r.ok ? r.blob() : null)
.then(blob => {
if (!blob) return;
const oldBlobUrl = currentArtworkBlobUrl;
const url = URL.createObjectURL(blob);
currentArtworkBlobUrl = url;
dom.albumArt.src = url;
dom.miniAlbumArt.src = url;
if (dom.albumArtGlow) dom.albumArtGlow.src = url;
if (oldBlobUrl) setTimeout(() => URL.revokeObjectURL(oldBlobUrl), 1000);
})
.catch(err => console.error('Artwork fetch failed:', err));
} else {
if (currentArtworkBlobUrl) {
URL.revokeObjectURL(currentArtworkBlobUrl);
currentArtworkBlobUrl = null;
}
dom.albumArt.src = placeholderArt;
dom.miniAlbumArt.src = placeholderArt;
if (dom.albumArtGlow) dom.albumArtGlow.src = placeholderGlow;
}
}
if (status.duration && status.position !== null) {
currentDuration = status.duration;
currentPosition = status.position;
lastPositionUpdate = Date.now();
lastPositionValue = status.position;
updateProgress(status.position, status.duration);
}
if (!isUserAdjustingVolume) {
dom.volumeSlider.value = status.volume;
dom.volumeDisplay.textContent = `${status.volume}%`;
dom.miniVolumeSlider.value = status.volume;
dom.miniVolumeDisplay.textContent = `${status.volume}%`;
}
updateMuteIcon(status.muted);
const src = resolveMediaSource(status.source);
dom.source.textContent = src ? src.name : t('player.unknown_source');
dom.sourceIcon.innerHTML = src?.icon || '';
const hasMedia = status.state !== 'idle';
dom.btnPlayPause.disabled = !hasMedia;
dom.btnNext.disabled = !hasMedia;
dom.btnPrevious.disabled = !hasMedia;
dom.miniBtnPlayPause.disabled = !hasMedia;
if (status.state === 'playing' && previousState !== 'playing') {
startPositionInterpolation();
} else if (status.state !== 'playing' && previousState === 'playing') {
stopPositionInterpolation();
}
}
function updatePlaybackState(state) {
currentPlayState = state;
switch(state) {
case 'playing':
dom.playbackState.textContent = t('state.playing');
dom.stateIcon.innerHTML = SVG_PLAY;
dom.playPauseIcon.innerHTML = SVG_PAUSE;
dom.miniPlayPauseIcon.innerHTML = SVG_PAUSE;
break;
case 'paused':
dom.playbackState.textContent = t('state.paused');
dom.stateIcon.innerHTML = SVG_PAUSE;
dom.playPauseIcon.innerHTML = SVG_PLAY;
dom.miniPlayPauseIcon.innerHTML = SVG_PLAY;
break;
case 'stopped':
dom.playbackState.textContent = t('state.stopped');
dom.stateIcon.innerHTML = SVG_STOP;
dom.playPauseIcon.innerHTML = SVG_PLAY;
dom.miniPlayPauseIcon.innerHTML = SVG_PLAY;
break;
default:
dom.playbackState.textContent = t('state.idle');
dom.stateIcon.innerHTML = SVG_IDLE;
dom.playPauseIcon.innerHTML = SVG_PLAY;
dom.miniPlayPauseIcon.innerHTML = SVG_PLAY;
}
updateVinylSpin();
}
function updateProgress(position, duration) {
const percent = (position / duration) * 100;
const widthStr = `${percent}%`;
const currentStr = formatTime(position);
const totalStr = formatTime(duration);
const posRound = Math.round(position);
const durRound = Math.round(duration);
dom.progressFill.style.width = widthStr;
dom.currentTime.textContent = currentStr;
dom.totalTime.textContent = totalStr;
dom.progressBar.dataset.duration = duration;
dom.progressBar.setAttribute('aria-valuenow', posRound);
dom.progressBar.setAttribute('aria-valuemax', durRound);
dom.miniProgressFill.style.width = widthStr;
dom.miniCurrentTime.textContent = currentStr;
dom.miniTotalTime.textContent = totalStr;
if (dom.miniPlayer) dom.miniPlayer.style.setProperty('--mini-progress', widthStr);
const miniBar = document.getElementById('mini-progress-bar');
miniBar.setAttribute('aria-valuenow', posRound);
miniBar.setAttribute('aria-valuemax', durRound);
}
function startPositionInterpolation() {
if (interpolationInterval) {
clearInterval(interpolationInterval);
}
interpolationInterval = setInterval(() => {
if (currentState === 'playing' && currentDuration > 0 && lastPositionUpdate > 0) {
const elapsed = (Date.now() - lastPositionUpdate) / 1000;
const interpolatedPosition = Math.min(lastPositionValue + elapsed, currentDuration);
updateProgress(interpolatedPosition, currentDuration);
}
}, POSITION_INTERPOLATION_MS);
}
function stopPositionInterpolation() {
if (interpolationInterval) {
clearInterval(interpolationInterval);
interpolationInterval = null;
}
}
function updateMuteIcon(muted) {
const path = muted ? SVG_MUTED : SVG_UNMUTED;
dom.muteIcon.innerHTML = path;
dom.miniMuteIcon.innerHTML = path;
}

View File

@@ -0,0 +1,537 @@
// ============================================================
// Scripts: CRUD, quick access, execution dialog
// ============================================================
let scriptFormDirty = false;
async function loadScripts() {
const token = localStorage.getItem('media_server_token');
try {
const response = await fetch('/api/scripts/list', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
scripts = await response.json();
displayQuickAccess();
}
} catch (error) {
console.error('Error loading scripts:', error);
}
}
let _quickAccessGen = 0;
async function displayQuickAccess() {
const gen = ++_quickAccessGen;
const grid = document.getElementById('scripts-grid');
const fragment = document.createDocumentFragment();
const hasScripts = scripts.length > 0;
let hasLinks = false;
scripts.forEach(script => {
const button = document.createElement('button');
button.className = 'script-btn';
button.onclick = () => executeScript(script.name, button);
if (script.icon) {
const iconEl = document.createElement('div');
iconEl.className = 'script-icon';
iconEl.setAttribute('data-mdi-icon', script.icon);
button.appendChild(iconEl);
}
const label = document.createElement('div');
label.className = 'script-label';
label.textContent = script.label || script.name;
button.appendChild(label);
if (script.description) {
const description = document.createElement('div');
description.className = 'script-description';
description.textContent = script.description;
button.appendChild(description);
}
fragment.appendChild(button);
});
try {
const token = localStorage.getItem('media_server_token');
if (token) {
const response = await fetch('/api/links/list', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (gen !== _quickAccessGen) return;
if (response.ok) {
const links = await response.json();
hasLinks = links.length > 0;
links.forEach(link => {
const card = document.createElement('a');
card.className = 'script-btn link-card';
card.href = link.url;
card.target = '_blank';
card.rel = 'noopener noreferrer';
if (link.icon) {
const iconEl = document.createElement('div');
iconEl.className = 'script-icon';
iconEl.setAttribute('data-mdi-icon', link.icon);
card.appendChild(iconEl);
}
const label = document.createElement('div');
label.className = 'script-label';
label.textContent = link.label || link.name;
card.appendChild(label);
if (link.description) {
const desc = document.createElement('div');
desc.className = 'script-description';
desc.textContent = link.description;
card.appendChild(desc);
}
fragment.appendChild(card);
});
}
}
} catch (e) {
if (gen !== _quickAccessGen) return;
console.warn('Failed to load links for quick access:', e);
}
if (!hasScripts && !hasLinks) {
const empty = document.createElement('div');
empty.className = 'scripts-empty empty-state-illustration';
empty.innerHTML = `<svg viewBox="0 0 64 64"><path d="M20 8l-8 48"/><path d="M44 8l8 48"/><path d="M10 24h44"/><path d="M8 40h44"/></svg><p>${t('quick_access.no_items')}</p>`;
fragment.prepend(empty);
}
grid.innerHTML = '';
grid.appendChild(fragment);
resolveMdiIcons(grid);
}
async function executeScript(scriptName, buttonElement) {
const token = localStorage.getItem('media_server_token');
buttonElement.classList.add('executing');
try {
const response = await fetch(`/api/scripts/execute/${scriptName}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ args: [] })
});
const result = await response.json();
if (response.ok && result.success) {
showToast(`${scriptName} executed successfully`, 'success');
} else {
showToast(`Failed to execute ${scriptName}`, 'error');
}
} catch (error) {
console.error(`Error executing script ${scriptName}:`, error);
showToast(`Error executing ${scriptName}`, 'error');
} finally {
buttonElement.classList.remove('executing');
}
}
// ============================================================
// Script Management CRUD
// ============================================================
let _loadScriptsPromise = null;
async function loadScriptsTable() {
if (_loadScriptsPromise) return _loadScriptsPromise;
_loadScriptsPromise = _loadScriptsTableImpl();
_loadScriptsPromise.finally(() => { _loadScriptsPromise = null; });
return _loadScriptsPromise;
}
async function _loadScriptsTableImpl() {
const token = localStorage.getItem('media_server_token');
const tbody = document.getElementById('scriptsTableBody');
try {
const response = await fetch('/api/scripts/list', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) {
throw new Error('Failed to fetch scripts');
}
const scriptsList = await response.json();
if (scriptsList.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="empty-state"><div class="empty-state-illustration"><svg viewBox="0 0 64 64"><rect x="8" y="4" width="48" height="56" rx="4"/><path d="M20 20h24M20 32h16M20 44h20"/></svg><p>' + t('scripts.empty') + '</p></div></td></tr>';
return;
}
tbody.innerHTML = scriptsList.map(script => `
<tr>
<td><span class="name-with-icon">${script.icon ? `<span class="table-icon" data-mdi-icon="${escapeHtml(script.icon)}"></span>` : ''}<code>${escapeHtml(script.name)}</code></span></td>
<td>${escapeHtml(script.label || script.name)}</td>
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"
title="${escapeHtml(script.command || 'N/A')}">${escapeHtml(script.command || 'N/A')}</td>
<td>${script.timeout}s</td>
<td>
<div class="action-buttons">
<button class="action-btn execute" data-action="execute" data-script-name="${escapeHtml(script.name)}" title="Execute script">
<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
</button>
<button class="action-btn" data-action="edit" data-script-name="${escapeHtml(script.name)}" title="Edit script">
<svg viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
</button>
<button class="action-btn delete" data-action="delete" data-script-name="${escapeHtml(script.name)}" title="Delete script">
<svg viewBox="0 0 24 24"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
</button>
</div>
</td>
</tr>
`).join('');
resolveMdiIcons(tbody);
} catch (error) {
console.error('Error loading scripts:', error);
tbody.innerHTML = '<tr><td colspan="5" class="empty-state" style="color: var(--error);">Failed to load scripts</td></tr>';
}
}
function showAddScriptDialog() {
const dialog = document.getElementById('scriptDialog');
const form = document.getElementById('scriptForm');
const title = document.getElementById('dialogTitle');
form.reset();
document.getElementById('scriptOriginalName').value = '';
document.getElementById('scriptIsEdit').value = 'false';
document.getElementById('scriptName').disabled = false;
document.getElementById('scriptIconPreview').innerHTML = '';
title.textContent = t('scripts.dialog.add');
scriptFormDirty = false;
document.body.classList.add('dialog-open');
dialog.showModal();
}
async function showEditScriptDialog(scriptName) {
const token = localStorage.getItem('media_server_token');
const dialog = document.getElementById('scriptDialog');
const title = document.getElementById('dialogTitle');
try {
const response = await fetch('/api/scripts/list', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) {
throw new Error('Failed to fetch script details');
}
const scriptsList = await response.json();
const script = scriptsList.find(s => s.name === scriptName);
if (!script) {
showToast('Script not found', 'error');
return;
}
document.getElementById('scriptOriginalName').value = scriptName;
document.getElementById('scriptIsEdit').value = 'true';
document.getElementById('scriptName').value = scriptName;
document.getElementById('scriptName').disabled = true;
document.getElementById('scriptLabel').value = script.label || '';
document.getElementById('scriptCommand').value = script.command || '';
document.getElementById('scriptDescription').value = script.description || '';
document.getElementById('scriptIcon').value = script.icon || '';
document.getElementById('scriptTimeout').value = script.timeout || 30;
const preview = document.getElementById('scriptIconPreview');
if (script.icon) {
fetchMdiIcon(script.icon).then(svg => { preview.innerHTML = svg; });
} else {
preview.innerHTML = '';
}
title.textContent = t('scripts.dialog.edit');
scriptFormDirty = false;
document.body.classList.add('dialog-open');
dialog.showModal();
} catch (error) {
console.error('Error loading script for edit:', error);
showToast('Failed to load script details', 'error');
}
}
async function closeScriptDialog() {
if (scriptFormDirty) {
if (!await showConfirm(t('scripts.confirm.unsaved'))) {
return;
}
}
const dialog = document.getElementById('scriptDialog');
scriptFormDirty = false;
closeDialog(dialog);
document.body.classList.remove('dialog-open');
}
async function saveScript(event) {
event.preventDefault();
const submitBtn = event.target.querySelector('button[type="submit"]');
if (submitBtn) submitBtn.disabled = true;
const token = localStorage.getItem('media_server_token');
const isEdit = document.getElementById('scriptIsEdit').value === 'true';
const scriptName = isEdit ?
document.getElementById('scriptOriginalName').value :
document.getElementById('scriptName').value;
const data = {
command: document.getElementById('scriptCommand').value,
label: document.getElementById('scriptLabel').value || null,
description: document.getElementById('scriptDescription').value || '',
icon: document.getElementById('scriptIcon').value || null,
timeout: parseInt(document.getElementById('scriptTimeout').value) || 30,
shell: true
};
const endpoint = isEdit ?
`/api/scripts/update/${scriptName}` :
`/api/scripts/create/${scriptName}`;
const method = isEdit ? 'PUT' : 'POST';
try {
const response = await fetch(endpoint, {
method,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
const result = await response.json();
if (response.ok && result.success) {
showToast(`Script ${isEdit ? 'updated' : 'created'} successfully`, 'success');
scriptFormDirty = false;
closeScriptDialog();
} else {
showToast(result.detail || `Failed to ${isEdit ? 'update' : 'create'} script`, 'error');
}
} catch (error) {
console.error('Error saving script:', error);
showToast(`Error ${isEdit ? 'updating' : 'creating'} script`, 'error');
} finally {
if (submitBtn) submitBtn.disabled = false;
}
}
async function deleteScriptConfirm(scriptName) {
if (!await showConfirm(t('scripts.confirm.delete').replace('{name}', scriptName))) {
return;
}
const token = localStorage.getItem('media_server_token');
try {
const response = await fetch(`/api/scripts/delete/${scriptName}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
});
const result = await response.json();
if (response.ok && result.success) {
showToast('Script deleted successfully', 'success');
} else {
showToast(result.detail || 'Failed to delete script', 'error');
}
} catch (error) {
console.error('Error deleting script:', error);
showToast('Error deleting script', 'error');
}
}
// ============================================================
// Execution Result Dialog (shared by scripts and callbacks)
// ============================================================
function closeExecutionDialog() {
const dialog = document.getElementById('executionDialog');
closeDialog(dialog);
document.body.classList.remove('dialog-open');
}
function showExecutionResult(name, result, type = 'script') {
const dialog = document.getElementById('executionDialog');
const title = document.getElementById('executionDialogTitle');
const statusDiv = document.getElementById('executionStatus');
const outputSection = document.getElementById('outputSection');
const errorSection = document.getElementById('errorSection');
const outputPre = document.getElementById('executionOutput');
const errorPre = document.getElementById('executionError');
title.textContent = `Execution Result: ${name}`;
const success = result.success && result.exit_code === 0;
const statusClass = success ? 'success' : 'error';
const statusText = success ? 'Success' : 'Failed';
statusDiv.innerHTML = `
<div class="status-item ${statusClass}">
<label>Status</label>
<value>${statusText}</value>
</div>
<div class="status-item">
<label>Exit Code</label>
<value>${result.exit_code !== undefined ? result.exit_code : 'N/A'}</value>
</div>
<div class="status-item">
<label>Duration</label>
<value>${result.execution_time !== undefined && result.execution_time !== null ? result.execution_time.toFixed(3) + 's' : 'N/A'}</value>
</div>
`;
outputSection.style.display = 'block';
if (result.stdout && result.stdout.trim()) {
outputPre.textContent = result.stdout;
} else {
outputPre.textContent = '(no output)';
outputPre.style.fontStyle = 'italic';
outputPre.style.color = 'var(--text-secondary)';
}
if (result.stderr && result.stderr.trim()) {
errorSection.style.display = 'block';
errorPre.textContent = result.stderr;
errorPre.style.fontStyle = 'normal';
errorPre.style.color = 'var(--error)';
} else if (!success && result.error) {
errorSection.style.display = 'block';
errorPre.textContent = result.error;
errorPre.style.fontStyle = 'normal';
errorPre.style.color = 'var(--error)';
} else {
errorSection.style.display = 'none';
}
dialog.showModal();
}
async function executeScriptDebug(scriptName) {
const token = localStorage.getItem('media_server_token');
const dialog = document.getElementById('executionDialog');
const title = document.getElementById('executionDialogTitle');
const statusDiv = document.getElementById('executionStatus');
title.textContent = `Executing: ${scriptName}`;
statusDiv.innerHTML = `
<div class="status-item">
<label>Status</label>
<value><span class="loading-spinner"></span> Running...</value>
</div>
`;
document.getElementById('outputSection').style.display = 'none';
document.getElementById('errorSection').style.display = 'none';
document.body.classList.add('dialog-open');
dialog.showModal();
try {
const response = await fetch(`/api/scripts/execute/${scriptName}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ args: [] })
});
const result = await response.json();
if (response.ok) {
showExecutionResult(scriptName, result, 'script');
} else {
showExecutionResult(scriptName, {
success: false,
exit_code: -1,
error: result.detail || 'Execution failed',
stderr: result.detail || 'Unknown error'
}, 'script');
}
} catch (error) {
console.error(`Error executing script ${scriptName}:`, error);
showExecutionResult(scriptName, {
success: false,
exit_code: -1,
error: error.message,
stderr: `Network error: ${error.message}`
}, 'script');
}
}
async function executeCallbackDebug(callbackName) {
const token = localStorage.getItem('media_server_token');
const dialog = document.getElementById('executionDialog');
const title = document.getElementById('executionDialogTitle');
const statusDiv = document.getElementById('executionStatus');
title.textContent = `Executing: ${callbackName}`;
statusDiv.innerHTML = `
<div class="status-item">
<label>Status</label>
<value><span class="loading-spinner"></span> Running...</value>
</div>
`;
document.getElementById('outputSection').style.display = 'none';
document.getElementById('errorSection').style.display = 'none';
document.body.classList.add('dialog-open');
dialog.showModal();
try {
const response = await fetch(`/api/callbacks/execute/${callbackName}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
const result = await response.json();
if (response.ok) {
showExecutionResult(callbackName, result, 'callback');
} else {
showExecutionResult(callbackName, {
success: false,
exit_code: -1,
error: result.detail || 'Execution failed',
stderr: result.detail || 'Unknown error'
}, 'callback');
}
} catch (error) {
console.error(`Error executing callback ${callbackName}:`, error);
showExecutionResult(callbackName, {
success: false,
exit_code: -1,
error: error.message,
stderr: `Network error: ${error.message}`
}, 'callback');
}
}

View File

@@ -0,0 +1,169 @@
// ============================================================
// WebSocket: Connection, reconnection, authentication
// ============================================================
let reconnectTimeout = null;
let pingInterval = null;
let wsReconnectAttempts = 0;
function showAuthForm(errorMessage = '') {
const overlay = document.getElementById('auth-overlay');
overlay.classList.remove('hidden');
const errorEl = document.getElementById('auth-error');
if (errorMessage) {
errorEl.textContent = errorMessage;
errorEl.classList.add('visible');
} else {
errorEl.classList.remove('visible');
}
}
function hideAuthForm() {
document.getElementById('auth-overlay').classList.add('hidden');
}
function authenticate() {
const token = document.getElementById('token-input').value.trim();
if (!token) {
showAuthForm(t('auth.required'));
return;
}
localStorage.setItem('media_server_token', token);
connectWebSocket(token);
}
function clearToken() {
localStorage.removeItem('media_server_token');
if (ws) {
ws.close();
}
showAuthForm(t('auth.cleared'));
}
function connectWebSocket(token) {
if (pingInterval) {
clearInterval(pingInterval);
pingInterval = null;
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/media/ws?token=${encodeURIComponent(token)}`;
ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('WebSocket connected');
wsReconnectAttempts = 0;
updateConnectionStatus(true);
hideConnectionBanner();
hideAuthForm();
loadScripts();
loadScriptsTable();
loadCallbacksTable();
loadLinksTable();
loadHeaderLinks();
loadAudioDevices();
if (visualizerEnabled && visualizerAvailable) {
ws.send(JSON.stringify({ type: 'enable_visualizer' }));
}
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'status' || msg.type === 'status_update') {
updateUI(msg.data);
} else if (msg.type === 'scripts_changed') {
console.log('Scripts changed, reloading...');
loadScripts();
loadScriptsTable();
} else if (msg.type === 'links_changed') {
console.log('Links changed, reloading...');
loadHeaderLinks();
loadLinksTable();
displayQuickAccess();
} else if (msg.type === 'audio_data') {
frequencyData = msg.data;
} else if (msg.type === 'error') {
console.error('WebSocket error:', msg.message);
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
updateConnectionStatus(false);
};
ws.onclose = (event) => {
console.log('WebSocket closed:', event.code);
updateConnectionStatus(false);
stopPositionInterpolation();
if (event.code === 4001) {
localStorage.removeItem('media_server_token');
showAuthForm(t('auth.invalid'));
} else if (event.code !== 1000) {
wsReconnectAttempts++;
if (wsReconnectAttempts <= WS_MAX_RECONNECT_ATTEMPTS) {
const delay = Math.min(
WS_BACKOFF_BASE_MS * Math.pow(1.5, wsReconnectAttempts - 1),
WS_BACKOFF_MAX_MS
);
console.log(`Reconnecting in ${Math.round(delay / 1000)}s (attempt ${wsReconnectAttempts}/${WS_MAX_RECONNECT_ATTEMPTS})...`);
if (wsReconnectAttempts >= 3) {
showConnectionBanner(t('connection.reconnecting').replace('{attempt}', wsReconnectAttempts), false);
}
reconnectTimeout = setTimeout(() => {
const savedToken = localStorage.getItem('media_server_token');
if (savedToken) {
connectWebSocket(savedToken);
}
}, delay);
} else {
showConnectionBanner(t('connection.lost'), true);
}
}
};
pingInterval = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }));
}
}, WS_PING_INTERVAL_MS);
}
function updateConnectionStatus(connected) {
if (connected) {
dom.statusDot.classList.add('connected');
} else {
dom.statusDot.classList.remove('connected');
}
}
function showConnectionBanner(message, showButton) {
const banner = document.getElementById('connectionBanner');
const text = document.getElementById('connectionBannerText');
const btn = document.getElementById('connectionBannerBtn');
text.textContent = message;
btn.style.display = showButton ? '' : 'none';
banner.classList.remove('hidden');
}
function hideConnectionBanner() {
const banner = document.getElementById('connectionBanner');
banner.classList.add('hidden');
}
function manualReconnect() {
const savedToken = localStorage.getItem('media_server_token');
if (savedToken) {
wsReconnectAttempts = 0;
hideConnectionBanner();
connectWebSocket(savedToken);
}
}

View File

@@ -10,6 +10,7 @@
"auth.cleared": "Token cleared. Please enter a new token.",
"auth.required": "Please enter a token",
"player.theme": "Toggle theme",
"accent.custom": "Custom",
"player.locale": "Change language",
"player.previous": "Previous",
"player.play": "Play/Pause",
@@ -18,8 +19,12 @@
"player.status.connected": "Connected",
"player.status.disconnected": "Disconnected",
"player.no_media": "No media playing",
"player.title_unavailable": "Title unavailable",
"player.source": "Source:",
"player.unknown_source": "Unknown",
"player.vinyl": "Vinyl mode",
"player.visualizer": "Audio visualizer",
"player.background": "Dynamic background",
"state.playing": "Playing",
"state.paused": "Paused",
"state.stopped": "Stopped",
@@ -64,6 +69,10 @@
"scripts.msg.load_failed": "Failed to load script details",
"scripts.msg.list_failed": "Failed to load scripts",
"scripts.confirm.delete": "Are you sure you want to delete the script \"{name}\"?",
"scripts.execution.title": "Execution Result",
"scripts.execution.output": "Output",
"scripts.execution.error_output": "Error Output",
"scripts.execution.close": "Close",
"scripts.confirm.unsaved": "You have unsaved changes. Are you sure you want to discard them?",
"callbacks.management": "Callback Management",
"callbacks.description": "Callbacks are scripts triggered automatically by media control events (play, pause, stop, etc.)",
@@ -108,16 +117,51 @@
"callbacks.msg.list_failed": "Failed to load callbacks",
"callbacks.confirm.delete": "Are you sure you want to delete the callback \"{name}\"?",
"callbacks.confirm.unsaved": "You have unsaved changes. Are you sure you want to discard them?",
"tab.player": "Player",
"tab.browser": "Browser",
"tab.quick_access": "Quick Access",
"tab.settings": "Settings",
"tab.display": "Display",
"settings.section.scripts": "Scripts",
"settings.section.callbacks": "Callbacks",
"settings.section.links": "Links",
"settings.section.audio": "Audio",
"settings.audio.description": "Select which audio output device to capture for the visualizer.",
"settings.audio.device": "Loopback Device",
"settings.audio.auto": "Auto-detect",
"settings.audio.status_active": "Capturing audio",
"settings.audio.status_available": "Available, not capturing",
"settings.audio.status_unavailable": "Unavailable",
"settings.audio.device_changed": "Audio device changed",
"settings.audio.device_change_failed": "Failed to change audio device",
"quick_access.no_items": "No quick actions or links configured",
"display.loading": "Loading monitors...",
"display.error": "Failed to load monitors",
"display.no_monitors": "No monitors detected",
"display.power_on": "Turn on",
"display.power_off": "Turn off",
"display.primary": "Primary",
"browser.title": "Media Browser",
"browser.home": "Home",
"browser.manage_folders": "Manage Folders",
"browser.select_folder": "Select a folder...",
"browser.select_folder_option": "Select a folder...",
"browser.no_folder_selected": "Select a folder to browse media files",
"browser.no_items": "No media files found in this folder",
"browser.view_grid": "Grid view",
"browser.view_compact": "Compact view",
"browser.view_list": "List view",
"browser.search": "Search...",
"browser.items_per_page": "Items per page:",
"browser.page": "Page",
"browser.previous": "Previous",
"browser.next": "Next",
"browser.download": "Download",
"browser.play_success": "Playing {filename}",
"browser.play_error": "Failed to play file",
"browser.play_all": "Play All",
"browser.play_all_success": "Playing {count} files",
"browser.play_all_error": "Failed to play folder",
"browser.error_loading": "Error loading directory",
"browser.error_loading_folders": "Failed to load media folders",
"browser.manage_folders_hint": "Folder management coming soon! For now, edit config.yaml to add media folders.",
@@ -131,5 +175,45 @@
"browser.folder_dialog.path_help": "Absolute path to media directory",
"browser.folder_dialog.enabled": "Enabled",
"browser.folder_dialog.cancel": "Cancel",
"browser.folder_dialog.save": "Save"
"browser.folder_dialog.save": "Save",
"browser.download_error": "Failed to download file",
"connection.reconnecting": "Connection lost. Reconnecting (attempt {attempt})...",
"connection.lost": "Connection lost. Server may be unavailable.",
"connection.reconnect": "Reconnect",
"dialog.cancel": "Cancel",
"dialog.confirm": "Confirm",
"links.description": "Quick links displayed as icons in the header bar. Click an icon to open the URL in a new tab.",
"links.empty": "No links configured. Click 'Add' to create one.",
"links.table.name": "Name",
"links.table.url": "URL",
"links.table.label": "Label",
"links.table.actions": "Actions",
"links.dialog.add": "Add Link",
"links.dialog.edit": "Edit Link",
"links.field.name": "Link Name *",
"links.field.url": "URL *",
"links.field.icon": "Icon (MDI)",
"links.field.label": "Label",
"links.field.description": "Description",
"links.placeholder.name": "Only letters, numbers, and underscores allowed",
"links.placeholder.url": "https://example.com",
"links.placeholder.icon": "mdi:link",
"links.placeholder.label": "Tooltip text",
"links.placeholder.description": "What does this link point to?",
"links.button.cancel": "Cancel",
"links.button.save": "Save",
"links.button.edit": "Edit",
"links.button.delete": "Delete",
"links.msg.created": "Link created successfully",
"links.msg.updated": "Link updated successfully",
"links.msg.create_failed": "Failed to create link",
"links.msg.update_failed": "Failed to update link",
"links.msg.deleted": "Link deleted successfully",
"links.msg.delete_failed": "Failed to delete link",
"links.msg.not_found": "Link not found",
"links.msg.load_failed": "Failed to load link details",
"links.confirm.delete": "Are you sure you want to delete the link \"{name}\"?",
"links.confirm.unsaved": "You have unsaved changes. Are you sure you want to discard them?",
"footer.created_by": "Created by",
"footer.source_code": "Source Code"
}

View File

@@ -10,6 +10,7 @@
"auth.cleared": "Токен очищен. Пожалуйста, введите новый токен.",
"auth.required": "Пожалуйста, введите токен",
"player.theme": "Переключить тему",
"accent.custom": "Свой цвет",
"player.locale": "Изменить язык",
"player.previous": "Предыдущий",
"player.play": "Воспроизвести/Пауза",
@@ -18,8 +19,12 @@
"player.status.connected": "Подключено",
"player.status.disconnected": "Отключено",
"player.no_media": "Медиа не воспроизводится",
"player.title_unavailable": "Название недоступно",
"player.source": "Источник:",
"player.unknown_source": "Неизвестно",
"player.vinyl": "Режим винила",
"player.visualizer": "Аудио визуализатор",
"player.background": "Динамический фон",
"state.playing": "Воспроизведение",
"state.paused": "Пауза",
"state.stopped": "Остановлено",
@@ -64,6 +69,10 @@
"scripts.msg.load_failed": "Не удалось загрузить данные скрипта",
"scripts.msg.list_failed": "Не удалось загрузить скрипты",
"scripts.confirm.delete": "Вы уверены, что хотите удалить скрипт \"{name}\"?",
"scripts.execution.title": "Результат выполнения",
"scripts.execution.output": "Вывод",
"scripts.execution.error_output": "Вывод ошибок",
"scripts.execution.close": "Закрыть",
"scripts.confirm.unsaved": "У вас есть несохраненные изменения. Вы уверены, что хотите отменить их?",
"callbacks.management": "Управление Обратными Вызовами",
"callbacks.description": "Обратные вызовы - это скрипты, автоматически запускаемые при событиях управления медиа (воспроизведение, пауза, остановка и т.д.)",
@@ -108,16 +117,51 @@
"callbacks.msg.list_failed": "Не удалось загрузить обратные вызовы",
"callbacks.confirm.delete": "Вы уверены, что хотите удалить обратный вызов \"{name}\"?",
"callbacks.confirm.unsaved": "У вас есть несохраненные изменения. Вы уверены, что хотите отменить их?",
"tab.player": "Плеер",
"tab.browser": "Браузер",
"tab.quick_access": "Быстрый Доступ",
"tab.settings": "Настройки",
"tab.display": "Дисплей",
"settings.section.scripts": "Скрипты",
"settings.section.callbacks": "Колбэки",
"settings.section.links": "Ссылки",
"settings.section.audio": "Аудио",
"settings.audio.description": "Выберите аудиоустройство для захвата звука визуализатора.",
"settings.audio.device": "Устройство захвата",
"settings.audio.auto": "Автоопределение",
"settings.audio.status_active": "Захват аудио",
"settings.audio.status_available": "Доступно, не захватывает",
"settings.audio.status_unavailable": "Недоступно",
"settings.audio.device_changed": "Аудиоустройство изменено",
"settings.audio.device_change_failed": "Не удалось изменить аудиоустройство",
"quick_access.no_items": "Быстрые действия и ссылки не настроены",
"display.loading": "Загрузка мониторов...",
"display.error": "Не удалось загрузить мониторы",
"display.no_monitors": "Мониторы не обнаружены",
"display.power_on": "Включить",
"display.power_off": "Выключить",
"display.primary": "Основной",
"browser.title": "Медиа Браузер",
"browser.home": "Главная",
"browser.manage_folders": "Управление папками",
"browser.select_folder": "Выберите папку...",
"browser.select_folder_option": "Выберите папку...",
"browser.no_folder_selected": "Выберите папку для просмотра медиафайлов",
"browser.no_items": "В этой папке не найдено медиафайлов",
"browser.view_grid": "Сетка",
"browser.view_compact": "Компактный вид",
"browser.view_list": "Список",
"browser.search": "Поиск...",
"browser.items_per_page": "Элементов на странице:",
"browser.page": "Страница",
"browser.previous": "Предыдущая",
"browser.next": "Следующая",
"browser.download": "Скачать",
"browser.play_success": "Воспроизведение {filename}",
"browser.play_error": "Не удалось воспроизвести файл",
"browser.play_all": "Воспроизвести все",
"browser.play_all_success": "Воспроизведение {count} файлов",
"browser.play_all_error": "Не удалось воспроизвести папку",
"browser.error_loading": "Ошибка загрузки каталога",
"browser.error_loading_folders": "Не удалось загрузить медиа папки",
"browser.manage_folders_hint": "Управление папками скоро появится! Пока редактируйте config.yaml для добавления медиа папок.",
@@ -131,5 +175,45 @@
"browser.folder_dialog.path_help": "Абсолютный путь к медиа каталогу",
"browser.folder_dialog.enabled": "Включено",
"browser.folder_dialog.cancel": "Отмена",
"browser.folder_dialog.save": "Сохранить"
"browser.folder_dialog.save": "Сохранить",
"browser.download_error": "Не удалось скачать файл",
"connection.reconnecting": "Соединение потеряно. Переподключение (попытка {attempt})...",
"connection.lost": "Соединение потеряно. Сервер может быть недоступен.",
"connection.reconnect": "Переподключиться",
"dialog.cancel": "Отмена",
"dialog.confirm": "Подтвердить",
"links.description": "Быстрые ссылки, отображаемые в виде иконок в шапке. Нажмите на иконку, чтобы открыть URL в новой вкладке.",
"links.empty": "Ссылки не настроены. Нажмите 'Добавить' для создания.",
"links.table.name": "Имя",
"links.table.url": "URL",
"links.table.label": "Метка",
"links.table.actions": "Действия",
"links.dialog.add": "Добавить Ссылку",
"links.dialog.edit": "Редактировать Ссылку",
"links.field.name": "Имя Ссылки *",
"links.field.url": "URL *",
"links.field.icon": "Иконка (MDI)",
"links.field.label": "Метка",
"links.field.description": "Описание",
"links.placeholder.name": "Только буквы, цифры и подчеркивания",
"links.placeholder.url": "https://example.com",
"links.placeholder.icon": "mdi:link",
"links.placeholder.label": "Текст подсказки",
"links.placeholder.description": "Куда ведет эта ссылка?",
"links.button.cancel": "Отмена",
"links.button.save": "Сохранить",
"links.button.edit": "Редактировать",
"links.button.delete": "Удалить",
"links.msg.created": "Ссылка создана успешно",
"links.msg.updated": "Ссылка обновлена успешно",
"links.msg.create_failed": "Не удалось создать ссылку",
"links.msg.update_failed": "Не удалось обновить ссылку",
"links.msg.deleted": "Ссылка удалена успешно",
"links.msg.delete_failed": "Не удалось удалить ссылку",
"links.msg.not_found": "Ссылка не найдена",
"links.msg.load_failed": "Не удалось загрузить данные ссылки",
"links.confirm.delete": "Вы уверены, что хотите удалить ссылку \"{name}\"?",
"links.confirm.unsaved": "У вас есть несохраненные изменения. Вы уверены, что хотите отменить их?",
"footer.created_by": "Создано",
"footer.source_code": "Исходный код"
}

View File

@@ -0,0 +1,23 @@
{
"name": "Media Server",
"short_name": "Media",
"description": "Remote media player control and file browser",
"start_url": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#121212",
"theme_color": "#121212",
"icons": [
{
"src": "/static/icons/icon.svg",
"sizes": "any",
"type": "image/svg+xml"
},
{
"src": "/static/icons/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "maskable"
}
]
}

15
media_server/static/sw.js Normal file
View File

@@ -0,0 +1,15 @@
// Minimal service worker for PWA installability.
// This app requires a live WebSocket connection, so offline caching is not useful.
// All fetch requests are passed through to the network.
self.addEventListener('install', () => {
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim());
});
self.addEventListener('fetch', (event) => {
event.respondWith(fetch(event.request));
});

View File

@@ -40,6 +40,12 @@ windows = [
"pywin32>=306",
"comtypes>=1.2.0",
"pycaw>=20230407",
"screen-brightness-control>=0.20.0",
"monitorcontrol>=3.0.0",
]
visualizer = [
"soundcard>=0.4.0",
"numpy>=1.24.0",
]
dev = [
"pytest>=7.0",

View File

@@ -0,0 +1,19 @@
Unregister-ScheduledTask -TaskName "MediaServer" -Confirm:$false -ErrorAction SilentlyContinue
# Get the media-server directory (parent of scripts folder)
$serverRoot = (Get-Item $PSScriptRoot).Parent.FullName
$vbsPath = Join-Path $PSScriptRoot "start-hidden.vbs"
if (-not (Test-Path $vbsPath)) {
Write-Error "start-hidden.vbs not found in scripts folder."
exit 1
}
# Launch via wscript + VBS to run python completely hidden (no console window)
$action = New-ScheduledTaskAction -Execute "wscript.exe" -Argument "`"$vbsPath`"" -WorkingDirectory $serverRoot
$trigger = New-ScheduledTaskTrigger -AtLogon -User "$env:USERNAME"
$principal = New-ScheduledTaskPrincipal -UserId "$env:USERNAME" -LogonType Interactive -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable
Register-ScheduledTask -TaskName "MediaServer" -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Description "Media Server for Home Assistant"
Write-Host "Scheduled task 'MediaServer' created with working directory: $serverRoot"

View File

@@ -1,24 +0,0 @@
@echo off
REM Media Server Restart Script
REM This script restarts the media server
echo Restarting Media Server...
echo.
REM Stop the server first
echo [1/2] Stopping server...
call "%~dp0\stop-server.bat"
REM Wait a moment
timeout /t 2 /nobreak >nul
REM Change to parent directory (media-server root)
cd /d "%~dp0\.."
REM Start the server
echo.
echo [2/2] Starting server...
python -m media_server.main
REM If the server exits, pause to show any error messages
pause

View File

@@ -0,0 +1,35 @@
# Restart the Media Server
# Stop any running instance
$procs = Get-Process -Name 'media-server' -ErrorAction SilentlyContinue
foreach ($p in $procs) {
Write-Host "Stopping server (PID $($p.Id))..."
Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
}
if ($procs) { Start-Sleep -Seconds 2 }
# Merge registry PATH with current PATH so newly-installed tools are visible
$regUser = [Environment]::GetEnvironmentVariable('PATH', 'User')
if ($regUser) {
$currentDirs = $env:PATH -split ';' | ForEach-Object { $_.TrimEnd('\') }
foreach ($dir in ($regUser -split ';')) {
if ($dir -and ($currentDirs -notcontains $dir.TrimEnd('\'))) {
$env:PATH = "$env:PATH;$dir"
}
}
}
# Start server detached
Write-Host "Starting server..."
Start-Process -FilePath 'media-server' `
-WorkingDirectory 'c:\Users\Alexei\Documents\haos-integration-media-player\media-server' `
-WindowStyle Hidden
Start-Sleep -Seconds 3
# Verify it's running
$check = Get-Process -Name 'media-server' -ErrorAction SilentlyContinue
if ($check) {
Write-Host "Server started (PID $($check[0].Id))"
} else {
Write-Host "WARNING: Server does not appear to be running!"
}

7
scripts/start-hidden.vbs Normal file
View File

@@ -0,0 +1,7 @@
Set WshShell = CreateObject("WScript.Shell")
' Get the directory of this script (scripts\), then go up to media-server root
scriptDir = CreateObject("Scripting.FileSystemObject").GetParentFolderName(WScript.ScriptFullName)
serverRoot = CreateObject("Scripting.FileSystemObject").GetParentFolderName(scriptDir)
WshShell.CurrentDirectory = serverRoot
' Run python completely hidden (0 = hidden, False = don't wait)
WshShell.Run "python -m media_server.main", 0, False