From a20812ec292d8e649a03da1f7ee4244b96c202e9 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sun, 1 Mar 2026 13:17:56 +0300 Subject: [PATCH] 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 --- README.md | 37 +++++++++++++-- media_server/main.py | 9 ++++ media_server/static/css/styles.css | 73 ++++++++++++++++++++++++++++++ media_server/static/icons/icon.svg | 10 ++++ media_server/static/index.html | 11 ++++- media_server/static/js/main.js | 5 ++ media_server/static/js/player.js | 5 ++ media_server/static/manifest.json | 23 ++++++++++ media_server/static/sw.js | 15 ++++++ 9 files changed, 182 insertions(+), 6 deletions(-) create mode 100644 media_server/static/icons/icon.svg create mode 100644 media_server/static/manifest.json create mode 100644 media_server/static/sw.js diff --git a/README.md b/README.md index 8cf294d..eb6c324 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,10 @@ 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 @@ -36,10 +39,13 @@ The media server includes a built-in web interface for controlling and monitorin - **Mini player** - Sticky compact player that appears when scrolling away from the main player - **Connection status indicator** - Know when you're connected - **Token authentication** - Saved in browser localStorage -- **Responsive design** - Works on desktop and mobile -- **Dark and light themes** - Toggle between dark and light modes -- **Accent color picker** - Choose from 9 preset accent colors (green, blue, purple, pink, orange, red, teal, cyan, yellow) -- **Tab-based navigation** - Player, Browser, Quick Actions, Scripts, and Callbacks tabs +- **Audio spectrum visualizer** - Real-time frequency bars with beat-reactive album art scaling and glow (on-demand WASAPI loopback capture) +- **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 @@ -58,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: diff --git a/media_server/main.py b/media_server/main.py index 598d0a0..8c396f0 100644 --- a/media_server/main.py +++ b/media_server/main.py @@ -154,6 +154,15 @@ def create_app() -> FastAPI: # Mount static files and serve UI at root static_dir = Path(__file__).parent / "static" if static_dir.exists(): + @app.get("/sw.js", include_in_schema=False) + async def serve_service_worker(): + """Serve service worker from root scope for PWA installability.""" + return FileResponse( + static_dir / "sw.js", + media_type="application/javascript", + headers={"Cache-Control": "no-cache"}, + ) + app.mount("/static", StaticFiles(directory=str(static_dir)), name="static") @app.get("/", include_in_schema=False) diff --git a/media_server/static/css/styles.css b/media_server/static/css/styles.css index f5d291e..983e22d 100644 --- a/media_server/static/css/styles.css +++ b/media_server/static/css/styles.css @@ -3067,6 +3067,10 @@ footer .separator { padding-top: calc(0.5rem + 2px); } + .mini-nav-btn { + display: none; + } + .mini-player-info { min-width: 120px; } @@ -3164,3 +3168,72 @@ footer .separator { justify-content: flex-start; } } + +/* ======================================== + PWA Standalone & Mobile Polish + ======================================== */ + +html { + overscroll-behavior: none; +} + +/* Safe area insets for notched phones (viewport-fit=cover) */ +.container { + padding-left: max(0.75rem, env(safe-area-inset-left)); + padding-right: max(0.75rem, env(safe-area-inset-right)); +} + +.mini-player { + padding-bottom: max(0.75rem, env(safe-area-inset-bottom)); + padding-left: max(1rem, env(safe-area-inset-left)); + padding-right: max(1rem, env(safe-area-inset-right)); +} + +footer { + padding-bottom: max(0.75rem, env(safe-area-inset-bottom)); +} + +body.mini-player-visible footer { + padding-bottom: calc(70px + env(safe-area-inset-bottom, 0px)); +} + +.connection-banner { + padding-top: max(10px, env(safe-area-inset-top)); +} + +/* Touch optimization: eliminate 300ms tap delay */ +.controls button, +.mini-controls button, +.mini-control-btn, +.tab-btn, +.header-btn, +.header-link, +.mute-btn, +.vinyl-toggle-btn, +.view-toggle-btn, +.browser-item, +.browser-list-item, +.script-btn, +.action-btn { + touch-action: manipulation; +} + +@media (max-width: 600px) { + .container { + padding-left: max(0.5rem, env(safe-area-inset-left)); + padding-right: max(0.5rem, env(safe-area-inset-right)); + } + + .mini-player { + padding-bottom: max(0.5rem, env(safe-area-inset-bottom)); + padding-left: max(0.75rem, env(safe-area-inset-left)); + padding-right: max(0.75rem, env(safe-area-inset-right)); + } +} + +@media (display-mode: standalone) { + body { + overscroll-behavior-y: none; + -webkit-overflow-scrolling: touch; + } +} diff --git a/media_server/static/icons/icon.svg b/media_server/static/icons/icon.svg new file mode 100644 index 0000000..d26b69a --- /dev/null +++ b/media_server/static/icons/icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/media_server/static/index.html b/media_server/static/index.html index 8f54bd7..e45941f 100644 --- a/media_server/static/index.html +++ b/media_server/static/index.html @@ -2,9 +2,16 @@ - + Media Server - + + + + + + + + diff --git a/media_server/static/js/main.js b/media_server/static/js/main.js index 13e63b4..4f4e54c 100644 --- a/media_server/static/js/main.js +++ b/media_server/static/js/main.js @@ -10,6 +10,11 @@ window.addEventListener('DOMContentLoaded', async () => { initTheme(); initAccentColor(); + // Register service worker for PWA installability + if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('/sw.js').catch(() => {}); + } + // Initialize vinyl mode applyVinylMode(); diff --git a/media_server/static/js/player.js b/media_server/static/js/player.js index d960cc4..6c0f5c7 100644 --- a/media_server/static/js/player.js +++ b/media_server/static/js/player.js @@ -94,6 +94,11 @@ function setTheme(theme) { 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'); + } } function toggleTheme() { diff --git a/media_server/static/manifest.json b/media_server/static/manifest.json new file mode 100644 index 0000000..3b766b3 --- /dev/null +++ b/media_server/static/manifest.json @@ -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" + } + ] +} diff --git a/media_server/static/sw.js b/media_server/static/sw.js new file mode 100644 index 0000000..83bd7bc --- /dev/null +++ b/media_server/static/sw.js @@ -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)); +});